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 7980f71321..dd604a9cdf 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -9,6 +9,7 @@ ignorePaths: - .clang-tidy - src/test/app/wasm_fixtures/**/*.wat - src/test/app/wasm_fixtures/*.c + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true @@ -71,6 +72,7 @@ words: - canonicality - cdylib - canonicalised + - cctools - changespq - checkme - choco @@ -112,6 +114,7 @@ words: - disablerepo - distro - doxyfile + - dsymutil - dxrpl - elgamal - emittance @@ -170,6 +173,7 @@ words: - LOCALGOOD - logwstream - Lombrozo + - lresolv - lseq - lsmf - ltype @@ -223,6 +227,7 @@ words: - Nyffenegger - onlatest - ostr + - otool - oxalica - pargs - partitioner @@ -252,11 +257,15 @@ words: - Raphson - rcflags - replayer + - repodata + - repomd - rerandomize - rerandomization - rerandomized - rerandomizes - rerere + - retargeted + - retargets - retriable - RIPD - ripdtop @@ -292,6 +301,7 @@ words: - sles - soci - socidb + - Sonatype - sponsee - sponsees - SRPMS @@ -313,6 +323,7 @@ words: - summands - superpeer - superpeers + - Swatinem - takergets - takerpays - ters @@ -368,13 +379,17 @@ words: - wthread - xbridge - xchain + - xcrun - xfloat - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld - xrplf - xxhash - xxhasher + - zstdio - CGNAT + - ungated diff --git a/.envrc b/.envrc index cecf4b4767..a3f6be96ea 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,10 @@ watch_file nix/*.nix +# Pinned Rust toolchain, read by nix/packages.nix via fromRustupToolchainFile. +watch_file rust-toolchain.toml + +# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any +# change in there has to invalidate direnv's cached environment. +watch_dir conan + use flake diff --git a/.github/actions/cargo-cache/action.yml b/.github/actions/cargo-cache/action.yml new file mode 100644 index 0000000000..f716d3e4a4 --- /dev/null +++ b/.github/actions/cargo-cache/action.yml @@ -0,0 +1,39 @@ +name: Use cargo artifacts cache +description: > + Cache the cargo build artifacts with rust-cache. Never caches ~/.cargo/bin: + when saving the cache, rust-cache deletes all binaries that were already + present there, which on persistent self-hosted runners wipes the tools + installed by prepare-runner. Harmless on ephemeral runners, but kept + consistent everywhere. + +inputs: + workspaces: + description: "Workspaces to cache, as 'workspace -> target' lines." + required: false + default: crates + key: + description: "Additional part of the cache key." + required: false + default: "" + cache-directories: + description: "Additional non-workspace directories to cache." + required: false + default: "" + save-if: + description: > + Condition for saving the cache after the job. Defaults to save only from develop branch + required: false + default: ${{ github.ref == 'refs/heads/develop' }} + +runs: + using: composite + + steps: + - name: Use cargo artifacts cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: "false" + cache-directories: ${{ inputs.cache-directories }} + key: ${{ inputs.key }} + save-if: ${{ inputs.save-if }} + workspaces: ${{ inputs.workspaces }} diff --git a/.github/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..e03170b2c8 --- /dev/null +++ b/.github/actions/release-info/action.yml @@ -0,0 +1,44 @@ +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.release_info.outputs.channel }} + pkg_release: + description: "The package release number: 1 for a tag, .git otherwise." + value: ${{ steps.release_info.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}" + + - name: Determine release channel and package release + id: release_info + uses: XRPLF/actions/release-info@7cc0e4a8d9d0b838f92c48d312856b190341bbba diff --git a/.github/actions/setup-nix-env/action.yml b/.github/actions/setup-nix-env/action.yml new file mode 100644 index 0000000000..38b8365649 --- /dev/null +++ b/.github/actions/setup-nix-env/action.yml @@ -0,0 +1,70 @@ +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. + + # In RUNNER_TEMP, which the runner empties per job, like the `.conan2` + # prepare-runner hands the system toolchain - but under its own name: + # that Conan is a different version, and the two would migrate each + # other's cache. + echo "CONAN_HOME=${RUNNER_TEMP}/.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..7361a3db63 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,8 @@ updates: directories: - / - .github/actions/build-deps/ - - .github/actions/generate-version/ + - .github/actions/cargo-cache/ + - .github/actions/release-info/ - .github/actions/set-compiler-env/ - .github/actions/setup-conan/ schedule: @@ -19,3 +20,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/pull_request_template.md b/.github/pull_request_template.md index 95d75c04b4..e0d511c467 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,7 +18,7 @@ If too broad, please consider splitting into multiple PRs. If there is a relevant task or issue, please link it here. --> -### Context of Change +## Context of Change -### API Impact +## API Impact 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 0b6053f922..458c49712d 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/IPAddress.h b/include/xrpl/beast/net/IPAddress.h index 7422778ea2..e636a69ce7 100644 --- a/include/xrpl/beast/net/IPAddress.h +++ b/include/xrpl/beast/net/IPAddress.h @@ -103,7 +103,7 @@ namespace boost { template <> struct hash<::beast::ip::Address> { - explicit hash() = default; + hash() = default; std::size_t operator()(::beast::ip::Address const& addr) const diff --git a/include/xrpl/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/config/Constants.h b/include/xrpl/config/Constants.h index 251a9699bc..19f5dc7c5b 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -128,6 +128,7 @@ struct Keys static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; + static constexpr auto kMaxWaitingLedgers = "max_waiting_ledgers"; static constexpr auto kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; diff --git a/include/xrpl/core/HashRouter.h b/include/xrpl/core/HashRouter.h index 20aafecc5f..25e4df3d0d 100644 --- a/include/xrpl/core/HashRouter.h +++ b/include/xrpl/core/HashRouter.h @@ -34,7 +34,10 @@ enum class HashRouterFlags : std::uint16_t { PRIVATE4 = 0x0800, // Used in EscrowFinish.cpp PRIVATE5 = 0x1000, - PRIVATE6 = 0x2000 + PRIVATE6 = 0x2000, + // Used in apply.cpp + PRIVATE7 = 0x4000, + PRIVATE8 = 0x8000 }; constexpr HashRouterFlags 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 2b4eeb04c8..1a8f59357e 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -28,6 +28,7 @@ #include #include #include +#include namespace xrpl { @@ -39,6 +40,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. * @@ -58,11 +64,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 @@ -72,6 +83,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, @@ -79,6 +97,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); @@ -165,7 +203,10 @@ dirLink( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -176,7 +217,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. @@ -189,7 +231,10 @@ canWithdraw( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -199,20 +244,25 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. * * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * destination account (sfDestination). Credentials, if any, are taken from the + * transaction's sfCredentialIDs field. * * - Checks that the receiver account exists. * - If the receiver requires a destination tag, check that one exists, even * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials in sfCredentialIDs to already exist in the + * ledger, and returns an internal error otherwise. Validate them + * beforehand with credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ diff --git a/include/xrpl/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/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 350fc6ca85..452d402d14 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -15,7 +15,6 @@ #include #include #include -#include #include namespace xrpl { @@ -353,14 +352,14 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); * * The list is constructed during initialization and is const after that. * Pseudo-account designator fields MUST be maintained by including the - * SField::sMD_PseudoAccount flag in the SField definition. + * SField::kSmdPseudoAccount flag in the SField definition. */ [[nodiscard]] std::vector const& getPseudoAccountFields(); /** - * Returns true if and only if sleAcct is a pseudo-account or specific - * pseudo-accounts in pseudoFieldFilter. + * Returns true if and only if sleAcct is a pseudo-account of any kind + * (i.e. carries at least one field flagged with SField::kSmdPseudoAccount). * * Returns false if sleAcct is: * - NOT a pseudo-account OR @@ -368,18 +367,15 @@ getPseudoAccountFields(); * - null pointer */ [[nodiscard]] bool -isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter = {}); +isPseudoAccount(SLE::const_pointer sleAcct); /** * Convenience overload that reads the account from the view. */ [[nodiscard]] inline bool -isPseudoAccount( - ReadView const& view, - AccountID const& accountId, - std::set const& pseudoFieldFilter = {}) +isPseudoAccount(ReadView const& view, AccountID const& accountId) { - return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter); + return isPseudoAccount(view.read(keylet::account(accountId))); } /** diff --git a/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..f3fc82eacb 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 @@ -286,6 +324,12 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +// Returns true if the loan's next payment is late per protocol rules. The +// boundary is amendment-gated: with fixCleanup3_4_0 the due date must be +// strictly in the past, otherwise the exact due-date instant counts as late. +[[nodiscard]] bool +isPaymentLate(ReadView const& view, SLE::const_ref loanSle); + // Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single // accounting touch point (origination, payment, impair/unimpair/default). struct AccountingDeltas diff --git a/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/RippleStateHelpers.h b/include/xrpl/ledger/helpers/RippleStateHelpers.h index a0508d074f..1a0f92a94b 100644 --- a/include/xrpl/ledger/helpers/RippleStateHelpers.h +++ b/include/xrpl/ledger/helpers/RippleStateHelpers.h @@ -239,8 +239,13 @@ canTransfer(ReadView const& view, Issue const& issue, AccountID const& from, Acc //------------------------------------------------------------------------------ /** - * Any transactors that call addEmptyHolding() in doApply must call - * canAddHolding() in preflight with the same View and Asset + * XRP and the issuer itself are always tesSUCCESS. Otherwise, after + * fixCleanup3_4_0, an existing trust line returns tecDUPLICATE without + * consulting issuer freeze or DefaultRipple; both still apply on the create + * path (DefaultRipple off is terNO_RIPPLE). canAddHolding() ignores existing + * holdings, so transactors that may create a holding in doApply should gate + * their preclaim call on it: after the amendment only when no holding + * exists, before it always. */ [[nodiscard]] TER addEmptyHolding( diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 501101136a..12fa8a105e 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -38,6 +38,12 @@ enum class FreezeHandling { IgnoreFreeze, ZeroIfFrozen }; */ enum class AuthHandling { IgnoreAuth, ZeroIfUnauthorized }; +/** + * Controls whether the recipient owner-reserve check is enforced when + * auto-creating a trustline or MPToken during AMMWithdraw or AMMClawback. + */ +enum class ReserveHandling : bool { EnforceReserve, IgnoreReserve }; + /** * Controls whether to include the account's full spendable balance */ @@ -294,6 +300,14 @@ accountFunds( AuthHandling authHandling, beast::Journal j); +/** + * Returns the transfer fee as Rate based on the type of token + * @param view The ledger view + * @param asset The asset being transferred + */ +[[nodiscard]] Rate +transferRate(ReadView const& view, Asset const& asset); + /** * Returns the transfer fee as Rate based on the type of token * @param view The ledger view @@ -311,6 +325,12 @@ transferRate(ReadView const& view, STAmount const& amount); [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); +/** + * True if the account already holds this asset (or is the issuer / XRP). + */ +[[nodiscard]] bool +holdingExists(ReadView const& view, AccountID const& account, Asset const& asset); + [[nodiscard]] TER addEmptyHolding( ApplyViewContext ctx, diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e8..b42f349b95 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,15 +1,22 @@ #pragma once +#include #include #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 @@ -38,6 +45,32 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co [[nodiscard]] std::optional sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); +/** + * Adjusts a requested asset change (`delta`) to match the decimal scale of the + * updated total vault assets. This ensures `sfAssetsTotal`, `sfAssetsAvailable`, + * and the actual asset transfer change by the exact same representable amount. + * + * Rounding strategy: + * - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent + * paying out more than requested. + * - Credits (deposits): Floors the resulting total asset balance and returns the + * difference from the current total. This prevents crediting the vault with + * more assets than the user deposited. + * + * Key rules: + * - The returned magnitude never exceeds `|delta|`. + * - Returns `tecPRECISION_LOSS` if the change is smaller than 1 ULP of the target scale + * (prevents share operations when totals cannot change). + * - For integer assets (XRP, MPT), rounding is a no-op. + * + * @param vault The vault ledger entry. + * @param delta The requested signed change to sfAssetsTotal. + * @return The rounded, positive magnitude, or `tecPRECISION_LOSS` if the + * change is below representable precision. + */ +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta); + /** * Controls whether to truncate shares instead of rounding. */ @@ -52,6 +85,35 @@ enum class TruncateShares : bool { No = false, Yes = true }; */ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; +/** + * Returns the assets backing outstanding shares for a withdrawal: + * sfAssetsTotal minus sfLossUnrealized, or sfAssetsTotal alone when the + * unrealized loss is waived. Used by assetsToSharesWithdraw and + * sharesToAssetsWithdraw as the numerator of the share/asset exchange rate. + * + * @param vault The vault SLE. + * @param waive Whether to skip subtracting the unrealized loss. + */ +[[nodiscard]] Number +assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive); + +/** + * Returns true if debiting `amount` from `total` (the current value of a + * vault's sfAssetsTotal or sfAssetsAvailable) would canonicalize to the + * same STAmount value. This happens when `amount` is non-zero but too small + * to change the stored total at STAmount's precision. Shares would still + * move, so the ValidVault invariant would fail after apply; callers use + * this to reject the transaction upfront instead. + * + * @param asset The vault's underlying asset, used to canonicalize both + * sides the same way the ledger will when the field is stored. + * @param total The field's current value. + * @param amount The amount to debit. Zero always returns false; that case + * is rejected separately. + */ +[[nodiscard]] bool +debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount); + /** * 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 +185,118 @@ 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); + +/** + * Controls whether checkVaultDomain reports an expired credential as an + * error. A caller that deletes expired credentials later, in doApply, passes + * Yes and treats the subject as authorized; a caller with no such cleanup + * step must keep the error. + */ +enum class SuppressExpired : bool { No = false, Yes = true }; + +/** + * Checks that subject belongs to the permissioned domain governing a vault's + * shares. + * + * The domain is read from the share issuance rather than from the vault. Vault + * shares are issued by the vault's pseudo-account, which cannot grant an + * authorization explicitly, so domain membership is the only route to being + * authorized: a vault with no domain set has no authorized participants at + * all, and every subject fails with tecNO_AUTH. + * + * Which accounts to check, and whether to check at all, is left to the caller. + * This says nothing about vault privacy or about the roles of the accounts. + * + * @param view The ledger view. + * @param issuance The MPTokenIssuance SLE for the vault's shares. + * @param subject The account whose domain membership is checked. + * @param suppressExpired Whether an expired credential counts as authorized. + * + * @return tesSUCCESS if the subject is a domain member, otherwise the reason + * it is not. + */ +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired); + } // namespace xrpl diff --git a/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 1e11f6cd8b..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -91,6 +91,17 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAuctionSlotFeeScaleFactor; } +/** + * Minimum auction slot price: LPTokens * TradingFee / kAuctionSlotMinFeeFraction + * @param lptAMMBalance AMM LP token balance + * @param tradingFee trading fee in {0, 1000} + */ +inline Number +ammAuctionMinSlotPrice(Number const& lptAMMBalance, std::uint16_t tradingFee) +{ + return lptAMMBalance * getFee(tradingFee) / kAuctionSlotMinFeeFraction; +} + /** * Get fee multiplier (1 - tfee) * @tfee trading fee in basis points diff --git a/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/Book.h b/include/xrpl/protocol/Book.h index a83eb41b24..e6ed3729dd 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -133,7 +133,7 @@ private: using id_hash_type = boost::base_from_member, 0>; public: - explicit hash() = default; + hash() = default; using value_type = std::size_t; using argument_type = xrpl::MPTIssue; @@ -160,7 +160,7 @@ private: mptissue_hasher mMptissueHasher_; public: - explicit hash() = default; + hash() = default; value_type operator()(argument_type const& asset) const @@ -227,7 +227,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; @@ -235,7 +235,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; diff --git a/include/xrpl/protocol/Emitable.h b/include/xrpl/protocol/Emitable.h index 6acce0d102..e046ad0e5d 100644 --- a/include/xrpl/protocol/Emitable.h +++ b/include/xrpl/protocol/Emitable.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -28,8 +29,6 @@ enum GranularEmitableType : std::uint32_t { #pragma pop_macro("EMITABLE") }; -enum Emittance { emitable, notEmitable }; - class Emitable { private: diff --git a/include/xrpl/protocol/HashPrefix.h b/include/xrpl/protocol/HashPrefix.h index 9d4471d05c..e77e891b04 100644 --- a/include/xrpl/protocol/HashPrefix.h +++ b/include/xrpl/protocol/HashPrefix.h @@ -92,6 +92,26 @@ enum class HashPrefix : std::uint32_t { * Batch */ Batch = detail::makeHashPrefix('B', 'C', 'H'), + + /** + * inner transaction to sign as the counterparty + */ + CounterpartyTxSign = detail::makeHashPrefix('C', 'P', 'T'), + + /** + * inner transaction to multi-sign as the counterparty + */ + CounterpartyTxMultiSign = detail::makeHashPrefix('C', 'P', 'M'), + + /** + * inner transaction to sign as the sponsor + */ + SponsorTxSign = detail::makeHashPrefix('S', 'P', 'N'), + + /** + * inner transaction to multi-sign as the sponsor + */ + SponsorTxMultiSign = detail::makeHashPrefix('S', 'P', 'M'), }; template diff --git a/include/xrpl/protocol/MPTAmount.h b/include/xrpl/protocol/MPTAmount.h index 462092f7dd..68a7926256 100644 --- a/include/xrpl/protocol/MPTAmount.h +++ b/include/xrpl/protocol/MPTAmount.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -174,4 +175,17 @@ mulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU return MPTAmount(r.convert_to()); } +inline std::optional +tryMulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp) +{ + try + { + return mulRatio(amt, num, den, roundUp); + } + catch (std::overflow_error const&) + { + return std::nullopt; + } +} + } // namespace xrpl diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index 7f473da6a2..49c1fd63dc 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -151,7 +151,7 @@ namespace std { template <> struct hash : xrpl::MPTID::hasher { - explicit hash() = default; + hash() = default; }; } // namespace std diff --git a/include/xrpl/protocol/NFTSyntheticSerializer.h b/include/xrpl/protocol/NFTSyntheticSerializer.h deleted file mode 100644 index df4fedb707..0000000000 --- a/include/xrpl/protocol/NFTSyntheticSerializer.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -namespace xrpl::rpc { - -/** - * Adds common synthetic fields to transaction-related JSON responses - */ -/** @{ */ -void -insertNFTSyntheticInJson(json::Value&, std::shared_ptr const&, TxMeta const&); -/** @} */ - -} // namespace xrpl::rpc diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 703a0939c9..2a3f561a10 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,6 @@ enum GranularPermissionType : std::uint32_t { #pragma pop_macro("GRANULAR_PERMISSION") }; -// Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor -// tricks in tests and macro-generated code; enum class would break that. -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Delegation { Delegable, NotDelegable }; - class Permission { private: @@ -65,7 +61,7 @@ private: struct TxDelegationEntry { uint256 amendment; - Delegation delegable{NotDelegable}; + Delegation delegable{Delegation::NotDelegable}; }; std::unordered_set granularTxTypes_; diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index fbfa75b988..d0de17787c 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,8 +9,10 @@ #include #include +#include #include #include +#include namespace xrpl { @@ -327,6 +329,47 @@ 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, +}; + +/** + * Minimum gap between a closed-ended loan's final scheduled payment and the + * vault's RedemptionDate. LoanSet rejects a schedule whose final payment is + * fewer than this many seconds before RedemptionDate. + */ +constexpr std::uint32_t kLoanRedemptionBuffer = std::chrono::seconds{60}.count(); + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + * + * 180s is enough to originate a loan that uses the minimum payment interval + * and kLoanRedemptionBuffer after StartDate, which is strictly after + * SubscriptionDate. The interval and buffer need not be equal; only their + * sum plus one second must fit in this floor. + */ +constexpr std::uint32_t kMinInvestmentPeriod = std::chrono::seconds{180}.count(); +// This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). +constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 @@ -512,6 +555,11 @@ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_ */ constexpr std::uint32_t kConfidentialFeeMultiplier = 9; +/** + * Maximum value a confidential MPT key epoch may reach. + */ +constexpr std::uint32_t kMaxKeyEpoch = std::numeric_limits::max(); + /** * Compressed EC point prefix for even y-coordinate */ 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/STLedgerEntry.h b/include/xrpl/protocol/STLedgerEntry.h index 8731488adb..7bc369ea37 100644 --- a/include/xrpl/protocol/STLedgerEntry.h +++ b/include/xrpl/protocol/STLedgerEntry.h @@ -19,7 +19,7 @@ namespace xrpl { class Rules; namespace test { -class Invariants_test; +class InvariantsMisc_test; } // namespace test class STLedgerEntry final : public STObject, public CountedObject @@ -83,8 +83,8 @@ private: void setSLEType(); - friend test::Invariants_test; // this test wants access to the private - // type_ + friend test::InvariantsMisc_test; // this test wants access to the + // private type_ STBase* copy(std::size_t n, void* buf) const override; diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index ac404e6b36..3d448097d2 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -444,8 +444,6 @@ public: bool operator==(STObject const& o) const; - bool - operator!=(STObject const& o) const; class FieldErr; @@ -679,36 +677,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; @@ -1214,12 +1182,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/STTx.h b/include/xrpl/protocol/STTx.h index e213d4e0b7..e6291ae950 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include @@ -105,14 +107,36 @@ public: [[nodiscard]] json::Value getJson(JsonOptions options, bool binary) const; + /** + * Sign the transaction as its account. + * + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + */ + void + sign(PublicKey const& publicKey, SecretKey const& secretKey); + + /** + * Sign the transaction in one of its signature fields. + * + * The signature is bound to the role that made it, so it cannot be moved + * into another role. + * + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @param role The role signing the transaction. + * @param rules The current ledger rules. + */ void sign( PublicKey const& publicKey, SecretKey const& secretKey, - std::optional> signatureTarget = {}); + SignatureRole role, + Rules const& rules); /** * Check the signature. + * * @param rules The current ledger rules. * @return `true` if valid signature. If invalid, the error message string. */ @@ -120,7 +144,7 @@ public: checkSign(Rules const& rules) const; [[nodiscard]] std::expected - checkBatchSign(Rules const& rules) const; + checkBatchSign() const; // SQL Functions with metadata. static std::string const& @@ -162,28 +186,28 @@ public: private: /** * Check the signature. + * * @param rules The current ledger rules. * @param sigObject Reference to object that contains the signature fields. * Will be *this more often than not. + * @param role The role that made the signature in sigObject. Determines + * the signing prefix, which binds the signature to that role. * @return `true` if valid signature. If invalid, the error message string. */ [[nodiscard]] std::expected - checkSign(Rules const& rules, STObject const& sigObject) const; + checkSign(Rules const& rules, STObject const& sigObject, SignatureRole role) const; [[nodiscard]] std::expected - checkSingleSign(STObject const& sigObject) const; + checkSingleSign(STObject const& sigObject, HashPrefix prefix) const; [[nodiscard]] std::expected - checkMultiSign(Rules const& rules, STObject const& sigObject) const; + checkMultiSign(STObject const& sigObject, HashPrefix prefix) const; [[nodiscard]] std::expected checkBatchSingleSign(STObject const& batchSigner, std::vector const& txIds) const; [[nodiscard]] std::expected - checkBatchMultiSign( - STObject const& batchSigner, - Rules const& rules, - std::vector const& txIds) const; + checkBatchMultiSign(STObject const& batchSigner, std::vector const& txIds) const; void buildBatchTxns(); 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/Sign.h b/include/xrpl/protocol/Sign.h index fad2c35c9e..b7a318eb35 100644 --- a/include/xrpl/protocol/Sign.h +++ b/include/xrpl/protocol/Sign.h @@ -4,13 +4,65 @@ #include #include #include +#include #include #include #include #include +#include + namespace xrpl { +/** + * The signature slots on a transaction. + * + * Each role signs different bytes, so a signature cannot be moved from the + * role that made it into another role. See signingPrefix. + */ +enum class SignatureRole { + /** + * The transaction's own signature, in sfTxnSignature or sfSigners. + */ + Transaction, + /** + * The counterparty's signature, in sfCounterpartySignature. + */ + Counterparty, + /** + * The sponsor's signature, in sfSponsorSignature. + */ + Sponsor +}; + +/** + * The field that holds this role's signature. + * + * @return The signature field, or nullptr for SignatureRole::Transaction, + * whose signature lives at the top level of the transaction. + */ +[[nodiscard]] SField const* +signatureField(SignatureRole role); + +/** + * The role that signs into the given field. + * + * @return The role, or an unseated optional if the field does not hold a + * transaction signature. + */ +[[nodiscard]] std::optional +signatureRole(SField const& sigField); + +/** + * The hash prefix that binds a transaction signature to the role that made it. + * + * @param role The role making the signature. + * @param multiSigning Whether the signature is a multi-signature. + * @param rules The current ledger rules. + */ +[[nodiscard]] HashPrefix +signingPrefix(SignatureRole role, bool multiSigning, Rules const& rules); + /** * Sign an STObject * @@ -49,9 +101,12 @@ verify( /** * Return a Serializer suitable for computing a multisigning TxnSignature. + * + * @param prefix Prefix to insert before the serialized object. Get it from + * signingPrefix, so that the signature is bound to the role making it. */ Serializer -buildMultiSigningData(STObject const& obj, AccountID const& signingID); +buildMultiSigningData(STObject const& obj, AccountID const& signingID, HashPrefix prefix); /** * Break the multi-signing hash computation into 2 parts for optimization. @@ -67,7 +122,7 @@ buildMultiSigningData(STObject const& obj, AccountID const& signingID); * signer's unique data. */ Serializer -startMultiSigningData(STObject const& obj); +startMultiSigningData(STObject const& obj, HashPrefix prefix); inline void finishMultiSigningData(AccountID const& signingID, Serializer& s) diff --git a/include/xrpl/protocol/TER.h b/include/xrpl/protocol/TER.h index 879f4da23a..47a8780a41 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -132,7 +132,6 @@ enum TEMcodes : TERUnderlyingType { temBAD_MPT, temBAD_CIPHERTEXT, - temBAD_WASM, temINVALID_BYTECODE, temTEMP_DISABLED, }; diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index e272f25424..b6ac85720a 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -438,8 +438,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/TxSettings.h b/include/xrpl/protocol/TxSettings.h new file mode 100644 index 0000000000..b65e3e8413 --- /dev/null +++ b/include/xrpl/protocol/TxSettings.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include + +#include +#include + +namespace xrpl { + +enum class Delegation { Delegable, NotDelegable }; + +/** + * Whether a smart contract may emit a transaction of this type. + */ +enum class Emittance { Emitable, NotEmitable }; + +/** + * Operations a transaction is permitted to perform, as a bitfield. + * + * These are declared per-transaction in transactions.macro (via + * TxSettings::privileges) and enforced in InvariantCheck.cpp. + */ +enum class Privilege : std::uint16_t { + NoPriv = 0x0000, // The transaction can not do any of the enumerated operations + CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. + CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, + // which implies createAcct + MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object + MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT + // object, but does not have to + OverrideFreeze = 0x0010, // The transaction can override some freeze rules + ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT + CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance + DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance + MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT + // object (except by issuer) + MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT + // object (except by issuer) + MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. + MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault + MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. +}; + +// The inner static_cast is not redundant: the underlying type is narrower than +// `int`, so the operands integer-promote and the result has to be narrowed back. +// safeCast rejects that narrowing, but every input bit is a Privilege bit by +// construction, so the result is always representable. +constexpr Privilege +operator|(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) | safeCast(rhs))); +} + +constexpr Privilege +operator&(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) & safeCast(rhs))); +} + +/** + * Per-transaction metadata declared in transactions.macro. + * + * Every member has a default, so a transaction only needs to name the settings + * that differ from the common case. See the documentation at the top of + * transactions.macro for the authoring syntax. + * + * This is deliberately not a constexpr-friendly type: amendment identifiers are + * runtime-initialized `extern uint256 const` globals (see Feature.h), so a + * TxSettings can only be built at runtime. + */ +struct TxSettings +{ + /** + * Whether an account may delegate this transaction to another account. + */ + Delegation delegable{Delegation::NotDelegable}; + + /** + * The amendment gating this transaction, or uint256{} if always available. + */ + // The `{}` looks redundant, because BaseUInt's default constructor already + // zeroes the value. It is not: without a default member initializer here, + // every partial designated initializer in transactions.macro trips the + // missing-designated-field-initializers warning, which the build treats as + // an error. + // NOLINTNEXTLINE(readability-redundant-member-init) + uint256 amendment{}; + + /** + * Operations this transaction is permitted to perform. + */ + Privilege privileges{Privilege::NoPriv}; + + /** + * Whether a smart contract may emit this transaction. + */ + Emittance emittance{Emittance::Emitable}; +}; + +} // namespace xrpl 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/features.macro b/include/xrpl/protocol/detail/features.macro index f837f503c6..bc4b5f62e5 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -20,7 +20,7 @@ XRPL_FIX (Cleanup3_4_0, Supported::Yes, VoteBehavior::DefaultN XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocolV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(ConfidentialTransfer, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) @@ -146,3 +146,6 @@ XRPL_RETIRE_FEATURE(SortedDirectories) XRPL_RETIRE_FEATURE(TicketBatch) XRPL_RETIRE_FEATURE(TickSize) XRPL_RETIRE_FEATURE(TrustSetAuth) +XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) +XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) +XRPL_FEATURE(ConfidentialMPTKeyRotation, Supported::No, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index c62450d172..82764875c7 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -416,6 +416,8 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, + {sfIssuerKeyEpoch, SoeOptional}, + {sfAuditorKeyEpoch, SoeOptional}, {sfConfidentialOutstandingAmount, SoeDefault}, })) @@ -514,6 +516,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 b862bf5d74..50444bdcbc 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -27,6 +27,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) 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) @@ -116,12 +117,18 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) -TYPED_SFIELD(sfGasLimit, UINT32, 75) -TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 76) -TYPED_SFIELD(sfGasPrice, UINT32, 77) -TYPED_SFIELD(sfGas, UINT32, 78) -TYPED_SFIELD(sfGasUsed, UINT32, 79) -TYPED_SFIELD(sfParameterFlag, UINT32, 80) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) +TYPED_SFIELD(sfIssuerKeyEpoch, UINT32, 77) +TYPED_SFIELD(sfAuditorKeyEpoch, UINT32, 78) +TYPED_SFIELD(sfIssuerKeyMirrorEpoch, UINT32, 79) +TYPED_SFIELD(sfAuditorKeyMirrorEpoch, UINT32, 80) +TYPED_SFIELD(sfGasLimit, UINT32, 81) +TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 82) +TYPED_SFIELD(sfGasPrice, UINT32, 83) +TYPED_SFIELD(sfGas, UINT32, 84) +TYPED_SFIELD(sfGasUsed, UINT32, 85) +TYPED_SFIELD(sfParameterFlag, UINT32, 86) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -461,12 +468,6 @@ UNTYPED_SFIELD(sfFunctions, ARRAY, 32) UNTYPED_SFIELD(sfInstanceParameters, ARRAY, 33) UNTYPED_SFIELD(sfInstanceParameterValues,ARRAY, 34) UNTYPED_SFIELD(sfParameters, ARRAY, 35) - -// data TYPED_SFIELD(sfParameterValue, DATA, 1, SField::kSmdDefault) - -// data type TYPED_SFIELD(sfParameterType, DATATYPE, 1) - -// json TYPED_SFIELD(sfContractJson, JSON, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 485b24323b..19f2ec300f 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, emitable, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,32 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * Emittance emittance{Emittance::Emitable}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,10 +48,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -45,12 +65,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -58,19 +73,14 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, {sfCancelAfter, SoeOptional}, {sfFinishAfter, SoeOptional}, {sfBytecode, SoeOptional}, - {sfData, SoeOptional}, + {sfData, SoeOptional}, })) /** This transaction type completes an existing escrow. */ #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -85,10 +95,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::emitable, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -106,12 +113,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -121,10 +123,9 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .emittance = Emittance::NotEmitable, + }), ({ {sfRegularKey, SoeOptional}, })) @@ -136,10 +137,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -152,12 +150,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -167,12 +160,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -185,10 +173,9 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .emittance = Emittance::NotEmitable, + }), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -198,12 +185,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -216,12 +198,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -231,12 +208,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -249,12 +221,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -267,10 +234,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -281,12 +245,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -295,10 +254,10 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, # include #endif TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .delegable = Delegation::Delegable, + .emittance = Emittance::NotEmitable, + }), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, @@ -310,12 +269,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -326,10 +280,10 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, - Emittance::notEmitable, + ({ + .privileges = Privilege::MustDeleteAcct, + .emittance = Emittance::NotEmitable, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -343,10 +297,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -362,10 +313,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -375,12 +323,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -392,12 +335,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -405,12 +343,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -420,12 +353,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - Emittance::emitable, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -435,10 +363,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -451,10 +381,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -466,10 +397,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,10 +413,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -503,10 +432,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -518,10 +444,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -535,10 +458,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -549,10 +473,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -561,10 +482,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -574,10 +492,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -588,10 +503,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -601,10 +513,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -621,12 +534,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, - Emittance::emitable, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -645,10 +558,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -657,10 +567,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -672,10 +579,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -687,10 +591,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -698,10 +599,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -716,10 +614,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -729,10 +624,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -744,10 +636,11 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, @@ -762,10 +655,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -775,10 +669,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -795,10 +686,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -809,10 +701,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -825,10 +714,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -839,10 +725,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -854,10 +737,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -869,10 +749,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -883,10 +760,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -896,10 +770,10 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, - Emittance::notEmitable, + ({ + .amendment = featurePermissionDelegationV1_1, + .emittance = Emittance::NotEmitable, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -910,10 +784,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -922,6 +797,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. */ @@ -929,10 +807,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -945,10 +823,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -959,10 +838,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -973,15 +852,17 @@ TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, # include #endif TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back tokens from a vault. */ @@ -989,10 +870,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1004,10 +885,10 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, - Emittance::notEmitable, + ({ + .amendment = featureBatchV1_1, + .emittance = Emittance::NotEmitable, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -1020,10 +901,10 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, @@ -1039,10 +920,10 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfLoanBrokerID, SoeRequired}, })) @@ -1052,10 +933,9 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + }), ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -1066,15 +946,16 @@ TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt, + }), ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back First Loss Capital from a Loan Broker to @@ -1083,10 +964,9 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + }), ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -1097,10 +977,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, @@ -1126,10 +1006,9 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + }), ({ {sfLoanID, SoeRequired}, })) @@ -1139,13 +1018,13 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), ({ {sfLoanID, SoeRequired}, })) @@ -1155,10 +1034,10 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, - Emittance::emitable, + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -1169,10 +1048,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::NotDelegable, - featureConfidentialTransfer, - NoPriv, - Emittance::emitable, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1189,10 +1067,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1202,10 +1077,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1221,10 +1093,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1243,10 +1112,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1259,10 +1125,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, - Emittance::emitable, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1273,10 +1138,7 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, - Emittance::emitable, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1290,10 +1152,11 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttCONTRACT_CREATE, 92, ContractCreate, - Delegation::Delegable, - featureSmartContract, - CreatePseudoAcct, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + .privileges = Privilege::CreatePseudoAcct, + }), ({ {sfContractCode, SoeOptional}, {sfContractHash, SoeOptional}, @@ -1308,10 +1171,10 @@ TRANSACTION(ttCONTRACT_CREATE, 92, ContractCreate, # include #endif TRANSACTION(ttCONTRACT_MODIFY, 93, ContractModify, - Delegation::Delegable, - featureSmartContract, - NoPriv, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + }), ({ {sfContractAccount, SoeOptional}, {sfOwner, SoeOptional}, @@ -1328,10 +1191,11 @@ TRANSACTION(ttCONTRACT_MODIFY, 93, ContractModify, # include #endif TRANSACTION(ttCONTRACT_DELETE, 94, ContractDelete, - Delegation::Delegable, - featureSmartContract, - MustDeleteAcct, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfContractAccount, SoeRequired}, })) @@ -1341,10 +1205,10 @@ TRANSACTION(ttCONTRACT_DELETE, 94, ContractDelete, # include #endif TRANSACTION(ttCONTRACT_CLAWBACK, 95, ContractClawback, - Delegation::Delegable, - featureSmartContract, - NoPriv, - Emittance::emitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + }), ({ {sfContractAccount, SoeOptional}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -1355,10 +1219,11 @@ TRANSACTION(ttCONTRACT_CLAWBACK, 95, ContractClawback, # include #endif TRANSACTION(ttCONTRACT_USER_DELETE, 96, ContractUserDelete, - Delegation::Delegable, - featureSmartContract, - NoPriv, - Emittance::notEmitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + .emittance = Emittance::NotEmitable, + }), ({ {sfContractAccount, SoeRequired}, {sfGas, SoeRequired}, @@ -1369,10 +1234,11 @@ TRANSACTION(ttCONTRACT_USER_DELETE, 96, ContractUserDelete, # include #endif TRANSACTION(ttCONTRACT_CALL, 97, ContractCall, - Delegation::Delegable, - featureSmartContract, - NoPriv, - Emittance::notEmitable, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSmartContract, + .emittance = Emittance::NotEmitable, + }), ({ {sfContractAccount, SoeRequired}, {sfFunctionName, SoeRequired}, @@ -1388,10 +1254,9 @@ TRANSACTION(ttCONTRACT_CALL, 97, ContractCall, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .emittance = Emittance::NotEmitable, + }), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1401,10 +1266,9 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .emittance = Emittance::NotEmitable, + }), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1427,10 +1291,9 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, - Emittance::notEmitable, + ({ + .emittance = Emittance::NotEmitable, + }), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 6a2caf52ae..74b5e3c4eb 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -351,6 +351,54 @@ public: return this->sle_->isFieldPresent(sfAuditorEncryptionKey); } + /** + * @brief Get sfIssuerKeyEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerKeyEpoch() const + { + if (hasIssuerKeyEpoch()) + return this->sle_->at(sfIssuerKeyEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerKeyEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerKeyEpoch() const + { + return this->sle_->isFieldPresent(sfIssuerKeyEpoch); + } + + /** + * @brief Get sfAuditorKeyEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorKeyEpoch() const + { + if (hasAuditorKeyEpoch()) + return this->sle_->at(sfAuditorKeyEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorKeyEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorKeyEpoch() const + { + return this->sle_->isFieldPresent(sfAuditorKeyEpoch); + } + /** * @brief Get sfConfidentialOutstandingAmount (SoeDefault) * @return The field value, or std::nullopt if not present. @@ -600,6 +648,28 @@ public: return *this; } + /** + * @brief Set sfIssuerKeyEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setIssuerKeyEpoch(std::decay_t const& value) + { + object_[sfIssuerKeyEpoch] = value; + return *this; + } + + /** + * @brief Set sfAuditorKeyEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenIssuanceBuilder& + setAuditorKeyEpoch(std::decay_t const& value) + { + object_[sfAuditorKeyEpoch] = value; + return *this; + } + /** * @brief Set sfConfidentialOutstandingAmount (SoeDefault) * @return Reference to this builder for method chaining. 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/AMMBid.h b/include/xrpl/protocol_autogen/transactions/AMMBid.h index 30a2b6f2ab..94d0672699 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMBid.h +++ b/include/xrpl/protocol_autogen/transactions/AMMBid.h @@ -21,7 +21,7 @@ class AMMBidBuilder; * Type: ttAMM_BID (39) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMBidBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMClawback.h b/include/xrpl/protocol_autogen/transactions/AMMClawback.h index 38aba892c4..c837b5cee6 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMClawback.h +++ b/include/xrpl/protocol_autogen/transactions/AMMClawback.h @@ -21,7 +21,7 @@ class AMMClawbackBuilder; * Type: ttAMM_CLAWBACK (31) * Delegable: Delegation::Delegable * Amendment: featureAMMClawback - * Privileges: MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::OverrideFreeze | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMCreate.h b/include/xrpl/protocol_autogen/transactions/AMMCreate.h index c6ccd4e860..e2e50f87ff 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMCreate.h +++ b/include/xrpl/protocol_autogen/transactions/AMMCreate.h @@ -21,7 +21,7 @@ class AMMCreateBuilder; * Type: ttAMM_CREATE (35) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: CreatePseudoAcct | MayCreateMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDelete.h b/include/xrpl/protocol_autogen/transactions/AMMDelete.h index 05899a46c8..86e91bf52b 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDelete.h @@ -21,7 +21,7 @@ class AMMDeleteBuilder; * Type: ttAMM_DELETE (40) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MustDeleteAcct | MayDeleteMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayDeleteMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h index 5416547dab..fed1bd3195 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h @@ -21,7 +21,7 @@ class AMMDepositBuilder; * Type: ttAMM_DEPOSIT (36) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMVote.h b/include/xrpl/protocol_autogen/transactions/AMMVote.h index 7dce3c252f..3fca42a232 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMVote.h +++ b/include/xrpl/protocol_autogen/transactions/AMMVote.h @@ -21,7 +21,7 @@ class AMMVoteBuilder; * Type: ttAMM_VOTE (38) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMVoteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h index 81258f22d6..e177011801 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h @@ -21,7 +21,7 @@ class AMMWithdrawBuilder; * Type: ttAMM_WITHDRAW (37) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MayDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountDelete.h b/include/xrpl/protocol_autogen/transactions/AccountDelete.h index cf6e97bb63..87ecab0c7b 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AccountDelete.h @@ -21,7 +21,7 @@ class AccountDeleteBuilder; * Type: ttACCOUNT_DELETE (21) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: MustDeleteAcct + * Privileges: Privilege::MustDeleteAcct * * Immutable wrapper around STTx providing type-safe field access. * Use AccountDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountSet.h b/include/xrpl/protocol_autogen/transactions/AccountSet.h index 55c449e78e..9f85603e22 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountSet.h +++ b/include/xrpl/protocol_autogen/transactions/AccountSet.h @@ -21,7 +21,7 @@ class AccountSetBuilder; * Type: ttACCOUNT_SET (3) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AccountSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Batch.h b/include/xrpl/protocol_autogen/transactions/Batch.h index 1a59d2b4c0..f92aaa5348 100644 --- a/include/xrpl/protocol_autogen/transactions/Batch.h +++ b/include/xrpl/protocol_autogen/transactions/Batch.h @@ -21,7 +21,7 @@ class BatchBuilder; * Type: ttBATCH (71) * Delegable: Delegation::NotDelegable * Amendment: featureBatchV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use BatchBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCancel.h b/include/xrpl/protocol_autogen/transactions/CheckCancel.h index b75b717e3f..cf300d3b9b 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCancel.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCancel.h @@ -21,7 +21,7 @@ class CheckCancelBuilder; * Type: ttCHECK_CANCEL (18) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCash.h b/include/xrpl/protocol_autogen/transactions/CheckCash.h index c742a15154..b80429875f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCash.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCash.h @@ -21,7 +21,7 @@ class CheckCashBuilder; * Type: ttCHECK_CASH (17) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCashBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCreate.h b/include/xrpl/protocol_autogen/transactions/CheckCreate.h index 63e55f8604..db51b5eb5f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCreate.h @@ -21,7 +21,7 @@ class CheckCreateBuilder; * Type: ttCHECK_CREATE (16) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Clawback.h b/include/xrpl/protocol_autogen/transactions/Clawback.h index 9a3a7f9feb..ad79f1d1fe 100644 --- a/include/xrpl/protocol_autogen/transactions/Clawback.h +++ b/include/xrpl/protocol_autogen/transactions/Clawback.h @@ -21,7 +21,7 @@ class ClawbackBuilder; * Type: ttCLAWBACK (30) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h index c80fc81dc5..bf204a35cb 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -21,7 +21,7 @@ class ConfidentialMPTClawbackBuilder; * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index 284b7f9e70..d23e6409d9 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT (85) * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h index 53a8e64125..80ec81e6f3 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBackBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h index 848da42a41..e3ec886acf 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -21,7 +21,7 @@ class ConfidentialMPTMergeInboxBuilder; * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractCall.h b/include/xrpl/protocol_autogen/transactions/ContractCall.h index 5067cd6cf7..73d95a0ebb 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractCall.h +++ b/include/xrpl/protocol_autogen/transactions/ContractCall.h @@ -21,7 +21,7 @@ class ContractCallBuilder; * Type: ttCONTRACT_CALL (97) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ContractCallBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractClawback.h b/include/xrpl/protocol_autogen/transactions/ContractClawback.h index 857b2a821d..8e0f815415 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractClawback.h +++ b/include/xrpl/protocol_autogen/transactions/ContractClawback.h @@ -21,7 +21,7 @@ class ContractClawbackBuilder; * Type: ttCONTRACT_CLAWBACK (95) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ContractClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractCreate.h b/include/xrpl/protocol_autogen/transactions/ContractCreate.h index 360e216004..703031f6f8 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractCreate.h +++ b/include/xrpl/protocol_autogen/transactions/ContractCreate.h @@ -21,7 +21,7 @@ class ContractCreateBuilder; * Type: ttCONTRACT_CREATE (92) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: CreatePseudoAcct + * Privileges: Privilege::CreatePseudoAcct * * Immutable wrapper around STTx providing type-safe field access. * Use ContractCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractDelete.h b/include/xrpl/protocol_autogen/transactions/ContractDelete.h index db7c4818fc..b0db8b01aa 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractDelete.h +++ b/include/xrpl/protocol_autogen/transactions/ContractDelete.h @@ -21,7 +21,7 @@ class ContractDeleteBuilder; * Type: ttCONTRACT_DELETE (94) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: MustDeleteAcct + * Privileges: Privilege::MustDeleteAcct * * Immutable wrapper around STTx providing type-safe field access. * Use ContractDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractModify.h b/include/xrpl/protocol_autogen/transactions/ContractModify.h index e2d4001376..737e7ea7fd 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractModify.h +++ b/include/xrpl/protocol_autogen/transactions/ContractModify.h @@ -21,7 +21,7 @@ class ContractModifyBuilder; * Type: ttCONTRACT_MODIFY (93) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ContractModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ContractUserDelete.h b/include/xrpl/protocol_autogen/transactions/ContractUserDelete.h index d1edaaaa07..e5fa308a27 100644 --- a/include/xrpl/protocol_autogen/transactions/ContractUserDelete.h +++ b/include/xrpl/protocol_autogen/transactions/ContractUserDelete.h @@ -21,7 +21,7 @@ class ContractUserDeleteBuilder; * Type: ttCONTRACT_USER_DELETE (96) * Delegable: Delegation::Delegable * Amendment: featureSmartContract - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ContractUserDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index d7621d7505..78f9f00033 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h @@ -21,7 +21,7 @@ class EscrowCreateBuilder; * Type: ttESCROW_CREATE (1) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 8a23fc6752..9c24c7671b 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h @@ -21,7 +21,7 @@ class EscrowFinishBuilder; * Type: ttESCROW_FINISH (2) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowFinishBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index af86dea0b0..4c02989f09 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -21,7 +21,7 @@ class LedgerStateFixBuilder; * Type: ttLEDGER_STATE_FIX (53) * Delegable: Delegation::Delegable * Amendment: fixNFTokenPageLinks - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LedgerStateFixBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h index 875e0a4c5e..468ce054c2 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h @@ -21,7 +21,7 @@ class LoanBrokerCoverClawbackBuilder; * Type: ttLOAN_BROKER_COVER_CLAWBACK (78) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h index 38cc113844..0fe1bd7b91 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h @@ -21,7 +21,7 @@ class LoanBrokerCoverDepositBuilder; * Type: ttLOAN_BROKER_COVER_DEPOSIT (76) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 56a93acbb4..4992fb8bbd 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -21,7 +21,7 @@ class LoanBrokerCoverWithdrawBuilder; * Type: ttLOAN_BROKER_COVER_WITHDRAW (77) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt + * Privileges: Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverWithdrawBuilder to construct new transactions. @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerCoverWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the LoanBrokerCoverWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h index 29b3a787fd..c449ebaff0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h @@ -21,7 +21,7 @@ class LoanBrokerDeleteBuilder; * Type: ttLOAN_BROKER_DELETE (75) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MustDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h index 41c87c281d..18f14b7a37 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h @@ -21,7 +21,7 @@ class LoanBrokerSetBuilder; * Type: ttLOAN_BROKER_SET (74) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: CreatePseudoAcct | MayAuthorizeMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanDelete.h b/include/xrpl/protocol_autogen/transactions/LoanDelete.h index 8ed537b37a..2696b542da 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanDelete.h @@ -21,7 +21,7 @@ class LoanDeleteBuilder; * Type: ttLOAN_DELETE (81) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanManage.h b/include/xrpl/protocol_autogen/transactions/LoanManage.h index 5eb95d21b1..4a665b372f 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanManage.h +++ b/include/xrpl/protocol_autogen/transactions/LoanManage.h @@ -21,7 +21,7 @@ class LoanManageBuilder; * Type: ttLOAN_MANAGE (82) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayModifyVault + * Privileges: Privilege::MayModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanManageBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanPay.h b/include/xrpl/protocol_autogen/transactions/LoanPay.h index 8e1faeb981..c9224fd697 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanPay.h +++ b/include/xrpl/protocol_autogen/transactions/LoanPay.h @@ -21,7 +21,7 @@ class LoanPayBuilder; * Type: ttLOAN_PAY (84) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanPayBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanSet.h b/include/xrpl/protocol_autogen/transactions/LoanSet.h index 2cadebd02e..eb04a468f0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -21,7 +21,7 @@ class LoanSetBuilder; * Type: ttLOAN_SET (80) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h index 2fb93eaf35..89d026928d 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h @@ -21,7 +21,7 @@ class MPTokenAuthorizeBuilder; * Type: ttMPTOKEN_AUTHORIZE (57) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: MustAuthorizeMpt + * Privileges: Privilege::MustAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenAuthorizeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index 82ffba9996..b83de9d843 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -21,7 +21,7 @@ class MPTokenIssuanceCreateBuilder; * Type: ttMPTOKEN_ISSUANCE_CREATE (54) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: CreateMptIssuance + * Privileges: Privilege::CreateMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h index cbcd206097..6d1c9b1eaa 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h @@ -21,7 +21,7 @@ class MPTokenIssuanceDestroyBuilder; * Type: ttMPTOKEN_ISSUANCE_DESTROY (55) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: DestroyMptIssuance + * Privileges: Privilege::DestroyMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceDestroyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index ed7e1f0f6c..43def05194 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -21,7 +21,7 @@ class MPTokenIssuanceSetBuilder; * Type: ttMPTOKEN_ISSUANCE_SET (56) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h index 325d2d7fbd..6c858be721 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h @@ -21,7 +21,7 @@ class NFTokenAcceptOfferBuilder; * Type: ttNFTOKEN_ACCEPT_OFFER (29) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenAcceptOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h index ec423ea468..ac831bf45e 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h @@ -21,7 +21,7 @@ class NFTokenBurnBuilder; * Type: ttNFTOKEN_BURN (26) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenBurnBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h index 4c4fb1dc65..81f4f3a848 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h @@ -21,7 +21,7 @@ class NFTokenCancelOfferBuilder; * Type: ttNFTOKEN_CANCEL_OFFER (28) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCancelOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h index a535a578e0..683436f4fd 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h @@ -21,7 +21,7 @@ class NFTokenCreateOfferBuilder; * Type: ttNFTOKEN_CREATE_OFFER (27) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCreateOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h index 5af41eb3dd..5a4e3b5b1c 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h @@ -21,7 +21,7 @@ class NFTokenMintBuilder; * Type: ttNFTOKEN_MINT (25) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenMintBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h index 9b9701fed6..84f1e395d4 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h @@ -21,7 +21,7 @@ class NFTokenModifyBuilder; * Type: ttNFTOKEN_MODIFY (61) * Delegable: Delegation::Delegable * Amendment: featureDynamicNFT - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCancel.h b/include/xrpl/protocol_autogen/transactions/OfferCancel.h index 5e6010e0dd..3e52ebf24b 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCancel.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCancel.h @@ -21,7 +21,7 @@ class OfferCancelBuilder; * Type: ttOFFER_CANCEL (8) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index ffc1216297..774921d87a 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -21,7 +21,7 @@ class OfferCreateBuilder; * Type: ttOFFER_CREATE (7) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleDelete.h b/include/xrpl/protocol_autogen/transactions/OracleDelete.h index ebdc8fb7e9..e50b6f6b02 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleDelete.h +++ b/include/xrpl/protocol_autogen/transactions/OracleDelete.h @@ -21,7 +21,7 @@ class OracleDeleteBuilder; * Type: ttORACLE_DELETE (52) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleSet.h b/include/xrpl/protocol_autogen/transactions/OracleSet.h index 0ec6d5cad0..03e4ffc518 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleSet.h +++ b/include/xrpl/protocol_autogen/transactions/OracleSet.h @@ -21,7 +21,7 @@ class OracleSetBuilder; * Type: ttORACLE_SET (51) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Payment.h b/include/xrpl/protocol_autogen/transactions/Payment.h index 389900bf12..cb177a8d08 100644 --- a/include/xrpl/protocol_autogen/transactions/Payment.h +++ b/include/xrpl/protocol_autogen/transactions/Payment.h @@ -21,7 +21,7 @@ class PaymentBuilder; * Type: ttPAYMENT (0) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: CreateAcct | MayCreateMpt + * Privileges: Privilege::CreateAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h index 4c567b13f4..06892955db 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h @@ -21,7 +21,7 @@ class PaymentChannelClaimBuilder; * Type: ttPAYCHAN_CLAIM (15) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h index 0a513d575a..2a3aebca4c 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h @@ -21,7 +21,7 @@ class PaymentChannelCreateBuilder; * Type: ttPAYCHAN_CREATE (13) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h index 51210dd796..9a8c452b0b 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h @@ -21,7 +21,7 @@ class PaymentChannelFundBuilder; * Type: ttPAYCHAN_FUND (14) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelFundBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h index 3db921776c..1b16b13116 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h @@ -21,7 +21,7 @@ class PermissionedDomainDeleteBuilder; * Type: ttPERMISSIONED_DOMAIN_DELETE (63) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h index 3e352cad76..30832aec8c 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h @@ -21,7 +21,7 @@ class PermissionedDomainSetBuilder; * Type: ttPERMISSIONED_DOMAIN_SET (62) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetFee.h b/include/xrpl/protocol_autogen/transactions/SetFee.h index ea3a1fccf7..edcea7b734 100644 --- a/include/xrpl/protocol_autogen/transactions/SetFee.h +++ b/include/xrpl/protocol_autogen/transactions/SetFee.h @@ -21,7 +21,7 @@ class SetFeeBuilder; * Type: ttFEE (101) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetFeeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h index a943bb0279..042676251b 100644 --- a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h +++ b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h @@ -21,7 +21,7 @@ class SetRegularKeyBuilder; * Type: ttREGULAR_KEY_SET (5) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetRegularKeyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SignerListSet.h b/include/xrpl/protocol_autogen/transactions/SignerListSet.h index 6e9d0e41ba..253bcccc1a 100644 --- a/include/xrpl/protocol_autogen/transactions/SignerListSet.h +++ b/include/xrpl/protocol_autogen/transactions/SignerListSet.h @@ -21,7 +21,7 @@ class SignerListSetBuilder; * Type: ttSIGNER_LIST_SET (12) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SignerListSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index dfd12a329f..bb3eb2ccf0 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -21,7 +21,7 @@ class SponsorshipSetBuilder; * Type: ttSPONSORSHIP_SET (91) * Delegable: Delegation::Delegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h index ab26e887e3..5bd5bc1319 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h @@ -21,7 +21,7 @@ class SponsorshipTransferBuilder; * Type: ttSPONSORSHIP_TRANSFER (90) * Delegable: Delegation::NotDelegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipTransferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TicketCreate.h b/include/xrpl/protocol_autogen/transactions/TicketCreate.h index 0d8670a76a..4cb109b8f2 100644 --- a/include/xrpl/protocol_autogen/transactions/TicketCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TicketCreate.h @@ -21,7 +21,7 @@ class TicketCreateBuilder; * Type: ttTICKET_CREATE (10) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TicketCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TrustSet.h b/include/xrpl/protocol_autogen/transactions/TrustSet.h index 22891b94ec..9d939eb1d0 100644 --- a/include/xrpl/protocol_autogen/transactions/TrustSet.h +++ b/include/xrpl/protocol_autogen/transactions/TrustSet.h @@ -21,7 +21,7 @@ class TrustSetBuilder; * Type: ttTRUST_SET (20) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TrustSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/UNLModify.h b/include/xrpl/protocol_autogen/transactions/UNLModify.h index 6569e4bf7d..f5c94071d7 100644 --- a/include/xrpl/protocol_autogen/transactions/UNLModify.h +++ b/include/xrpl/protocol_autogen/transactions/UNLModify.h @@ -21,7 +21,7 @@ class UNLModifyBuilder; * Type: ttUNL_MODIFY (102) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use UNLModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultClawback.h b/include/xrpl/protocol_autogen/transactions/VaultClawback.h index 270ccc94bb..d859b4a446 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultClawback.h +++ b/include/xrpl/protocol_autogen/transactions/VaultClawback.h @@ -21,7 +21,7 @@ class VaultClawbackBuilder; * Type: ttVAULT_CLAWBACK (70) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index b7e1527754..2925302dec 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -21,7 +21,7 @@ class VaultCreateBuilder; * Type: ttVAULT_CREATE (65) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: CreatePseudoAcct | CreateMptIssuance | MustModifyVault + * Privileges: Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultCreateBuilder to construct new transactions. @@ -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/protocol_autogen/transactions/VaultDelete.h b/include/xrpl/protocol_autogen/transactions/VaultDelete.h index 67cc32f543..3cef0ce599 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDelete.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDelete.h @@ -21,7 +21,7 @@ class VaultDeleteBuilder; * Type: ttVAULT_DELETE (67) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustDeleteAcct | DestroyMptIssuance | MustModifyVault + * Privileges: Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h index 5bb5362114..099342aa0c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h @@ -21,7 +21,7 @@ class VaultDepositBuilder; * Type: ttVAULT_DEPOSIT (68) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultSet.h b/include/xrpl/protocol_autogen/transactions/VaultSet.h index 14df70f13b..33dfe8bf21 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultSet.h +++ b/include/xrpl/protocol_autogen/transactions/VaultSet.h @@ -21,7 +21,7 @@ class VaultSetBuilder; * Type: ttVAULT_SET (66) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustModifyVault + * Privileges: Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 3211524e1f..dfa662f8fd 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h @@ -21,7 +21,7 @@ class VaultWithdrawBuilder; * Type: ttVAULT_WITHDRAW (69) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultWithdrawBuilder to construct new transactions. @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the VaultWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h index b8d551c5e1..a9aa7c2343 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h @@ -21,7 +21,7 @@ class XChainAccountCreateCommitBuilder; * Type: ttXCHAIN_ACCOUNT_CREATE_COMMIT (44) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAccountCreateCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h index 22b57803dc..9cb1f2eaaf 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h @@ -21,7 +21,7 @@ class XChainAddAccountCreateAttestationBuilder; * Type: ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION (46) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddAccountCreateAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h index 5e80c05aae..9184c83958 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h @@ -21,7 +21,7 @@ class XChainAddClaimAttestationBuilder; * Type: ttXCHAIN_ADD_CLAIM_ATTESTATION (45) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddClaimAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainClaim.h b/include/xrpl/protocol_autogen/transactions/XChainClaim.h index ec403b5eb8..e49434c878 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainClaim.h +++ b/include/xrpl/protocol_autogen/transactions/XChainClaim.h @@ -21,7 +21,7 @@ class XChainClaimBuilder; * Type: ttXCHAIN_CLAIM (43) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCommit.h b/include/xrpl/protocol_autogen/transactions/XChainCommit.h index 48b2263645..471a58dc53 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCommit.h @@ -21,7 +21,7 @@ class XChainCommitBuilder; * Type: ttXCHAIN_COMMIT (42) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h index 9614b0bd88..ae1269e825 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h @@ -21,7 +21,7 @@ class XChainCreateBridgeBuilder; * Type: ttXCHAIN_CREATE_BRIDGE (48) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateBridgeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h index d17759619f..4c6f98e48f 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h @@ -21,7 +21,7 @@ class XChainCreateClaimIDBuilder; * Type: ttXCHAIN_CREATE_CLAIM_ID (41) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateClaimIDBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h index e79c9139ce..a3f2930668 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h @@ -21,7 +21,7 @@ class XChainModifyBridgeBuilder; * Type: ttXCHAIN_MODIFY_BRIDGE (47) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainModifyBridgeBuilder to construct new transactions. diff --git a/include/xrpl/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 db4574b8fb..a21ed3ce5d 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -178,16 +178,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() { @@ -198,13 +188,6 @@ public: } private: - static TER - failInvariantCheck(TER const result); - - template - TER - checkInvariantsHelper(TER const result, XRPAmount const fee, std::index_sequence); - OpenViewSandbox base_; ApplyFlags flags_; std::optional view_; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index a71285f70e..aabde69ff9 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); ///////////////////////////////////////////////////// /* @@ -228,6 +259,13 @@ public: static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx, std::uint32_t extraBaseFeeMultiplier); + // Exposed for invariant checks (e.g. ValidVault) that need to know which + // ledger entry actually pays a transaction's fee, distinguishing an + // ordinary sender, a delegate, and pre-funded vs. co-signed fee + // sponsorship. + static FeePayer + getFeePayer(ReadView const& view, STTx const& tx); + /* Do NOT define an invokePreflight function in a derived class. Instead, define: @@ -494,9 +532,6 @@ private: std::pair reset(XRPAmount fee); - static FeePayer - getFeePayer(ReadView const& view, STTx const& tx); - TER consumeSeqProxy(SLE::pointer const& sleAccount); TER @@ -538,20 +573,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 8c69bb5aae..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 kInitialXrp 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 - * kInitialXrp drops exclusive. + * kInitialXRP drops exclusive. */ class NoZeroEscrow { diff --git a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h index b2f1c62a54..ca9755ea1c 100644 --- a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h +++ b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h @@ -1,9 +1,7 @@ #pragma once -#include #include - -#include +#include // IWYU pragma: export namespace xrpl { @@ -26,37 +24,8 @@ not have the relevant amendments enabled_. It's intentionally a pain in the neck so that bad code gets caught and fixed as early as possible. */ -// Bitwise flags, 86 files, used in macros files -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Privilege { - NoPriv = 0x0000, // The transaction can not do any of the enumerated operations - CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. - CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, - // which implies createAcct - MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object - MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT - // object, but does not have to - OverrideFreeze = 0x0010, // The transaction can override some freeze rules - ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT - CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance - DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance - MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT - // object (except by issuer) - MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT - // object (except by issuer) - MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. - MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault - MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault - MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. -}; - -constexpr Privilege -operator|(Privilege lhs, Privilege rhs) -{ - return safeCast( - safeCast>(lhs) | - safeCast>(rhs)); -} +// `enum Privilege` and its `operator|` live in , +// alongside the TxSettings struct that carries them out of transactions.macro. bool hasPrivilege(STTx const& tx, Privilege priv); diff --git a/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/LoanBrokerInvariant.h b/include/xrpl/tx/invariants/LoanBrokerInvariant.h index 979f57de35..2b8713fb90 100644 --- a/include/xrpl/tx/invariants/LoanBrokerInvariant.h +++ b/include/xrpl/tx/invariants/LoanBrokerInvariant.h @@ -19,6 +19,11 @@ namespace xrpl { * 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one * node (the root), which will only hold entries for `RippleState` or * `MPToken` objects. + * 2. Under featureLendingProtocolV1_1, an `ltLOAN_BROKER` may only be deleted + * by a `ttLOAN_BROKER_DELETE` transaction, and only when its pre-state + * `OwnerCount` is zero and its pre-state `DebtTotal` rounds to zero at the + * vault's `AssetsTotal` scale, as `LoanBrokerDelete::preclaim` requires. + * 3. At most one `ltLOAN_BROKER` may be deleted in a single transaction. * */ class ValidLoanBroker @@ -36,6 +41,15 @@ class ValidLoanBroker // pseudo-accounts. Key is the brokerID / index. It will be used to find the // LoanBroker object if brokerBefore and brokerAfter are nullptr std::map brokers_; + // The broker whose ledger entry was deleted by this transaction, if any. + // Only ttLOAN_BROKER_DELETE removes a broker, and it removes exactly one. + // This is the pre-transaction state, which is what LoanBrokerDelete::preclaim + // reads when it decides whether the broker may be deleted, so the deletion invariants inspect + // the same DebtTotal and OwnerCount that the transactor did. + SLE::const_pointer deletedBroker_ = nullptr; + // Set if visitEntry observes more than one ltLOAN_BROKER deletion in the + // same transaction. Enforced as its own invariant in finalize. + bool multipleBrokerDeletions_ = false; // Collect all the modified trust lines. Their high and low accounts will be // loaded to look for LoanBroker pseudo-accounts. std::vector lines_; diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..34ce1a4dc2 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -15,7 +15,33 @@ namespace xrpl { /** * @brief Invariants: Loans are internally consistent * - * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`. + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. + * 3. An `ltLOAN` may only be created by a `ttLOAN_SET` transaction. + * 4. Prior to `featureLendingProtocolV1_1`, the `lsfLoanOverpayment` flag on a + * Loan must not change. From `featureLendingProtocolV1_1` onward the same + * rule is enforced by `NoModifiedUnmodifiableFields`. + * 5. Under `featureLendingProtocolV1_1`: + * a. An `ltLOAN` may only be deleted by a `ttLOAN_DELETE` transaction. + * b. If `Loan.PaymentRemaining = 0` then `Loan.NextPaymentDueDate = 0`. + * c. The `lsfLoanImpaired` flag may only change through a `ttLOAN_MANAGE` + * or `ttLOAN_PAY` transaction. + * d. The `lsfLoanDefault` flag may only change through a `ttLOAN_MANAGE` + * transaction. Combined with `NoModifiedUnmodifiableFields`, which + * rejects any clearing of `lsfLoanDefault`, this makes the flag + * write-once: `ttLOAN_MANAGE` may set it, and no transaction may + * clear it. + * e. Interest due, computed as `TotalValueOutstanding - + * PrincipalOutstanding - ManagementFeeOutstanding`, must not be + * negative. + * f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must + * reference a live `ltVAULT`. + * g. Post-conditions for the Loan paid down by a successful `ttLOAN_PAY`: + * `PaymentRemaining > 0` after: `PrincipalOutstanding` and + * `PaymentRemaining` strictly decrease; `NextPaymentDueDate` + * advances by N * `PaymentInterval`, N > 0. + * `PaymentRemaining == 0` after: pinned by checks 1 and 5b. * */ class ValidLoan @@ -23,6 +49,9 @@ class ValidLoan // Pair is . After is used for most of the checks, except // those that check changed values. std::vector> loans_; + // Loans removed from the ledger, in the same form as loans_. + // Note that `after` holds the erased entry, so it is not null. + std::vector> deletedLoans_; public: void diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index 5740cd5be2..ddda348d2e 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -215,6 +215,13 @@ class ValidMPTTransfer // Deleted MPToken // MPToken key: true if MPTAuthorized is set hash_map deletedAuthorized_; + // Every touched AccountRoot (not only pseudos): + // AccountID -> whether it was a pseudo-account BEFORE this transaction + // applied. Needed because a transaction may erase a pseudo-account and + // move MPT out of it in the same transaction; by finalize() time the + // view no longer shows it as a pseudo-account (or as existing at all). + // False entries freeze the pre-tx classification for touched non-pseudos. + hash_map pseudoAccountsBefore_; public: /** diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..ee52f4edb3 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,20 @@ 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). From + * featureLendingProtocolV1_1 onwards, immutability of the vault's Asset, + * pseudo-account and ShareMPTID is likewise enforced by + * NoModifiedUnmodifiableFields; prior to that amendment it is checked here. */ class ValidVault { @@ -55,6 +68,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&); }; @@ -115,20 +131,57 @@ private: deltaAssets(AccountID const& id) const; /** - * @brief Return the vault-asset delta for the transaction's sending - * account, adjusted for the fee. + * @brief Return the AccountRoot whose XRP balance actually absorbed a + * transaction's fee, if any. * - * Calls @c deltaAssets for @c tx[sfAccount] and, for non-delegated XRP - * transactions, adds the consumed fee back so the invariant sees the net - * asset movement rather than the fee-reduced balance change. + * Mirrors @c Transactor::getFeePayer, but resolves to @c std::nullopt for + * a pre-funded sponsorship: that fee is drawn from the @c ltSponsorship + * object's @c sfFeeAmount, never from the sponsor's own AccountRoot, so + * there is no balance to add back there. * - * @param tx The transaction being applied. - * @param fee Fee charged by this transaction. + * @param view Read-only view of the ledger after the transaction. + * @param tx The transaction being applied. + * @return The fee-paying AccountRoot's id, or @c std::nullopt when the + * fee was not drawn from any AccountRoot balance. + */ + [[nodiscard]] static std::optional + feePayerAccountRoot(ReadView const& view, STTx const& tx); + + /** + * @brief Return the vault-asset delta for a party inspected as a + * withdrawal/deposit counterparty, adjusted for the fee. + * + * Calls @c deltaAssets for @p id and, for XRP transactions, adds the + * consumed fee back only when @p id is the AccountRoot that actually + * paid it (per @c feePayerAccountRoot) -- so the invariant sees the net + * asset movement rather than a fee-reduced balance change, regardless of + * whether @p id is the sender, a distinct destination, a delegate, or a + * co-signed fee sponsor. Post-@c fixCleanup3_4_0, any resulting + * economically-zero delta is always normalized to absence. + * + * Pre-@c fixCleanup3_4_0 this replicates the legacy behaviour exactly: + * only @c tx[sfAccount] could ever receive a fee correction (and only + * when it was itself, per @c STTx::getFeePayerID, the fee payer). After + * that sender-only correction a zero delta is collapsed to absence; if + * the correction does not apply, a present-zero delta is kept as-is. + * + * @param view Read-only view of the ledger after the transaction. + * @param id Account being inspected as sender or destination. + * @param tx The transaction being applied. + * @param fee Fee charged by this transaction. + * @param fix340Enabled Whether @c fixCleanup3_4_0 is enabled, as already + * determined once by @c finalize. * @return The fee-adjusted delta, or @c std::nullopt if the net delta is - * zero or the account entry was not touched. + * zero (always post-amendment; pre-amendment only after the + * sender-only fee correction) or the entry was not touched. */ [[nodiscard]] std::optional - deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const; + deltaAssetsForParty( + ReadView const& view, + AccountID const& id, + STTx const& tx, + XRPAmount fee, + bool fix340Enabled) const; /** * @brief Return the vault-share balance-change delta for an account. @@ -153,6 +206,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 precedes @c + * RedemptionDate by at least @c kLoanRedemptionBuffer) 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..ea0ad3b253 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -109,6 +109,11 @@ public: * @param lpTokens current LPT balance * @param lpTokensWithdraw amount of tokens to withdraw * @param tfee trading fee in basis points + * @param freezeHandling whether a frozen balance is reported as zero + * @param authHandling whether an unauthorized MPT balance is reported as + * zero + * @param reserveHandling whether the recipient owner-reserve check is + * enforced when a trustline or MPToken has to be auto-created * @param withdrawAll if withdrawing all lptokens * @param priorBalance balance before fees * @return @@ -118,6 +123,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -127,6 +133,7 @@ public: std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal); @@ -138,12 +145,22 @@ 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 * @param lpTokensAMMBalance current AMM LPT balance * @param lpTokensWithdraw amount of lptokens to withdraw * @param tfee trading fee in basis points + * @param freezeHandling whether a frozen balance is reported as zero + * @param authHandling whether an unauthorized MPT balance is reported as + * zero + * @param reserveHandling whether the recipient owner-reserve check is + * enforced when a trustline or MPToken has to be auto-created * @param withdrawAll if withdraw all lptokens * @param priorBalance balance before fees * @return @@ -153,6 +170,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, @@ -162,6 +180,7 @@ public: std::uint16_t tfee, FreezeHandling freezeHandling, AuthHandling authHandling, + ReserveHandling reserveHandling, WithdrawAll withdrawAll, XRPAmount const& priorBalance, beast::Journal const& journal); diff --git a/include/xrpl/tx/transactors/vault/VaultWithdraw.h b/include/xrpl/tx/transactors/vault/VaultWithdraw.h index 22ad39d26d..b61af8b323 100644 --- a/include/xrpl/tx/transactors/vault/VaultWithdraw.h +++ b/include/xrpl/tx/transactors/vault/VaultWithdraw.h @@ -20,6 +20,9 @@ public: { } + static bool + checkExtraFeatures(PreflightContext const& ctx); + static NotTEC preflight(PreflightContext const& ctx); diff --git a/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..d2dee651f1 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -1,47 +1,146 @@ 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.97.1 (c980f4866 2026-06-30) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit + ✅ 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.97 (8bab26f4f6 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.97.1 (8bab26f4 2026-07-14) + /nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/fqpjz4l0nsnji8b2pz57mnj0akbp6hcl-rust-nightly/bin/rust-nightly + ✅ rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 36 checked tools are present and runnable. +✅ All 45 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..ba5d5e65b1 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -1,55 +1,174 @@ 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.97.1 (c980f4866 2026-06-30) + /nix/store/88abzp43ywyzql1rhf8jh5aj5n5j7xzr-cargo-1.97.1-x86_64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit + ✅ 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.97 (8bab26f4f6 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/j7kf7a5h4xypzp6x1skg4dsdx2k4fwb3-rust-nightly/bin/rust-nightly + ✅ rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/6f1icmb2za20kxn30pgmbv5jq9fnbf4z-rustfmt-preview-1.97.1-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 53 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..2b45230327 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -1,55 +1,174 @@ 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.97.1 (c980f4866 2026-06-30) + /nix/store/6hch2qrr86n2sa2m90lrpxrfxxwbkayl-cargo-1.97.1-aarch64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit + ✅ 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.97 (8bab26f4f6 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer + ✅ rust-nightly + rustc 1.99.0-nightly (87e5904f5 2026-07-20) + /nix/store/c59pxk1yikdlf129qwyg4fplmxcrha0k-rust-nightly/bin/rust-nightly + ✅ rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/nd8g81wv1smnvdpy4whpcyv2siwjmaan-rustfmt-preview-1.97.1-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 53 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..7eae693ad2 100644 --- a/nix/docker/Dockerfile +++ b/nix/docker/Dockerfile @@ -8,8 +8,10 @@ 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/rust-nightly.sh /tmp/build/nix/rust-nightly.sh +COPY nix/rust.nix /tmp/build/nix/rust.nix COPY nix/utils.nix /tmp/build/nix/utils.nix COPY flake.nix /tmp/build/ COPY flake.lock /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..9af230097d 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -16,23 +16,7 @@ let exec ${pkgs.python3}/bin/python3 ${llvmPackages.clang-unwrapped}/bin/run-clang-tidy "$@" ''; - # rust-overlay's toolchain propagates the *default* stdenv.cc onto the PATH (so - # cargo has a linker). That default may be different from the clang we pin here, - # so it shadows our clang and the build can silently use a different compiler - # version. Drop that cc from every propagation channel instead of pinning a - # replacement: the toolchain then carries no compiler and cargo just uses the - # active shell's stdenv cc. Must cover all channels — rust-overlay uses both - # propagatedBuildInputs and depsHostHostPropagated. - rustToolchainBase = pkgs.rust-bin.fromRustupToolchainFile ../rust-toolchain.toml; - rustToolchain = - let - defaultCc = pkgs.stdenv.cc; # default compiler from nixpkgs stdenv - withoutDefaultCc = builtins.filter (dep: (dep.outPath or "") != defaultCc.outPath); - in - rustToolchainBase.overrideAttrs (old: { - propagatedBuildInputs = withoutDefaultCc (old.propagatedBuildInputs or [ ]); - depsHostHostPropagated = withoutDefaultCc (old.depsHostHostPropagated or [ ]); - }); + rust = import ./rust.nix { inherit pkgs; }; # Nix wraps its toolchain so that binaries are exposed only under unsuffixed # names (gcc, g++, clang-tidy, ...). Several tools probe for a @@ -50,6 +34,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 +44,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. @@ -102,51 +92,38 @@ in mkGcov ; - commonPackages = with pkgs; [ - clangToolLinks - runClangTidyLink - ccache - clangbuildanalyzer - clangTools - cmake - conan - curlMinimal # needed for codecov/codecov-action - doxygen - file # needed for cpack in Clio - gcovr - gh - git - git-cliff - git-lfs - gnumake - gnupg # needed for signing commits & codecov/codecov-action - graphviz - less # needed for git diff - mold - nettools # provides netstat, used to debug failures in CI - ninja - patchelf - 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 - zip - # Rust packages - cargo-audit - cargo-llvm-cov - cargo-nextest - corrosion - rustToolchain - ]; + commonPackages = + (with pkgs; [ + clangToolLinks + runClangTidyLink + ccache + clangbuildanalyzer + clangTools + cmake + conan + curlMinimal # needed for codecov/codecov-action + doxygen + file # needed for cpack in Clio + gcovr + gh + git + git-cliff + git-lfs + gnumake + gnupg # needed for signing commits & codecov/codecov-action + graphviz + less # needed for git diff + mold + nettools # provides netstat, used to debug failures in CI + ninja + patchelf + perl # needed for openssl + pkg-config + pre-commit + python3 + runClangTidy + vim + zip + ]) + ++ rust.packages; } diff --git a/nix/rust-nightly.sh b/nix/rust-nightly.sh new file mode 100644 index 0000000000..263e647d8e --- /dev/null +++ b/nix/rust-nightly.sh @@ -0,0 +1,23 @@ +#!@runtimeShell@ +# Reaches the nightly Rust toolchain, which is deliberately kept off PATH. +# Packaged by nix/rust.nix, which explains why. + +set -euo pipefail + +usage() { + echo "usage: rust-nightly (path | run ...)" >&2 + exit 2 +} + +case "${1-}" in + path) printf '%s\n' "@rustNightlyBin@" ;; + run) + shift + if [[ $# -eq 0 ]]; then + usage + fi + export PATH="@rustNightlyBin@:${PATH}" + exec "$@" + ;; + *) usage ;; +esac diff --git a/nix/rust.nix b/nix/rust.nix new file mode 100644 index 0000000000..8be48dcad0 --- /dev/null +++ b/nix/rust.nix @@ -0,0 +1,84 @@ +# The Rust half of the tool set shared by the CI environment and the dev shell: +# the stable toolchain pinned by rust-toolchain.toml, the nightly the Rust +# coverage job needs, and the cargo plugins. Consumed by packages.nix. +{ pkgs }: +let + # rust-overlay's toolchain propagates the *default* stdenv.cc onto the PATH (so + # cargo has a linker). That default may be different from the clang we pin + # elsewhere, so it shadows our clang and the build can silently use a different + # compiler version. Drop that cc from every propagation channel instead of + # pinning a replacement: the toolchain then carries no compiler and cargo just + # uses the active shell's stdenv cc. + # + # The channel list is every list mkDerivation propagates to a dependent's + # environment (including the two legacy aliases). rust-overlay currently only + # uses propagatedBuildInputs and depsHostHostPropagated, but covering all of + # them means an upstream switch to another channel cannot quietly put the + # compiler back on PATH. + dropDefaultCc = + toolchain: + let + defaultCc = pkgs.stdenv.cc; # default compiler from nixpkgs stdenv + withoutDefaultCc = builtins.filter (dep: (dep.outPath or "") != defaultCc.outPath); + in + toolchain.overrideAttrs ( + old: + pkgs.lib.genAttrs [ + "depsBuildBuildPropagated" + "propagatedNativeBuildInputs" # alias of depsBuildHostPropagated + "depsBuildTargetPropagated" + "depsHostHostPropagated" + "propagatedBuildInputs" # alias of depsHostTargetPropagated + "depsTargetTargetPropagated" + ] (channel: withoutDefaultCc (old.${channel} or [ ])) + ); + + rustToolchain = dropDefaultCc (pkgs.rust-bin.fromRustupToolchainFile ../rust-toolchain.toml); + + # cargo-llvm-cov honours the #[coverage(off)] that keeps unit tests out of the + # coverage report only under a nightly rustc, and looks for llvm-profdata and + # llvm-cov in that same toolchain's sysroot — hence llvm-tools-preview. + # + # Not every nightly ships every component, so `nightly.latest` breaks on the + # days llvm-tools-preview is absent; selectLatestNightlyWith walks back to the + # newest one that has it. The result is the newest such nightly *known to the + # locked rust-overlay*, which means updating flake.lock moves the compiler that + # produces the coverage numbers — and with it the rustc version recorded in + # nix/check-tools/*.txt, so those snapshots need regenerating alongside. + rustNightly = dropDefaultCc ( + pkgs.rust-bin.selectLatestNightlyWith ( + toolchain: toolchain.minimal.override { extensions = [ "llvm-tools-preview" ]; } + ) + ); + + # A second toolchain cannot go on PATH: its cargo and rustc would collide with + # the pinned stable's in the ci-env buildEnv, which resolves collisions by + # picking one silently. Reaching the nightly only through this wrapper keeps it + # in the image closure (the Docker build copies the whole closure, not just + # what is linked into /bin) while leaving it inactive everywhere that does not + # ask for it. + # + # The script's `path` subcommand exists for scopes wider than one command — a + # CI job appending to $GITHUB_PATH, so that the cargo cache action's own + # `rustc -vV` probe, which runs in a step of its own, agrees with the toolchain + # the build will use. + rustNightlyScript = pkgs.replaceVarsWith { + name = "rust-nightly"; + src = ./rust-nightly.sh; + dir = "bin"; + isExecutable = true; + replacements = { + inherit (pkgs) runtimeShell; + rustNightlyBin = "${rustNightly}/bin"; + }; + }; +in +{ + packages = [ + pkgs.cargo-audit + pkgs.cargo-llvm-cov + pkgs.cargo-nextest + rustNightlyScript + rustToolchain + ]; +} diff --git a/package/Dockerfile b/package/Dockerfile deleted file mode 100644 index 6cb2a09933..0000000000 --- a/package/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -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..6e88309ecd 100644 --- a/package/README.md +++ b/package/README.md @@ -8,10 +8,14 @@ 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.py Staging and build script (called by the CMake `package` target and CI) + sign_rpm.py Signs the built RPMs (called by CI when publishing) + docker/ + Dockerfile Packaging image, built by `build-packaging-images.yml`; installs its tooling with `bin/install-packaging-tools.sh` + publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec - debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) + debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, xrpld.lintian-overrides, source/format) shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) @@ -21,18 +25,19 @@ package/ ## Prerequisites -Packaging targets and their container images are declared in -[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json) -under `package_configs`, one entry per distro. Today only `linux/amd64` is -emitted. Each entry pins its full container image in an `image` field; to move -to a new image, edit that field and both CI and local builds pick it up. The -package format (deb or rpm) is inferred at build time from the container's -package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). +Packaging is declared on the build configs themselves, in +[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json): +a config that is also packaged carries a `package` map, so its binaries and its +packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map +pins the full container image in `image` — edit that field to move to a new +image and both CI and local builds pick it up — and names the format that image +builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two +have to stay in step. -| Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | -| ------------ | ---------------------------------------------------------- | --------------------------------------------------- | -| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild` | -| DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | +| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | +| ------------ | ---------------------------------------------------------- | -------------------------------------------------------------- | +| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | +| DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13, `lintian` | To print the full packaging matrix (artifact names and images) for the current `linux.json`: @@ -46,20 +51,27 @@ To print the full packaging matrix (artifact names and images) for the current ### Via CI Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call -`reusable-package.yml`. That workflow generates its own packaging matrix from -`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` -binary artifacts and runs in that distro's container, so the package format -follows from the container's package manager. The packaging script derives the -package version from the downloaded binary's `xrpld --version` output; no CMake -configure or build step is needed inside the packaging job. +`reusable-package.yml`, which runs in three stages: -The binaries come from the `debian` and `rhel` build configurations in -`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +1. `package` fans out one job per config carrying a `package` map, building and + signing in that config's container, and uploading `-pkg` alongside + `-pkg-debug` for the much larger debug symbols. +2. `test-install` installs `-pkg` in the container of every distro the + packages target and runs the binaries there, so one that cannot be installed + never reaches Nexus. +3. `publish` uploads both artifacts, or lists what it would upload. + +The packaging script derives the package version from the downloaded binary's +`xrpld --version` output; no CMake configure or build step is needed inside the +packaging job. + +The binaries come from the `debian` and `rhel` build configs themselves — the +ones carrying the `package` map — which pass `-Dvalidator_keys=ON` so that the build job produces `validator-keys` next to `xrpld` and uploads it as the -`validator-keys-` artifact. The packaging entry for a distro names -both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a -packaged configuration must keep `-Dvalidator_keys=ON`. +`validator-keys-` artifact. The packaging matrix names both +artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that +same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs +are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix. `validator-keys` is fetched from an exact commit pinned in [`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given @@ -72,11 +84,10 @@ With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash -# From the repo root. Each distro's container image is the `image` field of its -# package_configs entry in linux.json; the package format is inferred from the -# container's package manager. Example for the rpm-producing image (use -# .package_configs.debian[0].image for the deb image): -IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json) +# From the repo root. Each distro's container image is the `package.image` field +# of its config in linux.json. Example for the rpm-producing image (use +# .configs.debian[0].package.image and --package-type deb for the other one): +IMAGE=$(jq -r '.configs.rhel[0].package.image' .github/scripts/strategy-matrix/linux.json) PKG_RELEASE=1 @@ -84,10 +95,12 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "${IMAGE}" \ - ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" + ./package/build_pkg.py \ + --package-type rpm \ + --pkg-release "${PKG_RELEASE}" \ + --channel UNRELEASED -# Output: -# build/debbuild/*.deb (DEB + dbgsym .ddeb) +# Output (the deb image writes build/debbuild/*.deb instead): # build/rpmbuild/RPMS/x86_64/*.rpm ``` @@ -111,29 +124,94 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL The `cmake/XrplPackaging.cmake` module defines the `package` target only if at least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target -builds both binaries before packaging. `build_pkg.sh` then infers the package -format from the host's package manager. The packaging script installs to -FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of -`CMAKE_INSTALL_PREFIX`. +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise, and `--channel UNRELEASED`. +The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, +etc.) regardless of `CMAKE_INSTALL_PREFIX`. -The package version is not a CMake input on this path: `build_pkg.sh` derives it +The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. -## How `build_pkg.sh` works +## Publishing packages -`build_pkg.sh` derives the `xrpld` software version from +Packages are published to the XRPLF repositories on Sonatype Nexus at +`https://packages.xrplf.org`. The `release-info` action decides the channel from +the event, and `publish_pkg.py` maps that channel to its repositories: + +| Event | Version | Channel | DEB repository | RPM upload repository | +| ------------------------ | ----------------- | --------- | -------------- | --------------------- | +| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable-hosted` | +| tag | `X.Y.Z-rcN` | `rc` | `deb-rc` | `rpm-rc-hosted` | +| tag | `X.Y.Z-bN` | `beta` | `deb-beta` | `rpm-beta-hosted` | +| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | +| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | + +Only a tag names a channel — do not extend that to `develop`, where +`BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final +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 `.git`, e.g. +`857.20260826gitb6a8995` — the leading run number keeps each push superseding +the last, and the date and hash say which commit a package on +`packages.xrplf.org` came from. Both reach the packaging scripts as arguments, +so neither script derives anything itself. + +Publishing is its own job, gated behind `test-install`, uploading from the same +image that built the packages with the `publish_pkg.py` shipped in it — the +same copy other repositories run. Without `publish: true` the job is a +`--dry-run`, listing the uploads it would make without needing credentials, so +any run that builds packages also exercises the upload routing. `on-trigger.yml` +passes `publish: true` for develop pushes in `XRPLF/rippled` and `on-tag.yml` +for tags in any `XRPLF` repository, both authenticating with the +`NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the +Conan remote; `on-pr.yml` never publishes. + +Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: + +- Each apt-hosted repository needs a distribution (ours use `any`) and a PGP + signing keypair configured in Nexus, which rejects one created without a + keypair. Nexus signs the apt metadata with it, never the packages. +- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` + repository sits behind a `rpm-` yum group repository whose metadata + Nexus signs. Uploads go to the hosted repository; clients point at the group + and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs + themselves, so `sign_rpm.py` signs them before they are uploaded, and clients + verify them with `gpgcheck=1`. +- yum metadata is rebuilt asynchronously, so a successful publish is not + immediately installable. +- 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. + +### Publishing from other repositories + +`publish_pkg.py` knows nothing about `xrpld`, so the packaging image +installs it at `/usr/local/bin/publish_pkg.py` for other XRPLF repositories that +build their packages elsewhere. + +## How `build_pkg.py` works + +`build_pkg.py` derives the `xrpld` software version from `${BUILD_DIR}/xrpld --version` in both package formats. The binary's version is already SemVer-validated by `BuildInfo`. -`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or +`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or `3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before the final release. If that normalized package version still contains `-`, packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as the upstream/revision separator. +> [!NOTE] +> Debug and sanitizer builds are not packaged yet. + `pkg_version` is the normalized package metadata version derived inside -`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release +`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. `PKG_RELEASE` is a different value: the package release iteration for that @@ -151,32 +229,41 @@ 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. +`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it +from the build host, so the RHEL image can track a newer release without +changing what the packages claim to target. + +The Debian changelog entry carries the channel passed as `--channel`, which +only accepts the channels in the table above plus `UNRELEASED`, the Debian +convention for a build that targets no channel at all — what local and CMake +builds pass, since nothing publishes them. An unsupported pre-release, and +build metadata on a final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. -The package format (`deb` or `rpm`) is inferred from the host's package -manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those -fail early. +The package format is `--package-type`, either `deb` or `rpm`. It is required, +so a job never silently builds the wrong format for the image it runs in; the +matching build tool still has to be on PATH. -Flags are for explicit invocation; environment variables are intended for -CMake/CI integration. The CI workflow and the CMake `package` target both invoke -`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR` and `PKG_RELEASE` via env -and lets the script use defaults for the rest. +Every input is a named argument, and every argument but `--build-dir` and +`--pkg-release` is required. The repository root is not an argument +at all: the script reads it from its own location. Only secrets stay in the +environment, so they never reach the process list -- `PKG_SIGNING_KEY` for +`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`. -It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls +Signing is not part of this script. `sign_rpm.py` does it in a separate CI step +that only runs when publishing, so a published RPM is always signed and a local +build never needs a key. + +It resolves the build directory to an absolute path, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, and shared support files into the staging area, and invokes the platform build -tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging -environment; a missing or non-runnable one fails early. That runtime check is -what catches a binary still linked against the Nix store's ELF loader (see +tool. Both binaries must be present in the build directory and must run in the +packaging environment; a missing or non-runnable one fails early. That runtime +check is what catches a binary still linked against the Nix store's ELF loader (see `patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM @@ -186,14 +273,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 @@ -205,35 +287,45 @@ service restart. 2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and `validator-keys-LICENSE`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. -4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. +4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` where `dh_installsystemd`, `dh_installsysusers`, `dh_installtmpfiles` and `dh_installlogrotate` pick them up automatically. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. -7. Output: `debbuild/*.deb` and `debbuild/*.ddeb` (dbgsym package) + + It also rewrites the `libc6` bound to `LIBC_MIN` in `debian/rules`, the glibc + the Nix toolchain builds against. `dpkg-shlibdeps` would otherwise derive it + from the build host's symbols file — on trixie that yields `libc6 (>= 2.34)` + because of `sysconf`, locking out distros the binaries run on. A check fails + the build if either binary outgrows `LIBC_MIN`. + +7. Output: `debbuild/*.deb`, the binary package and the `-dbgsym` package. + Debian gives dbgsym packages a `.deb` extension; only Ubuntu uses `.ddeb`. ## Post-build verification ```bash -# DEB -dpkg-deb -c debbuild/*.deb | grep -E 'systemd|sysusers|tmpfiles' +# DEB (one invocation per package: the dbgsym package is a .deb too) +for deb in debbuild/*.deb; do dpkg-deb -c "${deb}"; done | grep -E 'systemd|sysusers|tmpfiles' lintian -I debbuild/*.deb # RPM rpm -qlp rpmbuild/RPMS/x86_64/*.rpm ``` +`lintian` still reports `embedded-library zlib`, `no-manual-page` and +`initial-upload-closes-no-bugs`; only the `/usr/local` tags are overridden. + ## Reproducibility -`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit -time, or the current time outside a git tree, and exports it (override with -`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file -modification times to it via `%build_mtime_policy`. The remaining variables -below further improve reproducibility but are _not_ set by the script — export -them yourself if needed: +Both formats build reproducibly as they are: the same binaries at the same +commit give byte-identical packages on a rebuild, and nothing has to be +exported by hand. -```bash -export TZ=UTC -export LC_ALL=C.UTF-8 -export GZIP=-n -export DEB_BUILD_OPTIONS="noautodbgsym reproducible=+fixfilepath" -``` +`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time. +`dpkg-buildpackage` honours it on its own; the RPM spec sets three macros: + +- `%clamp_mtime_to_source_date_epoch` — file modification times, from + `SOURCE_DATE_EPOCH`. +- `%use_source_date_epoch_as_buildtime` — the `BUILDTIME` header, from the + same. +- `%_buildhost` — pinned, so the builder's hostname stays out of the header. diff --git a/package/build_pkg.py b/package/build_pkg.py new file mode 100755 index 0000000000..1aaf53d5ff --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries. + +The build tool for the chosen format has to be on PATH, so this runs in the +vanilla distro image that matches it. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import textwrap +from datetime import datetime, timezone +from pathlib import Path + +# This script lives in the repository it packages. +SRC_DIR = Path(__file__).resolve().parents[1] + +PRE_RELEASE = re.compile(r"^(b|rc)(0|[1-9][0-9]*)(\+.*)?$") + +# Files both packaging systems consume, staged under the same names. +STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") +STAGED_FROM_SRC = { + "cfg/xrpld-example.cfg": "xrpld.cfg", + "cfg/validators-example.txt": "validators.txt", + "LICENSE.md": "LICENSE.md", + "README.md": "README.md", +} +STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") + + +def run(*command: object, cwd: Path | None = None) -> None: + """Echo a command and run it.""" + argv = [str(part) for part in command] + print("+ " + " ".join(argv), flush=True) + subprocess.run(argv, check=True, cwd=cwd) + + +def capture(*command: object) -> str: + """Run a command and return its stdout, stripped.""" + argv = [str(part) for part in command] + # stderr is left alone so a failing command explains itself. + return subprocess.run( + argv, stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def package_version(reported: str) -> str: + """Normalise a reported version into one the package formats accept. + + A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before + the final 3.2.0; a no-op for a final release. + """ + base, _, pre_release = reported.partition("-") + version = f"{base}~{pre_release}" if pre_release else base + + # BuildInfo already SemVer-validates the version. Packaging adds one narrower + # constraint: after normalisation the version must not contain '-', because + # RPM forbids it in Version and Debian reads it as the revision separator. + assert "-" not in version, ( + f"unsupported version {reported!r}: {version!r} cannot contain '-'. " + "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." + ) + assert pre_release or "+" not in reported, ( + f"unsupported version {reported!r}: " + "build metadata is only supported on bN/rcN pre-releases." + ) + assert not pre_release or PRE_RELEASE.match(pre_release), ( + f"unsupported pre-release {pre_release!r}: use bN or rcN, " + "e.g. 3.2.0-b1 or 3.2.0-rc2." + ) + return version + + +def read_version(xrpld: Path) -> str: + """Read the version from the binary that is about to be packaged.""" + fields = capture(xrpld, "--version").partition("\n")[0].split() + assert len(fields) >= 3, f"cannot read a version from {xrpld} --version" + return fields[2] + + +def check_binaries(build_dir: Path) -> None: + """Fail unless the binaries and their notices are present and runnable.""" + missing = [ + name + for name in ("xrpld", "validator-keys") + if not os.access(build_dir / name, os.X_OK) + ] + assert not missing, ( + f"missing or not executable in {build_dir}: {' '.join(missing)}. " + "Both binaries come from a single CMake build directory configured with " + "-Dxrpld=ON -Dvalidator_keys=ON." + ) + + # No package goes out without the attribution. + notice = build_dir / "validator-keys-LICENSE" + assert notice.is_file(), ( + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " + "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + ) + + # Catches a binary still pointing at the Nix store's ELF loader, since + # packaging runs in a vanilla distro container. + capture(build_dir / "validator-keys", "--version") + + +def source_date_epoch() -> int: + """The last commit's timestamp.""" + # git refuses to read a checkout owned by another user, which is what a CI + # container or a bind mount hands it. + return int( + capture( + "git", + "-c", + f"safe.directory={SRC_DIR}", + "-C", + SRC_DIR, + "log", + "-1", + "--format=%ct", + ) + ) + + +def stage_common(build_dir: Path, dest: Path) -> None: + """Copy everything both packaging systems consume into dest.""" + dest.mkdir(parents=True, exist_ok=True) + + for name in STAGED_FROM_BUILD: + shutil.copy2(build_dir / name, dest / name) + for source, name in STAGED_FROM_SRC.items(): + shutil.copy2(SRC_DIR / source, dest / name) + + +def stage_units(dest: Path) -> None: + """Copy the systemd, sysusers, tmpfiles and logrotate files into dest. + + Each format wants them somewhere else: rpmbuild reads them from SOURCES, + debhelper from debian/. + """ + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: + """Stage the spec and its sources, then build the binary RPMs.""" + topdir = build_dir / "rpmbuild" + for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (topdir / name).mkdir(parents=True, exist_ok=True) + + spec = topdir / "SPECS" / "xrpld.spec" + shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) + stage_common(build_dir, topdir / "SOURCES") + stage_units(topdir / "SOURCES") + + run( + "rpmbuild", + "-bb", + "--define", + f"_topdir {topdir}", + "--define", + f"pkg_version {version}", + "--define", + f"pkg_release {pkg_release}", + # The image tracks the newest distro, but the packages target el9. + "--define", + "dist .el9", + spec, + ) + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + + # debhelper picks these up from debian/ automatically. + stage_units(staging / "debian") + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + xrpld ({version}-{pkg_release}) {channel}; urgency=medium + * Release {reported}. + + -- XRPL Foundation {date} + """) + (staging / "debian" / "changelog").write_text(changelog) + + run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package-type", + required=True, + choices=("deb", "rpm"), + help="the package format to build", + ) + parser.add_argument( + "--build-dir", + type=Path, + default=Path("build"), + help="directory holding the xrpld and validator-keys binaries (default: %(default)s)", + ) + parser.add_argument( + "--pkg-release", + default="1", + help="package release iteration (default: %(default)s)", + ) + parser.add_argument( + "--channel", + required=True, + choices=("stable", "rc", "beta", "develop", "private", "UNRELEASED"), + help="release channel, written to debian/changelog", + ) + args = parser.parse_args() + package_type: str = args.package_type + build_dir: Path = args.build_dir.resolve() + pkg_release: str = args.pkg_release + channel: str = args.channel + + assert build_dir.is_dir(), ( + f"build directory not found: {build_dir}. Build the binaries before " + "packaging, or point --build-dir at the directory holding them." + ) + + check_binaries(build_dir) + reported = read_version(build_dir / "xrpld") + version = package_version(reported) + epoch = source_date_epoch() + + # rpmbuild and dpkg-buildpackage both honour this for file timestamps. + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + + # Remove both build trees, because a package left from an earlier build would + # otherwise be picked up and published alongside this one. + for tree in ("debbuild", "rpmbuild"): + shutil.rmtree(build_dir / tree, ignore_errors=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index d853bf95b7..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from the pre-built xrpld and validator-keys -# binaries. -# -# Flags override env vars; env vars override defaults. - -usage() { - cat <<'EOF' -Usage: build_pkg.sh [options] - -Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding the - xrpld and validator-keys - binaries [BUILD_DIR; default: ${PWD}/build] - --pkg-release N package release iteration [PKG_RELEASE; default: 1] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] - -h, --help show this help and exit -EOF -} - -need_arg() { - if [[ $# -lt 2 || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 2 - fi -} - -# Seed from env. CLI parsing below overrides these directly. -SRC_DIR="${SRC_DIR:-}" -BUILD_DIR="${BUILD_DIR:-}" -PKG_RELEASE="${PKG_RELEASE:-1}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --src-dir) - need_arg "$@" - SRC_DIR="$2" - shift 2 - ;; - --build-dir) - need_arg "$@" - BUILD_DIR="$2" - shift 2 - ;; - --pkg-release) - need_arg "$@" - PKG_RELEASE="$2" - shift 2 - ;; - --source-date-epoch) - need_arg "$@" - SOURCE_DATE_EPOCH="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="${BUILD_DIR:-${PWD}/build}" -if [[ ! -d "${BUILD_DIR}" ]]; then - echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -validator_keys_binary="${BUILD_DIR}/validator-keys" - -# Report both binaries at once: they share a single BUILD_DIR, so telling the -# reader to point it at one of them in isolation is advice they cannot follow. -missing=() -[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) -[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) - -if [[ ${#missing[@]} -gt 0 ]]; then - echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 - echo "Both binaries come from a single CMake build directory configured with" >&2 - echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 - echo "directory." >&2 - exit 1 -fi - -# Shipping validator-keys means shipping its notice, so treat it as required -# rather than letting a package go out without the attribution. -validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" -if [[ ! -f "${validator_keys_license}" ]]; then - echo "build_pkg.sh: missing ${validator_keys_license}." >&2 - echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 - echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 - exit 1 -fi - -# The binary must also *run* here. Packaging happens in a vanilla distro -# container, so this is what catches a binary still pointing at the Nix store's -# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is -# covered implicitly by the version query below. -if ! "${validator_keys_binary}" --version >/dev/null; then - echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 - exit 1 -fi - -xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')" - -if [[ -z "${xrpld_version}" ]]; then - echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2 - exit 1 -fi - -# The version as the package formats consume it: identical to xrpld_version -# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before -# the final 3.2.0; a no-op for a final release. Lowercase = derived internally, -# not an input (cf. pkg_type). -pkg_version="${xrpld_version}" -pre_release="" -if [[ "${xrpld_version}" == *-* ]]; then - pre_release="${xrpld_version#*-}" - pkg_version="${xrpld_version%%-*}~${pre_release}" -fi - -# BuildInfo already SemVer-validates the binary's version. Packaging adds one -# narrower constraint: after pre-release normalization, the package version must -# not contain '-' because RPM forbids it in Version and Debian uses it as the -# upstream/revision separator. -if [[ "${pkg_version}" == *-* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Package version '${pkg_version}' cannot contain '-'." >&2 - echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Build metadata is only supported on bN/rcN pre-releases." >&2 - exit 1 -fi - -if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if command -v apt-get >/dev/null 2>&1; then - pkg_type=deb -elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then - pkg_type=rpm -else - echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2 - exit 1 -fi - -if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then - if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH="$(date +%s)" - fi -fi - -export SOURCE_DATE_EPOCH -CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" - -SHARED="${SRC_DIR}/package/shared" -DEBIAN_DIR="${SRC_DIR}/package/debian" - -# Stage files that both packaging systems consume using the same filenames. -stage_common() { - local dest="$1" - mkdir -p "${dest}" - - cp "${xrpld_binary}" "${dest}/xrpld" - cp "${validator_keys_binary}" "${dest}/validator-keys" - cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" - - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" -} - -build_rpm() { - local topdir="${BUILD_DIR}/rpmbuild" - rm -rf "${topdir}" - mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - - cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" - stage_common "${topdir}/SOURCES" - - set -x - rpmbuild -bb \ - --define "_topdir ${topdir}" \ - --define "pkg_version ${pkg_version}" \ - --define "pkg_release ${PKG_RELEASE}" \ - "${topdir}/SPECS/xrpld.spec" -} - -build_deb() { - local staging="${BUILD_DIR}/debbuild/source" - rm -rf "${staging}" - mkdir -p "${staging}" - - stage_common "${staging}" - cp -r "${DEBIAN_DIR}" "${staging}/debian" - - cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service" - cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers" - cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" - cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - - # 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}
-EOF
-
-    chmod +x "${staging}/debian/rules"
-
-    set -x
-    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
-}
-
-"build_${pkg_type}"
diff --git a/package/debian/control b/package/debian/control
index 62e5d79ef1..359f39f770 100644
--- a/package/debian/control
+++ b/package/debian/control
@@ -4,6 +4,7 @@ Priority: optional
 Maintainer: XRPL Foundation 
 Rules-Requires-Root: no
 Build-Depends:
+ binutils,
  debhelper-compat (= 13)
 Standards-Version: 4.7.0
 Homepage: https://github.com/XRPLF/rippled
@@ -11,8 +12,6 @@ Vcs-Git: https://github.com/XRPLF/rippled.git
 Vcs-Browser: https://github.com/XRPLF/rippled
 
 Package: xrpld
-Section: net
-Priority: optional
 Architecture: any
 Depends:
  ${shlibs:Depends},
diff --git a/package/debian/copyright b/package/debian/copyright
index 2cf673854a..baaa12e13c 100644
--- a/package/debian/copyright
+++ b/package/debian/copyright
@@ -1,5 +1,5 @@
 Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Upstream-Name: rippled
+Upstream-Name: xrpld
 Source: https://github.com/XRPLF/rippled
 
 Files: *
@@ -15,7 +15,7 @@ Copyright: 2016, Ripple Labs Inc.
  2009-2010, Satoshi Nakamoto
  2011, The Bitcoin developers
  2003-2005, Tom Wu
-License: ISC
+License: ISC and BSL-1.0 and MIT and Tom-Wu
 Comment: Built from https://github.com/ripple/validator-keys-tool at the commit
  pinned in cmake/XrplValidatorKeys.cmake. Besides ISC-licensed code it
  incorporates work under the Boost Software License 1.0 (ASIO), the MIT/X11
@@ -35,3 +35,74 @@ License: ISC
  WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+License: BSL-1.0
+ Boost Software License - Version 1.0 - August 17th, 2003
+ .
+ Permission is hereby granted, free of charge, to any person or organization
+ obtaining a copy of the software and accompanying documentation covered by
+ this license (the "Software") to use, reproduce, display, distribute,
+ execute, and transmit the Software, and to prepare derivative works of the
+ Software, and to permit third-parties to whom the Software is furnished to
+ do so, all subject to the following:
+ .
+ The copyright notices in the Software and this entire statement, including
+ the above license grant, this restriction and the following disclaimer,
+ must be included in all copies of the Software, in whole or in part, and
+ all derivative works of the Software, unless such copies or derivative
+ works are solely in the form of machine-executable object code generated by
+ a source language processor.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+ SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+ FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE.
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person obtaining a
+ copy of this software and associated documentation files (the "Software"),
+ to deal in the Software without restriction, including without limitation
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ and/or sell copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following conditions:
+ .
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE.
+
+License: Tom-Wu
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+ .
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
+ WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
+ .
+ IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
+ INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
+ RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
+ THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ .
+ In addition, the following condition applies:
+ .
+ All redistributions must retain an intact copy of this copyright notice
+ and disclaimer.
diff --git a/package/debian/rules b/package/debian/rules
old mode 100644
new mode 100755
index 8f880b8192..dd6d1e66b9
--- a/package/debian/rules
+++ b/package/debian/rules
@@ -2,6 +2,12 @@
 
 export DH_VERBOSE = 1
 
+# The glibc the Nix toolchain builds against, and so the real floor for the
+# binaries. dpkg-shlibdeps would instead derive libc6 (>= 2.34) from the build
+# host's symbols file, where sysconf carries that minver, locking out distros
+# the binaries actually run on.
+LIBC_MIN = 2.31
+
 %:
 	dh $@
 
@@ -11,6 +17,8 @@ override_dh_auto_configure override_dh_auto_build override_dh_auto_test:
 override_dh_installsystemd:
 	dh_installsystemd --no-stop-on-upgrade xrpld.service
 
+# The tmpfiles snippet sets ownership to the xrpld user, so the sysusers snippet
+# has to be emitted first: run it early and make its own sequence slot a no-op.
 execute_before_dh_installtmpfiles:
 	dh_installsysusers
 
@@ -22,5 +30,23 @@ override_dh_install:
 	install -D -m 0644 xrpld.cfg        debian/xrpld/etc/xrpld/xrpld.cfg
 	install -D -m 0644 validators.txt   debian/xrpld/etc/xrpld/validators.txt
 
+override_dh_shlibdeps:
+	dh_shlibdeps
+	# Guards against the toolchain moving past LIBC_MIN and the packages then
+	# claiming a floor they do not meet.
+	for binary in xrpld validator-keys; do \
+		needed=$$(readelf --dyn-syms --wide $$binary \
+			| grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \
+		if [ -z "$$needed" ]; then \
+			echo "$$binary: no GLIBC_ symbol versions read, cannot check LIBC_MIN" >&2; \
+			exit 1; \
+		fi; \
+		if dpkg --compare-versions "$$needed" gt "$(LIBC_MIN)"; then \
+			echo "$$binary needs glibc $$needed, above LIBC_MIN $(LIBC_MIN)" >&2; \
+			exit 1; \
+		fi; \
+	done
+	sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/xrpld.substvars
+
 override_dh_dwz:
 	@:
diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs
index 77681ddc6e..97325dfcf5 100644
--- a/package/debian/xrpld.docs
+++ b/package/debian/xrpld.docs
@@ -1,2 +1,3 @@
 README.md
+LICENSE.md
 validator-keys-LICENSE
diff --git a/package/debian/xrpld.links b/package/debian/xrpld.links
index 10d34f5b8c..6dea4f28f3 100644
--- a/package/debian/xrpld.links
+++ b/package/debian/xrpld.links
@@ -1,2 +1,3 @@
-# Legacy compat symlinks (remove next major release)
+# Legacy compatibility for pre-FHS package layouts.
+# TODO: remove after rippled fully deprecated.
 usr/bin/xrpld          usr/local/bin/rippled
diff --git a/package/debian/xrpld.lintian-overrides b/package/debian/xrpld.lintian-overrides
new file mode 100644
index 0000000000..a0b3f583ed
--- /dev/null
+++ b/package/debian/xrpld.lintian-overrides
@@ -0,0 +1,6 @@
+# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS
+# layouts, so the Policy 9.1.2 tags it raises are expected.
+# TODO: remove alongside debian/xrpld.links after rippled fully deprecated.
+xrpld: dir-in-usr-local [usr/local/bin/]
+xrpld: file-in-usr-local [usr/local/bin/rippled]
+xrpld: file-in-unusual-dir [usr/local/bin/rippled]
diff --git a/package/docker/Dockerfile b/package/docker/Dockerfile
new file mode 100644
index 0000000000..adf372b6fa
--- /dev/null
+++ b/package/docker/Dockerfile
@@ -0,0 +1,10 @@
+ARG BASE_IMAGE=debian:trixie
+
+FROM ${BASE_IMAGE}
+
+# Bind-mounted rather than copied in, so the installer never lands in a layer.
+RUN --mount=type=bind,source=bin/install-packaging-tools.sh,target=/install-packaging-tools.sh \
+    /install-packaging-tools.sh
+
+# See ../README.md, "Publishing from other repositories".
+COPY package/docker/publish_pkg.py /usr/local/bin/publish_pkg.py
diff --git a/package/docker/publish_pkg.py b/package/docker/publish_pkg.py
new file mode 100755
index 0000000000..84a0448e7b
--- /dev/null
+++ b/package/docker/publish_pkg.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Publish built DEB and RPM packages to the XRPLF repositories on Nexus.
+
+Knows nothing about what it uploads beyond the channel, so it publishes whatever
+built the packages; see package/README.md, "Publishing from other repositories".
+
+RPMs are uploaded to the hosted repository, but yum clients install from the
+'rpm-' group repository in front of it, which serves signed metadata.
+
+NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
+credentials never reach the process list.
+"""
+
+import argparse
+import base64
+import os
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+SUFFIXES = (".deb", ".ddeb", ".rpm")
+
+# No progress for this long ends an attempt. urlopen applies the timeout per
+# socket operation, so a stalled transfer fails while a merely slow one carries
+# on -- the debuginfo package is large enough for that distinction to matter.
+STALL_TIMEOUT = 300
+
+ATTEMPTS = 4
+RETRY_DELAY = 5
+
+# 429 is Nexus asking to slow down, not a rejection, so it retries like a 5xx.
+RETRYABLE_STATUSES = (429,)
+
+
+def build_opener() -> urllib.request.OpenerDirector:
+    """An opener with no redirect handler, so a 3xx raises instead of being followed.
+
+    A redirected upload is silently downgraded to a GET, turning it into a no-op
+    that still answers 200.
+    """
+    opener = urllib.request.OpenerDirector()
+    opener.add_handler(urllib.request.HTTPHandler())
+    opener.add_handler(urllib.request.HTTPSHandler())
+    opener.add_handler(urllib.request.HTTPErrorProcessor())
+    opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+    return opener
+
+
+def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None:
+    """Send one package, retrying only what is worth retrying.
+
+    A 4xx other than 429 is a deterministic rejection, so it is reported at once
+    rather than re-sending the whole body three more times. Nexus explains what
+    it rejected in the response body, so that body is always surfaced.
+    """
+    opener = build_opener()
+
+    for attempt in range(1, ATTEMPTS + 1):
+        try:
+            with package.open("rb") as body:
+                request = urllib.request.Request(
+                    url,
+                    data=body,
+                    method=method,
+                    headers={**headers, "Content-Length": str(package.stat().st_size)},
+                )
+                opener.open(request, timeout=STALL_TIMEOUT)
+            return
+        except urllib.error.HTTPError as error:
+            detail = error.read().decode(errors="replace").strip()
+            reason = f"HTTP {error.code}: {detail}"
+            retryable = error.code >= 500 or error.code in RETRYABLE_STATUSES
+        except (urllib.error.URLError, OSError) as error:
+            reason = str(error)
+            retryable = True
+
+        assert (
+            retryable and attempt < ATTEMPTS
+        ), f"upload of {package.name} failed: {reason}"
+        print(f"    attempt {attempt} failed ({reason}), retrying")
+        time.sleep(RETRY_DELAY)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--channel",
+        required=True,
+        choices=("stable", "rc", "beta", "develop", "private"),
+        help="release channel, selecting the deb- and rpm--hosted repositories",
+    )
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--nexus-url",
+        default="https://packages.xrplf.org",
+        help="the Nexus instance to publish to (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="list the uploads without performing them",
+    )
+    args = parser.parse_args()
+    channel: str = args.channel
+    package_dir: Path = args.package_dir
+    nexus_url: str = args.nexus_url
+    dry_run: bool = args.dry_run
+
+    nexus = nexus_url.rstrip("/")
+    deb_repo = f"deb-{channel}"
+    rpm_repo = f"rpm-{channel}-hosted"
+
+    auth: dict[str, str] = {}
+    if not dry_run:
+        username = os.environ.get("NEXUS_USERNAME")
+        password = os.environ.get("NEXUS_PASSWORD")
+        assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
+        token = base64.b64encode(f"{username}:{password}".encode()).decode()
+        auth = {"Authorization": f"Basic {token}"}
+
+    # Deliberately not shared with sign_rpm.py: this script ships standalone in
+    # the packaging image for other repositories to run.
+    packages = sorted(
+        path
+        for path in package_dir.rglob("*")
+        if path.is_file() and path.suffix in SUFFIXES
+    )
+    # Uploading nothing would otherwise look like a successful publish.
+    assert packages, f"no packages found in {package_dir}"
+
+    print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
+    for package in packages:
+        if package.suffix == ".rpm":
+            # yum repositories are addressed by path, and the arch comes from
+            # the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
+            destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
+            url = f"{nexus}/repository/{destination}/{package.name}"
+            method, content_type = "PUT", "application/octet-stream"
+        else:
+            # A raw body with a multipart Content-Type, POSTed to the repository
+            # root, is the documented upload for a hosted apt repository:
+            # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+            destination = deb_repo
+            url = f"{nexus}/repository/{destination}/"
+            method, content_type = "POST", "multipart/form-data"
+
+        print(f"  {package.name} -> {destination}")
+        if not dry_run:
+            upload(url, method, {"Content-Type": content_type, **auth}, package)
+
+    verb = "would be published" if dry_run else "published"
+    print(f"{len(packages)} package(s) {verb}.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec
index 0e3ee2a968..5139cd54e5 100644
--- a/package/rpm/xrpld.spec
+++ b/package/rpm/xrpld.spec
@@ -17,17 +17,27 @@ URL:      https://github.com/XRPLF/rippled
 ExclusiveArch: x86_64 aarch64
 BuildRequires: systemd-rpm-macros
 
-%undefine _debugsource_packages
-%debug_package
-# Intentionally trade larger RPM artifacts for faster package validation.
-%global _binary_payload w.ufdio
-%global _find_debuginfo_dwz_opts %{nil}
-
-%build_mtime_policy clamp_to_source_date_epoch
-
+# These have to precede %%debug_package: it opens the debuginfo subpackage, and
+# any tag after it is silently dropped from the main package.
 %{?systemd_requires}
 %{?sysusers_requires_compat}
 
+%undefine _debugsource_packages
+%debug_package
+# Level 3 rather than the el9 default of 19: it shrinks the multi-gigabyte
+# 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}
+
+# Reproducibility: the first two take their value from the SOURCE_DATE_EPOCH
+# build_pkg.py exports. Without these the header records the wall clock and the
+# build container's hostname, so two builds of the same commit differ.
+%global clamp_mtime_to_source_date_epoch 1
+%global use_source_date_epoch_as_buildtime 1
+%global _buildhost xrplf.org
+
+
 %description
 xrpld is the reference implementation of the XRP Ledger protocol. It
 participates in the peer-to-peer XRP Ledger network, processes
@@ -51,7 +61,7 @@ install -Dm0644 %{_sourcedir}/validators.txt       %{buildroot}%{_sysconfdir}/%{
 install -Dm0644 %{_sourcedir}/xrpld.service        %{buildroot}%{_unitdir}/xrpld.service
 install -Dm0644 %{_sourcedir}/xrpld.sysusers       %{buildroot}%{_sysusersdir}/xrpld.conf
 install -Dm0644 %{_sourcedir}/xrpld.tmpfiles       %{buildroot}%{_tmpfilesdir}/xrpld.conf
-install -Dm0644 /dev/null %{buildroot}%{_presetdir}/50-xrpld.preset
+install -d %{buildroot}%{_presetdir}
 cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF'
 enable xrpld.service
 EOF
@@ -74,7 +84,7 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled
 %sysusers_create_package %{name} %{_sourcedir}/xrpld.sysusers
 
 %post
-systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
+%tmpfiles_create_package %{name} %{_sourcedir}/xrpld.tmpfiles
 %systemd_post xrpld.service
 
 %preun
@@ -84,11 +94,12 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
 %systemd_postun xrpld.service
 
 %files
+%attr(0755,root,root) %dir %{_docdir}/%{name}
 %license %{_docdir}/%{name}/LICENSE.md
 %license %{_docdir}/%{name}/validator-keys-LICENSE
 %doc %{_docdir}/%{name}/README.md
 
-%dir %{_sysconfdir}/%{name}
+%attr(0755,root,root) %dir %{_sysconfdir}/%{name}
 
 %{_bindir}/%{name}
 %{_bindir}/validator-keys
@@ -99,7 +110,7 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
 
 
 %{_unitdir}/xrpld.service
-%{_presetdir}/50-xrpld.preset
+%attr(0644,root,root) %{_presetdir}/50-xrpld.preset
 %{_sysusersdir}/xrpld.conf
 %{_tmpfilesdir}/xrpld.conf
 %ghost %dir /var/lib/xrpld
diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service
index f54e47aa14..27dd6a5a3a 100644
--- a/package/shared/xrpld.service
+++ b/package/shared/xrpld.service
@@ -17,6 +17,8 @@ ProtectHome=true
 PrivateTmp=true
 User=xrpld
 Group=xrpld
+# xrpld.tmpfiles creates these at install and boot; these recreate them on
+# every start, so a removed directory does not stop the service.
 StateDirectory=xrpld
 StateDirectoryMode=0750
 LogsDirectory=xrpld
@@ -24,9 +26,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.py b/package/sign_rpm.py
new file mode 100755
index 0000000000..07bda9f392
--- /dev/null
+++ b/package/sign_rpm.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Sign the RPMs built by build_pkg.py.
+
+Nexus signs the yum repository metadata (via the 'rpm-' group
+repository), but never the packages themselves, so they carry their own
+signature. Clients verify the packages with gpgcheck=1 and the metadata with
+repo_gpgcheck=1.
+
+The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+but apt does not verify them by default and trusts the repository metadata,
+which Nexus signs, instead.
+
+PKG_SIGNING_KEY is read from the environment, so the key never reaches the
+process list.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
+# so both are queried; checking only the first would reject a signed package.
+SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
+UNSIGNED = "(none)(none)"
+
+
+def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
+    """Run gpg against a throwaway keyring and return its stdout."""
+    return subprocess.run(
+        ["gpg", "--batch", "--quiet", *args],
+        input=stdin,
+        # stderr is left alone so a failing gpg explains itself.
+        stdout=subprocess.PIPE,
+        text=True,
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    ).stdout
+
+
+def import_key(gnupghome: Path, key: str) -> str:
+    """Import the armoured private key and return its fingerprint."""
+    gpg(gnupghome, "--import", stdin=key)
+
+    records = [
+        line.split(":")
+        for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
+    ]
+    # Exactly one, so the fingerprint picked below is not a guess.
+    secrets = [record for record in records if record[0] == "sec"]
+    assert (
+        len(secrets) == 1
+    ), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
+
+    # The first fingerprint belongs to the primary key; subkeys follow.
+    fingerprints = [record[9] for record in records if record[0] == "fpr"]
+    assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
+    return fingerprints[0]
+
+
+def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
+    """Attach a signature to every RPM in one rpmsign invocation."""
+    subprocess.run(
+        [
+            "rpmsign",
+            "--define",
+            f"_gpg_name {fingerprint}",
+            # Loopback pinentry: the key is unattended, so there is no tty to
+            # prompt on.
+            "--define",
+            "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
+            "--addsign",
+            *(str(rpm) for rpm in rpms),
+        ],
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    )
+
+
+def verify(rpms: list[Path]) -> None:
+    """Fail unless every RPM now carries a signature.
+
+    rpmsign can exit 0 having attached nothing, and an unsigned package is only
+    rejected later, on the installing machine.
+    """
+    for rpm in rpms:
+        signature = subprocess.run(
+            ["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
+            stdout=subprocess.PIPE,
+            text=True,
+            check=True,
+        ).stdout.strip()
+        assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help="searched recursively for *.rpm (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    package_dir: Path = args.package_dir
+
+    # Deliberately not shared with publish_pkg.py, which ships standalone in the
+    # packaging image.
+    rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
+    # Signing nothing would otherwise look like a successful signing.
+    assert rpms, f"no RPMs found in {package_dir}"
+
+    key = os.environ.get("PKG_SIGNING_KEY")
+    assert key, "PKG_SIGNING_KEY is required"
+
+    # The keyring holds an unencrypted private key, so it goes even if signing
+    # fails.
+    with tempfile.TemporaryDirectory() as tmp:
+        gnupghome = Path(tmp)
+        fingerprint = import_key(gnupghome, key)
+        print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
+        sign(gnupghome, rpms, fingerprint)
+        verify(rpms)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index a82b4734d8..dd5e1fe438 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,4 +1,4 @@
 [toolchain]
-channel = "1.95"
-components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"]
+channel = "1.97.1"
+components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"]
 profile = "minimal"
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/Number.cpp b/src/libxrpl/basics/Number.cpp
index 1f2c41809a..0917627073 100644
--- a/src/libxrpl/basics/Number.cpp
+++ b/src/libxrpl/basics/Number.cpp
@@ -260,6 +260,11 @@ public:
     unsigned
     pop() noexcept;
 
+    // if true, there are no recoverable digits in the guard, though there may be dropped digits
+    // (xbit_)
+    [[nodiscard]] bool
+    unrecoverable() const noexcept;
+
     // if true, there are no digits in the guard, including dropped digits (xbit_)
     [[nodiscard]] bool
     empty() const noexcept;
@@ -277,6 +282,17 @@ public:
     void
     doDropDigit(T& mantissa, int& exponent) noexcept;
 
+    /**
+     * Drop a digit from the mantissa, and increment the exponent, storing the dropped digit in
+     * this Guard.
+     *
+     * If a drop will not do anything meaningful (there are no recoverable digits in the guard, and
+     * the mantissa is 0), and if targetExponent > exponent, simply set exponent to targetExponent.
+     */
+    template 
+    void
+    doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept;
+
     // Modify the result to the correctly rounded value
     template 
     void
@@ -374,10 +390,16 @@ Number::Guard::pop() noexcept
     return d;
 }
 
+inline bool
+Number::Guard::unrecoverable() const noexcept
+{
+    return digits_ == 0;
+}
+
 inline bool
 Number::Guard::empty() const noexcept
 {
-    return digits_ == 0 && !xbit_;
+    return unrecoverable() && !xbit_;
 }
 
 template 
@@ -401,6 +423,25 @@ Number::Guard::doDropDigit(uint128_t& mantissa, int& exponent) noexce
     ++exponent;
 }
 
+template 
+void
+Number::Guard::doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept
+{
+    XRPL_ASSERT(
+        exponent < targetExponent, "xrpl::Number::Guard::doDropDigitWithTarget : something to do");
+    while (exponent < targetExponent)
+    {
+        if (mantissa == 0 && unrecoverable())
+        {
+            // No number of dropped digits is going to change anything except the exponent at this
+            // point, so just jump to the result
+            exponent = targetExponent;
+            return;
+        }
+        doDropDigit(mantissa, exponent);
+    }
+}
+
 template 
 void
 Number::Guard::pushOverflow(T mantissa)
@@ -928,6 +969,7 @@ Number::operator+=(Number const& y)
     //  to match, if necessary.
     auto const adjust = [&g, &upperLimit](
                             uint128_t& expandM, int& expandE, uint128_t& shrinkM, int& shrinkE) {
+        XRPL_ASSERT(shrinkE < expandE, "xrpl::Number::operator+= : exponents ordered correctly");
         // Adjust up and down until the exponents match
         if (g.cuspRoundingFix == MantissaRange::CuspRoundingFix::Enabled330)
         {
@@ -935,6 +977,8 @@ Number::operator+=(Number const& y)
             // 1. First, shrink the mantissa of shrinkM/shrinkE while shrinkM ends in 0.
             while (shrinkE < expandE && shrinkM % 10 == 0)
             {
+                // Don't use doDropDigitWithTarget here, because the loop will stop before the
+                // mantissa gets to 0.
                 g.doDropDigit(shrinkM, shrinkE);
             }
 
@@ -950,10 +994,11 @@ Number::operator+=(Number const& y)
 
         // 3. Finally, shrink the mantissa of shrinkM/shrinkE until the exponents match. Any removed
         // digits will be put into the Guard. This is the only step for non-Enabled330 modes.
-        while (shrinkE < expandE)
+        if (shrinkE < expandE)
         {
-            g.doDropDigit(shrinkM, shrinkE);
+            g.doDropDigitWithTarget(shrinkM, shrinkE, expandE);
         }
+        XRPL_ASSERT(shrinkE == expandE, "xrpl::Number::operator+= : exponents are equal");
     };
 
     // Shrink the mantissa and raise the exponent of the value with the lower exponent. Store any
@@ -996,7 +1041,7 @@ Number::operator+=(Number const& y)
             // round.
             XRPL_ASSERT(
                 xm > maxMantissa || g.empty(),
-                "xrpl::Number::operator+ : rounding state expected after add");
+                "xrpl::Number::operator+= : rounding state expected after add");
         }
         else
         {
@@ -1038,7 +1083,7 @@ Number::operator+=(Number const& y)
             }
             XRPL_ASSERT(
                 xm > maxMantissa || g.empty(),
-                "xrpl::Number::operator+ : rounding state expected after subtract");
+                "xrpl::Number::operator+= : rounding state expected after subtract");
         }
         else
         {
@@ -1330,9 +1375,10 @@ operator rep() const
             g.setNegative();
             drops = -drops;
         }
-        while (offset < 0)
+        if (offset < 0)
         {
-            g.doDropDigit(drops, offset);
+            g.doDropDigitWithTarget(drops, offset, 0);
+            XRPL_ASSERT(offset == 0, "xrpl::Number::operator rep() : exponents are equal");
         }
         for (; offset > 0; --offset)
         {
diff --git a/src/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 40ea69f427..67c7c8bdb7 100644
--- a/src/libxrpl/ledger/View.cpp
+++ b/src/libxrpl/ledger/View.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -45,20 +46,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 +73,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 +112,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 +138,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 +180,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,
@@ -391,7 +468,8 @@ canWithdraw(
     AccountID const& to,
     SLE::const_ref toSle,
     STAmount const& amount,
-    bool hasDestinationTag)
+    bool hasDestinationTag,
+    std::optional> const& credentialIDs)
 {
     if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag))
         return ret;
@@ -402,7 +480,28 @@ canWithdraw(
     if (toSle->isFlag(lsfDepositAuth))
     {
         if (!view.exists(keylet::depositPreauth(to, from)))
-            return tecNO_PERMISSION;
+        {
+            if (credentialIDs.has_value())
+            {
+                STVector256 const credIDs{*credentialIDs};
+
+                // Callers must have validated these in preclaim, so a missing
+                // credential here is an invariant violation.
+                for (auto const& h : credIDs)
+                {
+                    if (!view.exists(keylet::credential(h)))
+                        return tecINTERNAL;  // LCOV_EXCL_LINE
+                }
+
+                if (auto const ret = credentials::authorizedDepositPreauth(view, credIDs, to);
+                    !isTesSuccess(ret))
+                    return ret;
+            }
+            else
+            {
+                return tecNO_PERMISSION;
+            }
+        }
     }
 
     return withdrawToDestExceedsLimit(view, from, to, amount);
@@ -414,11 +513,12 @@ canWithdraw(
     AccountID const& from,
     AccountID const& to,
     STAmount const& amount,
-    bool hasDestinationTag)
+    bool hasDestinationTag,
+    std::optional> const& credentialIDs)
 {
     auto const toSle = view.read(keylet::account(to));
 
-    return canWithdraw(view, from, to, toSle, amount, hasDestinationTag);
+    return canWithdraw(view, from, to, toSle, amount, hasDestinationTag, credentialIDs);
 }
 
 [[nodiscard]] TER
@@ -427,7 +527,8 @@ canWithdraw(ReadView const& view, STTx const& tx)
     auto const from = tx[sfAccount];
     auto const to = tx[~sfDestination].value_or(from);
 
-    return canWithdraw(view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag));
+    return canWithdraw(
+        view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag), tx[~sfCredentialIDs]);
 }
 
 TER
@@ -442,12 +543,19 @@ doWithdraw(
 {
     auto const dstSle = ctx.view.read(keylet::account(dstAcct));
 
-    // Create trust line or MPToken for the receiving account
+    // Create a trust line or MPToken for a self-destination only when there
+    // is a payout to credit. Post-fixCleanup3_4_0, a zero-value withdraw
+    // (e.g. share redemption from a fully impaired vault) must not insert
+    // an empty holding: that records a one-sided zero delta and can also
+    // create+delete MPTokens in the same transaction.
     if (dstAcct == senderAcct)
     {
-        if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j);
-            !isTesSuccess(ter) && ter != tecDUPLICATE)
-            return ter;
+        if (amount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0))
+        {
+            if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j);
+                !isTesSuccess(ter) && ter != tecDUPLICATE)
+                return ter;
+        }
     }
     else
     {
diff --git a/src/libxrpl/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/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
index faca4ebfb6..819ebb04d1 100644
--- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
@@ -28,7 +28,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -515,8 +514,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey)
 }
 
 // Pseudo-account designator fields MUST be maintained by including the
-// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to
-// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated,
+// SField::kSmdPseudoAccount flag in the SField definition. (Don't forget to
+// "| SField::kSmdDefault"!) The fields do NOT need to be amendment-gated,
 // since a non-active amendment will not set any field, by definition.
 // Specific properties of a pseudo-account are NOT checked here, that's what
 // InvariantCheck is for.
@@ -547,18 +546,14 @@ getPseudoAccountFields()
 }
 
 [[nodiscard]] bool
-isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter)
+isPseudoAccount(SLE::const_pointer sleAcct)
 {
-    auto const& fields = getPseudoAccountFields();
-
     // Intentionally use defensive coding here because it's cheap and makes the
     // semantics of true return value clean.
     return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT &&
-        std::count_if(
-            fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool {
-                return sleAcct->isFieldPresent(*sf) &&
-                    (pseudoFieldFilter.empty() || pseudoFieldFilter.contains(sf));
-            }) > 0;
+        std::ranges::any_of(getPseudoAccountFields(), [&sleAcct](SField const* sf) {
+               return sleAcct->isFieldPresent(*sf);
+           });
 }
 
 std::expected
diff --git a/src/libxrpl/ledger/helpers/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..10c7e62c6c 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)
 {
@@ -131,6 +169,16 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
         roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
 }
 
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle)
+{
+    return hasExpired(
+        view,
+        loanSle->at(sfNextPaymentDueDate),
+        view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                              : ExpiryComparison::Inclusive);
+}
+
 namespace accrual {
 
 AccountingDeltas
@@ -476,7 +524,7 @@ loanLatePaymentInterest(
     // If the payment is not late by any amount of time, then there's no late
     // interest
     if (now <= nextPaymentDueDate)
-        return 0;
+        return kNumZero;
 
     // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
     auto const secondsOverdue = now - nextPaymentDueDate;
@@ -997,7 +1045,7 @@ doOverpayment(
 std::expected
 computeLatePayment(
     Asset const& asset,
-    ApplyView const& view,
+    ReadView const& view,
     SLE::const_ref loan,
     ExtendedPaymentComponents const& periodic,
     STAmount const& amount,
@@ -1008,8 +1056,11 @@ computeLatePayment(
     std::int32_t const loanScale = loan->at(sfLoanScale);
 
     // Check if the due date has passed. If not, reject the payment as
-    // being too soon
-    if (!hasExpired(view, nextDueDate))
+    // being too soon. Uses isPaymentLate() so this agrees with the
+    // regular payment path on whether the loan is actually late at the
+    // exact due date boundary (amendment-gated: Exclusive once
+    // fixCleanup3_4_0 is enabled, Inclusive otherwise).
+    if (!isPaymentLate(view, loan))
         return std::unexpected(tecTOO_SOON);
 
     // Calculate the penalty interest based on how long the payment is overdue.
@@ -1090,7 +1141,7 @@ computeLatePayment(
 std::expected
 computeFullPayment(
     Asset const& asset,
-    ApplyView& view,
+    ReadView const& view,
     SLE::const_ref loan,
     Number const& periodicRate,
     STAmount const& amount,
@@ -2232,7 +2283,7 @@ loanMakePayment(
 
     // -------------------------------------------------------------
     // A late payment not flagged as late overrides all other options.
-    if (paymentType != LoanPaymentType::Late && hasExpired(view, nextDueDateProxy))
+    if (paymentType != LoanPaymentType::Late && isPaymentLate(view, loan))
     {
         // If the payment is late, and the late flag was not set, it's not
         // valid
diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
index 6fe7328fa7..27dcd84675 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
@@ -132,6 +184,8 @@ addEmptyHolding(
     auto const mpt = ctx.view.peek(keylet::mptokenIssuance(mptID));
     if (!mpt)
         return tefINTERNAL;  // LCOV_EXCL_LINE
+    // Unlike IOU addEmptyHolding (post-fixCleanup3_4_0), a locked issuance is
+    // still rejected before the "MPToken already exists" short circuit.
     if (mpt->isFlag(lsfMPTLocked))
         return tefINTERNAL;  // LCOV_EXCL_LINE
     if (ctx.view.peek(keylet::mptoken(mptID, accountID)))
@@ -332,8 +386,7 @@ requireAuth(
     // They are implicitly authorized for any MPT they hold, including vault shares whose
     // underlying asset would otherwise require auth.
     auto const isPseudoAccountExempt = [&] {
-        return (featureSAVEnabled || featureMPTV2Enabled) &&
-            isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID, &sfAMMID});
+        return (featureSAVEnabled || featureMPTV2Enabled) && isPseudoAccount(view, account);
     };
 
     auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
@@ -952,6 +1005,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 +1015,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 +1031,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 d97b08981b..5d8526444c 100644
--- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
@@ -15,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -776,6 +777,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)
@@ -854,7 +862,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/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
index 868c9fb26d..cc02b56305 100644
--- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
@@ -584,9 +584,15 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account,
     {
         if (trustLine)
         {
-            return trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth)
-                ? tesSUCCESS
-                : TER{tecNO_AUTH};
+            if (trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth))
+                return tesSUCCESS;
+
+            // A pseudo-account cannot submit transactions and only stores assets for the object
+            // that owns it, so it is implicitly authorized.
+            if (view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(view, account))
+                return tesSUCCESS;
+
+            return TER{tecNO_AUTH};
         }
         return TER{tecNO_LINE};
     }
@@ -646,21 +652,32 @@ addEmptyHolding(
 
     auto const& issuerId = issue.getIssuer();
     auto const& currency = issue.currency;
-    if (isGlobalFrozen(ctx.view, issuerId))
-        return tecFROZEN;  // LCOV_EXCL_LINE
-
     auto const& srcId = issuerId;
     auto const& dstId = accountID;
     auto const high = srcId > dstId;
     auto const index = keylet::trustLine(srcId, dstId, currency);
+    // Post-fixCleanup3_4_0: an existing line is a no-op. Issuer freeze and
+    // DefaultRipple only matter when this function has to create a line.
+    bool const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0);
+    if (fix340Enabled && ctx.view.exists(index))
+        return tecDUPLICATE;
+
+    if (isGlobalFrozen(ctx.view, issuerId))
+        return tecFROZEN;  // LCOV_EXCL_LINE
+
     auto const sleSrc = ctx.view.peek(keylet::account(srcId));
     auto const sleDst = ctx.view.peek(keylet::account(dstId));
     if (!sleDst || !sleSrc)
         return tefINTERNAL;  // LCOV_EXCL_LINE
+    // Create path: DefaultRipple is still required. terNO_RIPPLE is
+    // intentional so VaultWithdraw / CoverWithdraw fail in preclaim via
+    // canAddHolding (retryable, no fee) rather than claiming a tec* fee
+    // in doApply. Transactor::operator() will not apply and will not
+    // convert it to tefINTERNAL.
     if (!sleSrc->isFlag(lsfDefaultRipple))
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        return fix340Enabled ? TER{terNO_RIPPLE} : tecINTERNAL;
     // If the line already exists, don't create it again.
-    if (ctx.view.read(index))
+    if (!fix340Enabled && ctx.view.exists(index))
         return tecDUPLICATE;
 
     // A reserve sponsor only covers tx.Account's own objects.
diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
index 79e10cdf79..2c2c943a9f 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);
     }
@@ -526,13 +535,19 @@ accountFunds(
 }
 
 Rate
-transferRate(ReadView const& view, STAmount const& amount)
+transferRate(ReadView const& view, Asset const& asset)
 {
-    return amount.asset().visit(
+    return asset.visit(
         [&](Issue const& issue) { return transferRate(view, issue.getIssuer()); },
         [&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); });
 }
 
+Rate
+transferRate(ReadView const& view, STAmount const& amount)
+{
+    return transferRate(view, amount.asset());
+}
+
 //------------------------------------------------------------------------------
 //
 // Holding operations
@@ -568,6 +583,32 @@ canAddHolding(ReadView const& view, Asset const& asset)
         asset.value());
 }
 
+[[nodiscard]] bool
+holdingExists(ReadView const& view, AccountID const& account, Issue const& issue)
+{
+    if (issue.native() || account == issue.getIssuer())
+        return true;
+    return view.exists(keylet::trustLine(account, issue));
+}
+
+[[nodiscard]] bool
+holdingExists(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue)
+{
+    if (account == mptIssue.getIssuer())
+        return true;
+    return view.exists(keylet::mptoken(mptIssue.getMptID(), account));
+}
+
+[[nodiscard]] bool
+holdingExists(ReadView const& view, AccountID const& account, Asset const& asset)
+{
+    return std::visit(
+        [&](TIss const& issue) -> bool {
+            return holdingExists(view, account, issue);
+        },
+        asset.value());
+}
+
 TER
 addEmptyHolding(
     ApplyViewContext ctx,
diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
index 78f64d2077..941b94143d 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -1,8 +1,11 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -11,8 +14,11 @@
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
+#include 
 
 #include 
+#include 
 #include 
 #include 
 
@@ -65,6 +71,82 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
     return assets;
 }
 
+[[nodiscard]] std::expected
+clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta)
+{
+    XRPL_ASSERT(
+        delta.asset() == vault->at(sfAsset),
+        "xrpl::clampToAssetsTotalScale : delta and vault asset match");
+
+    Asset const asset = vault->at(sfAsset);
+
+    STAmount magnitude = delta.negative() ? -delta : delta;
+    if (asset.integral())
+    {
+        return magnitude;
+    }
+    Number const assetsTotal = vault->at(sfAssetsTotal);
+
+    // Calculate the scale after applying the delta using ToNearest rounding.
+    // This aligns the delta with scale checks used by vault invariants.
+    int const postScale = [&] {
+        NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
+        return scale(assetsTotal + delta, asset);
+    }();
+
+    STAmount actualDelta;
+    if (delta.negative())
+    {
+        // For withdrawals (debits), floor the magnitude to the target scale
+        // to ensure exact grid alignment without paying out extra assets.
+        actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
+    }
+    else
+    {
+        // For deposits (credits), derive actualDelta from the floored posterior total.
+        // This prevents grid alignment issues from crediting the vault more than deposited.
+        //
+        // Sum using Downward rounding so intermediate precision doesn't round up
+        // and exceed the original requested amount.
+        Number const posterior = [&] {
+            NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
+            return assetsTotal + magnitude;
+        }();
+
+        Number const roundedPosterior =
+            roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
+        actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
+    }
+
+    XRPL_ASSERT(
+        abs(actualDelta) <= abs(delta),
+        "xrpl::clampToAssetsTotalScale : actual delta smaller or equal to calculated delta");
+
+    // Reject changes below scale precision (1 ULP) to prevent share balance changes
+    // without corresponding asset movements.
+    if (actualDelta <= beast::kZero)
+        return std::unexpected(tecPRECISION_LOSS);
+
+    return actualDelta;
+}
+
+[[nodiscard]] Number
+assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
+{
+    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 +162,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 +188,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 +235,96 @@ 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;
+}
+
+[[nodiscard]] TER
+checkVaultDomain(
+    ReadView const& view,
+    SLE::const_ref issuance,
+    AccountID const& subject,
+    SuppressExpired suppressExpired)
+{
+    XRPL_ASSERT(
+        issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::checkVaultDomain : valid issuance SLE");
+
+    auto const maybeDomainID = issuance->at(~sfDomainID);
+    if (!maybeDomainID)
+        return tecNO_AUTH;
+
+    auto const err = credentials::validDomain(view, *maybeDomainID, subject);
+    if (err == tecEXPIRED && suppressExpired == SuppressExpired::Yes)
+        return tesSUCCESS;
+
+    return err;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/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/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp
index ff4e5aa0ee..bf67defa3b 100644
--- a/src/libxrpl/protocol/BuildInfo.cpp
+++ b/src/libxrpl/protocol/BuildInfo.cpp
@@ -23,7 +23,7 @@ namespace {
 //------------------------------------------------------------------------------
 // clang-format off
 // NOLINTNEXTLINE(readability-identifier-naming)
-char const* const versionString = "3.4.0-b0"
+char const* const versionString = "3.4.0-rc1"
     // clang-format on
     ;
 
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/Emitable.cpp b/src/libxrpl/protocol/Emitable.cpp
index 06bf6feb7f..3a926183b3 100644
--- a/src/libxrpl/protocol/Emitable.cpp
+++ b/src/libxrpl/protocol/Emitable.cpp
@@ -1,7 +1,9 @@
-#include 
 #include 
+
+#include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -10,13 +12,18 @@ Emitable::Emitable()
 {
     emitableTx_ = {
 #pragma push_macro("TRANSACTION")
+#pragma push_macro("UNWRAP")
 #undef TRANSACTION
+#undef UNWRAP
 
-#define TRANSACTION(tag, value, name, delegatable, amendment, permissions, emitable, fields) \
-    {value, emitable},
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, fields) \
+    {value, (TxSettings UNWRAP settings).emittance},
 #include 
 
 #undef TRANSACTION
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 #pragma pop_macro("TRANSACTION")
     };
 
@@ -146,7 +153,7 @@ Emitable::isEmitable(std::uint32_t const& emitableValue) const
     //         return false;
     // }
 
-    if (it != emitableTx_.end() && it->second == Emittance::notEmitable)
+    if (it != emitableTx_.end() && it->second == Emittance::NotEmitable)
         return false;
 
     return true;
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/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp
index f8c8854111..81a1c58977 100644
--- a/src/libxrpl/protocol/Indexes.cpp
+++ b/src/libxrpl/protocol/Indexes.cpp
@@ -126,6 +126,10 @@ getBookBase(Book const& book)
 {
     XRPL_ASSERT(isConsistent(book), "xrpl::getBookBase : input is consistent");
 
+    constexpr std::uint8_t kIssueToMPTTag = 0x01;
+    constexpr std::uint8_t kMPTToIssueTag = 0x02;
+    constexpr std::uint8_t kMPTToMPTTag = 0x03;
+
     auto getIndexHash = [&book](Args... args) {
         if (book.domain)
             return indexHash(std::forward(args)..., *book.domain);
@@ -139,19 +143,36 @@ getBookBase(Book const& book)
                 return getIndexHash(
                     LedgerNameSpace::BookDir, in.currency, out.currency, in.account, out.account);
             }
+            // The three MPT-involving branches are new under MPTokensV2 and
+            // each gets a 1-byte discriminator to prevent preimage collisions
+            // between branches: the (Issue,MPT) and (MPT,Issue) preimages
+            // are both 64 bytes of raw concatenation, so without a
+            // per-branch tag chosen Currency / MPTID / AccountID values can
+            // align byte-for-byte and produce the same BookDir keylet for
+            // two distinct markets. (Issue,Issue) is left untagged to
+            // preserve existing mainnet order-book keylets.
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.currency, out.getMptID(), in.account);
+                    LedgerNameSpace::BookDir,
+                    kIssueToMPTTag,
+                    in.currency,
+                    out.getMptID(),
+                    in.account);
             }
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.getMptID(), out.currency, out.account);
+                    LedgerNameSpace::BookDir,
+                    kMPTToIssueTag,
+                    in.getMptID(),
+                    out.currency,
+                    out.account);
             }
             else
             {
-                return getIndexHash(LedgerNameSpace::BookDir, in.getMptID(), out.getMptID());
+                return getIndexHash(
+                    LedgerNameSpace::BookDir, kMPTToMPTTag, in.getMptID(), out.getMptID());
             }
         },
         book.in.value(),
diff --git a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp
deleted file mode 100644
index fd44ae1f33..0000000000
--- a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp
+++ /dev/null
@@ -1,24 +0,0 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-namespace xrpl::rpc {
-
-void
-insertNFTSyntheticInJson(
-    json::Value& response,
-    std::shared_ptr const& transaction,
-    TxMeta const& transactionMeta)
-{
-    insertNFTokenID(response[jss::meta], transaction, transactionMeta);
-    insertNFTokenOfferID(response[jss::meta], transaction, transactionMeta);
-}
-
-}  // namespace xrpl::rpc
diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp
index 2f3e25f823..a5adb294e9 100644
--- a/src/libxrpl/protocol/Permissions.cpp
+++ b/src/libxrpl/protocol/Permissions.cpp
@@ -10,6 +10,7 @@
 #include 
 #include   // IWYU pragma: keep
 #include 
+#include 
 
 #include 
 #include 
@@ -40,16 +41,24 @@ Permission::GranularPermissionEntry::GranularPermissionEntry(
 Permission::Permission()
 {
     {
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, delegable, amendment, ...) \
-    txDelegationMap_[static_cast(value)] = {amendment, delegable};
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                               \
+    {                                                                              \
+        TxSettings const s = UNWRAP settings;                                      \
+        txDelegationMap_[static_cast(value)] = {s.amendment, s.delegable}; \
+    }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
     }
 
     granularPermissionsByName_ = {
@@ -242,7 +251,7 @@ Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const
 
     // Tx-level permissions require the transaction type itself to be delegable, and
     // the corresponding amendment enabled.
-    return txIt != txDelegationMap_.end() && txIt->second.delegable != NotDelegable &&
+    return txIt != txDelegationMap_.end() && txIt->second.delegable != Delegation::NotDelegable &&
         amendmentEnabled(txIt->second);
 }
 
diff --git a/src/libxrpl/protocol/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..3db6a3dc6c 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 
@@ -168,10 +168,10 @@ STTx::getMentionedAccounts() const
 }
 
 static Blob
-getSigningData(STTx const& that)
+getSigningData(STTx const& that, HashPrefix prefix)
 {
     Serializer s;
-    s.add32(HashPrefix::TxSign);
+    s.add32(prefix);
     that.addWithoutSigningFields(s);
     return s.getData();
 }
@@ -212,30 +212,42 @@ STTx::getSeqProxy() const
     return SeqProxy::rawTicket(*ticketSeq);
 }
 
+void
+STTx::sign(PublicKey const& publicKey, SecretKey const& secretKey)
+{
+    // The account's own signature always covers the plain transaction prefix;
+    // see signingPrefix for the role signatures that do not.
+    auto const data = getSigningData(*this, HashPrefix::TxSign);
+
+    setFieldVL(sfTxnSignature, xrpl::sign(publicKey, secretKey, makeSlice(data)));
+    tid_ = getHash(HashPrefix::TransactionId);
+}
+
 void
 STTx::sign(
     PublicKey const& publicKey,
     SecretKey const& secretKey,
-    std::optional> signatureTarget)
+    SignatureRole role,
+    Rules const& rules)
 {
-    auto const data = getSigningData(*this);
+    auto const data = getSigningData(*this, signingPrefix(role, false, rules));
 
     auto const sig = xrpl::sign(publicKey, secretKey, makeSlice(data));
 
-    if (signatureTarget)
+    if (auto const target = signatureField(role))
     {
-        auto& target = peekFieldObject(*signatureTarget);
-        target.setFieldVL(sfTxnSignature, sig);
+        peekFieldObject(*target).setFieldVL(sfTxnSignature, sig);
     }
     else
     {
         setFieldVL(sfTxnSignature, sig);
     }
+
     tid_ = getHash(HashPrefix::TransactionId);
 }
 
 std::expected
-STTx::checkSign(Rules const& rules, STObject const& sigObject) const
+STTx::checkSign(Rules const& rules, STObject const& sigObject, SignatureRole role) const
 {
     try
     {
@@ -244,8 +256,10 @@ STTx::checkSign(Rules const& rules, STObject const& sigObject) const
         // multi-signing.  Otherwise we're single-signing.
 
         Blob const& signingPubKey = sigObject.getFieldVL(sfSigningPubKey);
-        return signingPubKey.empty() ? checkMultiSign(rules, sigObject)
-                                     : checkSingleSign(sigObject);
+        bool const multiSigning = signingPubKey.empty();
+        auto const prefix = signingPrefix(role, multiSigning, rules);
+        return multiSigning ? checkMultiSign(sigObject, prefix)
+                            : checkSingleSign(sigObject, prefix);
     }
     catch (...)
     {
@@ -256,20 +270,20 @@ STTx::checkSign(Rules const& rules, STObject const& sigObject) const
 std::expected
 STTx::checkSign(Rules const& rules) const
 {
-    if (auto const ret = checkSign(rules, *this); !ret)
+    if (auto const ret = checkSign(rules, *this, SignatureRole::Transaction); !ret)
         return ret;
 
     if (isFieldPresent(sfCounterpartySignature))
     {
         auto const counterSig = getFieldObject(sfCounterpartySignature);
-        if (auto const ret = checkSign(rules, counterSig); !ret)
+        if (auto const ret = checkSign(rules, counterSig, SignatureRole::Counterparty); !ret)
             return std::unexpected("Counterparty: " + ret.error());
     }
 
     if (isFieldPresent(sfSponsorSignature))
     {
         auto const sponsorSignatureObj = getFieldObject(sfSponsorSignature);
-        if (auto const ret = checkSign(rules, sponsorSignatureObj); !ret)
+        if (auto const ret = checkSign(rules, sponsorSignatureObj, SignatureRole::Sponsor); !ret)
             return std::unexpected("Sponsor: " + ret.error());
     }
 
@@ -277,14 +291,14 @@ STTx::checkSign(Rules const& rules) const
     // of signature checking.
     if (isFieldPresent(sfBatchSigners))
     {
-        if (auto const ret = checkBatchSign(rules); !ret)
+        if (auto const ret = checkBatchSign(); !ret)
             return ret;
     }
     return {};
 }
 
 std::expected
-STTx::checkBatchSign(Rules const& rules) const
+STTx::checkBatchSign() const
 {
     try
     {
@@ -318,7 +332,7 @@ STTx::checkBatchSign(Rules const& rules) const
         for (auto const& signer : signers)
         {
             Blob const& signingPubKey = signer.getFieldVL(sfSigningPubKey);
-            auto const result = signingPubKey.empty() ? checkBatchMultiSign(signer, rules, txIds)
+            auto const result = signingPubKey.empty() ? checkBatchMultiSign(signer, txIds)
                                                       : checkBatchSingleSign(signer, txIds);
 
             if (!result)
@@ -399,16 +413,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
@@ -442,9 +461,9 @@ singleSignHelper(STObject const& sigObject, Slice const& data)
 }
 
 std::expected
-STTx::checkSingleSign(STObject const& sigObject) const
+STTx::checkSingleSign(STObject const& sigObject, HashPrefix prefix) const
 {
-    auto const data = getSigningData(*this);
+    auto const data = getSigningData(*this, prefix);
     return singleSignHelper(sigObject, makeSlice(data));
 }
 
@@ -462,8 +481,7 @@ std::expected
 multiSignHelper(
     STObject const& sigObject,
     std::optional txnAccountID,
-    std::function makeMsg,
-    Rules const& rules)
+    std::function makeMsg)
 {
     // Make sure the MultiSigners are present.  Otherwise they are not
     // attempting multi-signing and we just have a bad SigningPubKey.
@@ -536,10 +554,7 @@ multiSignHelper(
 }
 
 std::expected
-STTx::checkBatchMultiSign(
-    STObject const& batchSigner,
-    Rules const& rules,
-    std::vector const& txIds) const
+STTx::checkBatchMultiSign(STObject const& batchSigner, std::vector const& txIds) const
 {
     XRPL_ASSERT(getTxnType() == ttBATCH, "STTx::checkBatchMultiSign : batch transaction");
     // We can ease the computational load inside the loop a bit by
@@ -550,18 +565,15 @@ STTx::checkBatchMultiSign(
     serializeBatch(dataStart, getAccountID(sfAccount), getSeqProxy().value(), getFlags(), txIds);
     dataStart.addBitString(batchSignerAccount);
     return multiSignHelper(
-        batchSigner,
-        batchSignerAccount,
-        [&dataStart](AccountID const& accountID) -> Serializer {
+        batchSigner, batchSignerAccount, [&dataStart](AccountID const& accountID) -> Serializer {
             Serializer s = dataStart;
             finishMultiSigningData(accountID, s);
             return s;
-        },
-        rules);
+        });
 }
 
 std::expected
-STTx::checkMultiSign(Rules const& rules, STObject const& sigObject) const
+STTx::checkMultiSign(STObject const& sigObject, HashPrefix prefix) const
 {
     // Used inside the loop in multiSignHelper to enforce that
     // the account owner may not multisign for themselves.
@@ -573,16 +585,13 @@ STTx::checkMultiSign(Rules const& rules, STObject const& sigObject) const
     // We can ease the computational load inside the loop a bit by
     // pre-constructing part of the data that we hash.  Fill a Serializer
     // with the stuff that stays constant from signature to signature.
-    Serializer dataStart = startMultiSigningData(*this);
+    Serializer dataStart = startMultiSigningData(*this, prefix);
     return multiSignHelper(
-        sigObject,
-        txnAccountID,
-        [&dataStart](AccountID const& accountID) -> Serializer {
+        sigObject, txnAccountID, [&dataStart](AccountID const& accountID) -> Serializer {
             Serializer s = dataStart;
             finishMultiSigningData(accountID, s);
             return s;
-        },
-        rules);
+        });
 }
 
 void
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/protocol/Sign.cpp b/src/libxrpl/protocol/Sign.cpp
index 9e7ef7f999..ce3dabde33 100644
--- a/src/libxrpl/protocol/Sign.cpp
+++ b/src/libxrpl/protocol/Sign.cpp
@@ -1,17 +1,77 @@
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+
 namespace xrpl {
 
+SField const*
+signatureField(SignatureRole role)
+{
+    switch (role)
+    {
+        case SignatureRole::Transaction:
+            return nullptr;
+        case SignatureRole::Counterparty:
+            return &sfCounterpartySignature;
+        case SignatureRole::Sponsor:
+            return &sfSponsorSignature;
+    }
+    UNREACHABLE("xrpl::signatureField : unknown SignatureRole");
+    return nullptr;
+}
+
+std::optional
+signatureRole(SField const& sigField)
+{
+    if (sigField == sfCounterpartySignature)
+        return SignatureRole::Counterparty;
+    if (sigField == sfSponsorSignature)
+        return SignatureRole::Sponsor;
+    return std::nullopt;
+}
+
+// Signature validity depends on fixCleanup3_4_0: a role signature covers
+// different bytes before and after the amendment activates. checkValidity
+// caches its verdict per transaction ID, so it keeps two separate cache slots
+// for role-signature transactions (kSfSiggoodOldPrefix / kSfSigbadOldPrefix in
+// tx/apply.cpp) to keep a pre-fix verdict from being reused in the post-fix
+// era, and vice versa. See the block comment in tx/apply.cpp for the details
+// and the reason both directions matter.
+HashPrefix
+signingPrefix(SignatureRole role, bool multiSigning, Rules const& rules)
+{
+    // Before fixCleanup3_4_0 every signature on a transaction covered the same
+    // bytes, so a signature could be moved from one role to another.
+    if (!rules.enabled(fixCleanup3_4_0))
+        return multiSigning ? HashPrefix::TxMultiSign : HashPrefix::TxSign;
+
+    switch (role)
+    {
+        case SignatureRole::Transaction:
+            return multiSigning ? HashPrefix::TxMultiSign : HashPrefix::TxSign;
+        case SignatureRole::Counterparty:
+            return multiSigning ? HashPrefix::CounterpartyTxMultiSign
+                                : HashPrefix::CounterpartyTxSign;
+        case SignatureRole::Sponsor:
+            return multiSigning ? HashPrefix::SponsorTxMultiSign : HashPrefix::SponsorTxSign;
+    }
+    UNREACHABLE("xrpl::signingPrefix : unknown SignatureRole");
+    return multiSigning ? HashPrefix::TxMultiSign : HashPrefix::TxSign;
+}
+
 void
 sign(
     STObject& st,
@@ -70,18 +130,18 @@ verify(STObject const& st, HashPrefix const& prefix, PublicKey const& pk, SF_VL
 // So, if we support multiple levels of signing, then we'll need to
 // incorporate the "signing for" accounts into the signing data as well.
 Serializer
-buildMultiSigningData(STObject const& obj, AccountID const& signingID)
+buildMultiSigningData(STObject const& obj, AccountID const& signingID, HashPrefix prefix)
 {
-    Serializer s{startMultiSigningData(obj)};
+    Serializer s{startMultiSigningData(obj, prefix)};
     finishMultiSigningData(signingID, s);
     return s;
 }
 
 Serializer
-startMultiSigningData(STObject const& obj)
+startMultiSigningData(STObject const& obj, HashPrefix prefix)
 {
     Serializer s;
-    s.add32(HashPrefix::TxMultiSign);
+    s.add32(prefix);
     obj.addWithoutSigningFields(s);
     return s;
 }
diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp
index b635fd5dc1..04dd4d009a 100644
--- a/src/libxrpl/protocol/TER.cpp
+++ b/src/libxrpl/protocol/TER.cpp
@@ -137,7 +137,7 @@ transResults()
         MAKE_ERROR(tefNO_DST_PARTIAL,              "Partial payment to create account not allowed."),
         MAKE_ERROR(tefBAD_PATH_COUNT,              "Malformed: Too many paths."),
         MAKE_ERROR(tefNO_BYTECODE,                 "There is no WASM code to run, but a WASM-specific field was included."),
-        MAKE_ERROR(tefBYTECODE_NOT_INCLUDED,       "WASM code requires a field to be included that was not included."),
+        MAKE_ERROR(tefBYTECODE_NOT_INCLUDED,       "WASM code requires a field that was not included."),
 
         MAKE_ERROR(telLOCAL_ERROR,            "Local failure."),
         MAKE_ERROR(telBAD_DOMAIN,             "Domain too long."),
@@ -209,8 +209,7 @@ transResults()
         MAKE_ERROR(temBAD_TRANSFER_FEE,          "Malformed: Transfer fee is outside valid range."),
         MAKE_ERROR(temINVALID_INNER_BATCH,       "Malformed: Invalid inner batch transaction."),
         MAKE_ERROR(temBAD_CIPHERTEXT,            "Malformed: Invalid ciphertext."),
-        MAKE_ERROR(temBAD_WASM,                  "Malformed: Provided WASM code is invalid."),
-        MAKE_ERROR(temINVALID_BYTECODE,          "Malformed: Provided WASM code is invalid."),
+        MAKE_ERROR(temINVALID_BYTECODE,          "Malformed: Provided byte code is invalid."),
         MAKE_ERROR(temTEMP_DISABLED,             "The transaction requires logic that is currently temporarily disabled."),
 
         MAKE_ERROR(terRETRY,                  "Retry transaction."),
diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp
index 555aa38ed6..c393c606fe 100644
--- a/src/libxrpl/protocol/TxFormats.cpp
+++ b/src/libxrpl/protocol/TxFormats.cpp
@@ -45,7 +45,7 @@ TxFormats::TxFormats()
 #undef TRANSACTION
 
 #define UNWRAP(...) __VA_ARGS__
-#define TRANSACTION(tag, value, name, delegatable, amendment, privileges, emitable, fields) \
+#define TRANSACTION(tag, value, name, settings, fields) \
     add(jss::name, tag, UNWRAP fields, getCommonFields());
 
 #include 
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..42b946b921 100644
--- a/src/libxrpl/shamap/SHAMapNodeID.cpp
+++ b/src/libxrpl/shamap/SHAMapNodeID.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -16,7 +17,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 +26,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;
@@ -40,14 +41,43 @@ depthMask(unsigned int depth)
     return kMasks.entry[depth];
 }
 
+// The prefix of `key` at `depth`: the leading nibbles naming the subtree a node at that depth
+// identifies, with the remainder of the key masked off.
+static uint256
+maskedToDepth(uint256 const& key, unsigned int depth)
+{
+    return key & depthMask(depth);
+}
+
+// Whether `id` at `depth` is what `key` looks like once masked down to that depth, i.e.
+// whether an ID with this depth and id names a subtree that `key` falls under.
+static bool
+isPrefixOfAtDepth(uint256 const& id, unsigned int depth, uint256 const& key)
+{
+    return maskedToDepth(key, depth) == id;
+}
+
 // canonicalize the hash to a node ID for this depth
 SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash), depth_(depth)
 {
+    // Every SHAMapNodeID's depth is stored here, so this is the one place that can stop an
+    // out-of-range one from being kept: a depth past kLeafDepth would go on to index depthMask
+    // out of bounds, and getRawString would narrow it to a byte, silently renaming the node.
+    // Clamp rather than throw, since node IDs are built from peer-supplied depths on the ledger
+    // data path, where no caller catches an exception before it reaches a thread boundary.
+    if (depth_ > SHAMap::kLeafDepth)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::SHAMapNodeID::SHAMapNodeID : depth within tree");
+        depth_ = SHAMap::kLeafDepth;
+        id_ = maskedToDepth(id_, depth_);
+        // LCOV_EXCL_STOP
+    }
+
+    // Reads the clamped member rather than the depth argument, so it cannot index depthMask past
+    // its last entry even once the clamp above has reported the bad input and carried on.
     XRPL_ASSERT(
-        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 +90,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 +109,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 isPrefixOfAtDepth(id_, depth_, key);
+}
+
 [[nodiscard]] std::optional
 deserializeSHAMapNodeID(void const* data, std::size_t size)
 {
@@ -97,9 +133,9 @@ deserializeSHAMapNodeID(void const* data, std::size_t size)
         unsigned int const depth = *(static_cast(data) + 32);
         if (depth <= SHAMap::kLeafDepth)
         {
-            auto const id = uint256::fromVoid(data);
-
-            if (id == (id & depthMask(depth)))
+            // Reject a serialized ID carrying bits below its own depth. Checked before
+            // constructing, since the constructor asserts that same property.
+            if (auto const id = uint256::fromVoid(data); isPrefixOfAtDepth(id, depth, id))
                 ret.emplace(depth, id);
         }
     }
@@ -110,7 +146,11 @@ deserializeSHAMapNodeID(void const* data, std::size_t size)
 [[nodiscard]] unsigned int
 selectBranch(SHAMapNodeID const& id, uint256 const& hash)
 {
-    auto const depth = id.getDepth();
+    XRPL_ASSERT(id.getDepth() < SHAMap::kLeafDepth, "xrpl::selectBranch : depth below leaf depth");
+
+    // A depth-64 ID has no nibble left to select. Callers must not ask, but clamp anyway to keep
+    // the read below the end of the 32-byte key.
+    auto const depth = std::min(id.getDepth(), SHAMap::kLeafDepth - 1u);
     auto branch = static_cast(*(hash.begin() + (depth / 2)));
 
     if ((depth & 1) != 0u)
@@ -127,11 +167,20 @@ 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");
-    return SHAMapNodeID(depth, key & depthMask(depth));
+    // The mask is chosen here, before the constructor runs, so the clamp there cannot cover this
+    // call: an out-of-range depth would index depthMask's table while still evaluating this
+    // argument. A public factory has to hold its own bound.
+    if (depth > SHAMap::kLeafDepth)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::SHAMapNodeID::createID : depth within tree");
+        depth = SHAMap::kLeafDepth;
+        // LCOV_EXCL_STOP
+    }
+
+    return SHAMapNodeID(depth, maskedToDepth(key, depth));
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp
index cbed6885c9..4319d0bcd4 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));
@@ -143,8 +143,22 @@ SHAMap::visitDifferences(
         if (!function(*node))
             return;
 
+        // Nibbles run out at kLeafDepth, so only a leaf belongs there. A well-formed map never
+        // holds an inner node at that depth: addKnownNode marks the map invalid rather than hooking
+        // one in, and fetch-pack data is hash-verified against a validated root, so reaching this
+        // means a defect or a corrupt store, not something a peer can provoke. Report the node
+        // anyway - the wire form carries no depth, and the recipient hooks blobs in by hash - but
+        // skip the children rather than letting getChildNodeID throw on them.
+        if (nodeID.getDepth() >= kLeafDepth)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::SHAMap::visitDifferences : inner node at leaf depth");
+            continue;
+            // LCOV_EXCL_STOP
+        }
+
         // 2) push non-matching child inner nodes
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (!node->isEmptyBranch(i))
             {
@@ -176,13 +190,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 +276,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 +437,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 +458,7 @@ SHAMap::getNodeFat(
         return false;
     }
 
-    std::stack> stack;
+    std::stack> stack;
     stack.emplace(node, nodeID, depth);
 
     Serializer s(8192);
@@ -464,12 +478,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 +569,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 +588,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 +698,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 +737,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 +763,18 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const
 
     do
     {
-        int const branch = selectBranch(nodeID, tag);
+        // Same kLeafDepth hazard as in visitDifferences above. That guard bounds the caller's own
+        // traversal, not the map queried here, and the loop below descends from this map's root
+        // independently, so this check is what keeps a malformed map from reaching getChildNodeID.
+        if (nodeID.getDepth() >= kLeafDepth)
+        {
+            // LCOV_EXCL_START
+            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 +826,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,15 +842,30 @@ 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 = static_cast(std::distance(path.rbegin(), rit));
             if (node->isInner())
             {
+                // Nibbles run out at kLeafDepth, so only the leaf terminating the path may sit
+                // there. These nodes come off the wire, so a peer can still claim an inner one;
+                // reject it rather than passing this depth to selectBranch.
+                SOMETIMES(
+                    depth >= kLeafDepth, "xrpl::SHAMap::verifyProofPath : inner at leaf depth");
+                if (depth >= kLeafDepth)
+                    return false;
+
                 auto nodeId = SHAMapNodeID::createID(depth, key);
                 hash = safeDowncast(node.get())
                            ->getChildHash(selectBranch(nodeId, key));
             }
             else
             {
+                // The hash chain up to rootHash only proves this leaf sits where the path claims,
+                // not that it is the leaf for `key`: a peer could substitute any other leaf whose
+                // subtree hashes to the same value at every level above it. Checking the terminal
+                // leaf's own key is what ties the proof to `key` specifically.
+                if (leafKey(*node) != key)
+                    return false;
+
                 // should exhaust all the blobs now
                 return depth + 1 == path.size();
             }
diff --git a/src/libxrpl/tx/AGENTS.md b/src/libxrpl/tx/AGENTS.md
new file mode 100644
index 0000000000..e2261fc1ad
--- /dev/null
+++ b/src/libxrpl/tx/AGENTS.md
@@ -0,0 +1,5 @@
+# AGENTS.md — tx
+
+See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance.
+
+Any change to transaction-processing behavior must be gated behind an amendment. New amendments (and fixes, i.e. `fix*` amendments) are added to [`include/xrpl/protocol/detail/features.macro`](../../../include/xrpl/protocol/detail/features.macro), as an `XRPL_FEATURE(...)` or `XRPL_FIX(...)` entry added to the top of the list (the list is kept in reverse chronological order). Once the pre-amendment code path for a retired amendment is removed, move its entry to `XRPL_RETIRE_FEATURE(...)`/`XRPL_RETIRE_FIX(...)` instead of deleting it.
diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp
index cb5815dcf8..d2b826bb53 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 {
 
@@ -95,75 +87,4 @@ ApplyContext::visit(
     view_->visit(base_.view(), 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/CLAUDE.md b/src/libxrpl/tx/CLAUDE.md
new file mode 120000
index 0000000000..47dc3e3d86
--- /dev/null
+++ b/src/libxrpl/tx/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
index b00a3a5db4..630075461e 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 
@@ -1591,53 +1592,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);
 }
 
 //------------------------------------------------------------------------------
@@ -1689,84 +1649,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) || (result == tecBYTECODE_REJECTED) ||
-        (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) || (result == tecBYTECODE_REJECTED) ||
+            (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 {
+        ctx_.finalize();
+        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);
 
-    if ((ctx_.flags() & TapDryRun) != 0u)
-    {
-        applied = false;
-    }
+    // Once we call apply, we will no longer be able to look at view()
+    metadata = ctx_.apply(result);
 
-    if (metadata && ctx_.getEmittedTxns().size() > 0)
+    // Apply the transactions a smart contract emitted, all or nothing.
+    if (metadata && !ctx_.getEmittedTxns().empty())
     {
         OpenView emittedTxnsView(kBatchView, ctx_.openView());
         auto const parentBatchId = ctx_.tx.getTransactionID();
@@ -1778,14 +1751,12 @@ Transactor::operator()()
                 ctx_.registry, perTxBatchView, parentBatchId, tx, TapBatch, ctx_.journal);
             XRPL_ASSERT(
                 ret.applied == (isTesSuccess(ret.ter) || isTecClaim(ret.ter)),
-                "Inner transaction should not be applied");
+                "xrpl::Transactor::operator() : emitted transaction applied iff tes or tec");
 
             JLOG(ctx_.journal.debug())
                 << "BatchTrace[" << parentBatchId << "]: " << tx.getTransactionID() << " "
                 << (ret.applied ? "applied" : "failure") << ": " << transToken(ret.ter);
 
-            // If the transaction should be applied push its changes to the
-            // whole-batch view.
             if (ret.applied && (isTesSuccess(ret.ter) || isTecClaim(ret.ter)))
                 perTxBatchView.apply(emittedTxnsView);
 
@@ -1798,40 +1769,40 @@ Transactor::operator()()
         {
             auto txn = emittedTxns.front();
             emittedTxns.pop();
-            auto const result = applyOneTransaction(*txn);
-            XRPL_ASSERT(
-                result.applied == (isTesSuccess(result.ter) || isTecClaim(result.ter)),
-                "Outer Batch failure, inner transaction should not be applied");
-
-            if (!isTesSuccess(result.ter))
+            if (!isTesSuccess(applyOneTransaction(*txn).ter))
                 emitResult = false;
         }
 
         if (emitResult)
+        {
             emittedTxnsView.apply(ctx_.openView());
+        }
         else
         {
-            // reset context
+            // Roll back to the fee claim; the emitted set failed.
             result = tecBYTECODE_REJECTED;
             auto const resetResult = reset(fee);
             if (!isTesSuccess(resetResult.first))
                 result = resetResult.first;
             fee = resetResult.second;
 
-            // InvariantCheck. The context has just been reset, so only
-            // protocol invariants are meaningful here.
-            result = ctx_.checkInvariants(result, fee);
+            // Only protocol invariants apply once the effects are rolled back.
+            if (isTesSuccess(result) || isTecClaim(result))
+                result = checkInvariants(result, fee, InvariantScope::ProtocolOnly);
+            if (!isTecClaim(result) && !isTesSuccess(result))
+                return logger(result, false);
 
-            // apply
+            // reset() discarded the earlier fee destruction.
+            if (!view().open() && fee != beast::kZero)
+                ctx_.destroyXRP(fee);
             metadata = ctx_.apply(result);
         }
     }
 
-    ctx_.finalize();
+    if ((ctx_.flags() & TapDryRun) != 0u)
+        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/apply.cpp b/src/libxrpl/tx/apply.cpp
index f93b19a158..688c585e2a 100644
--- a/src/libxrpl/tx/apply.cpp
+++ b/src/libxrpl/tx/apply.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -23,13 +24,38 @@
 
 namespace xrpl {
 
-// These are the same flags defined as HashRouterFlags::PRIVATE1-4 in
-// HashRouter.h
+// This file owns HashRouterFlags::PRIVATE1-4 and PRIVATE7-8 in HashRouter.h.
+// These are the first four; the other two are below.
 constexpr HashRouterFlags kSfSigbad = HashRouterFlags::PRIVATE1;     // Signature is bad
 constexpr HashRouterFlags kSfSiggood = HashRouterFlags::PRIVATE2;    // Signature is good
 constexpr HashRouterFlags kSfLocalbad = HashRouterFlags::PRIVATE3;   // Local checks failed
 constexpr HashRouterFlags kSfLocalgood = HashRouterFlags::PRIVATE4;  // Local checks passed
 
+// Before fixCleanup3_4_0, a signature in an alternate role field, such as
+// sfSponsorSignature, covered the same bytes as the top level signature. Which
+// bytes a role signature must cover therefore depends on whether the fix is
+// enabled, but the four flags above record only the verdict, not the rules that
+// produced it. A verdict reached under one prefix would otherwise be reused
+// under the other.
+//
+// The two flags below hold the verdict for the pre-fix prefixes, so the pre-fix
+// and post-fix verdicts occupy separate slots and neither is ever read in the
+// other's era. Nothing is cleared when the amendment activates: setFlags only
+// sets bits, so a stale pre-fix verdict simply stops being read and ages out
+// with the rest of the routing table.
+//
+// This is not one switchover at a single instant. The era is chosen per call
+// from the rules passed in, and callers do not agree on the rules: relay and
+// submit verify against the validated rules, which lag the open ledger rules
+// that preflight2 verifies against. At the amendment's flag ledger the same
+// transaction can therefore be checked under both prefixes, on the same node,
+// at the same time.
+//
+// Remove these two flags, and oldPrefixSig below, when Cleanup3_4_0 is retired
+// in features.macro.
+constexpr HashRouterFlags kSfSigbadOldPrefix = HashRouterFlags::PRIVATE7;
+constexpr HashRouterFlags kSfSiggoodOldPrefix = HashRouterFlags::PRIVATE8;
+
 //------------------------------------------------------------------------------
 
 std::pair
@@ -48,21 +74,41 @@ checkValidity(HashRouter& router, STTx const& tx, Rules const& rules)
         return {Validity::SigBad, "Batch inner transactions are never considered validly signed."};
     }
 
-    if (any(flags & kSfSigbad))
+    // Pick the cache slot for this call's era; see kSfSiggoodOldPrefix above.
+    // Only a transaction that carries a role signature, and only while the fix
+    // is disabled, uses the separate slot. Every other transaction, and every
+    // transaction once the fix is enabled, uses the ordinary flags and verifies
+    // exactly once, so there is no steady state cost.
+    //
+    // Both directions matter. A good verdict from before the fix must not let a
+    // signature moved between roles survive the amendment, and a bad verdict
+    // from before the fix must not condemn a transaction that the new prefixes
+    // accept.
+    //
+    // Whether a transaction carries a role signature is fixed for its ID: the
+    // fields are kNotSigning, so they are excluded from the signed bytes, but
+    // they are still covered by the transaction ID. Repeat calls for one ID
+    // therefore always agree on which slot pair to use.
+    bool const oldPrefixSig = !rules.enabled(fixCleanup3_4_0) &&
+        (tx.isFieldPresent(sfSponsorSignature) || tx.isFieldPresent(sfCounterpartySignature));
+    auto const sigbadFlag = oldPrefixSig ? kSfSigbadOldPrefix : kSfSigbad;
+    auto const siggoodFlag = oldPrefixSig ? kSfSiggoodOldPrefix : kSfSiggood;
+
+    if (any(flags & sigbadFlag))
     {
         // Signature is known bad
         return {Validity::SigBad, "Transaction has bad signature."};
     }
 
-    if (!any(flags & kSfSiggood))
+    if (!any(flags & siggoodFlag))
     {
         auto const sigVerify = tx.checkSign(rules);
         if (!sigVerify)
         {
-            router.setFlags(id, kSfSigbad);
+            router.setFlags(id, sigbadFlag);
             return {Validity::SigBad, sigVerify.error()};
         }
-        router.setFlags(id, kSfSiggood);
+        router.setFlags(id, siggoodFlag);
     }
 
     // Signature is now known good
@@ -94,6 +140,19 @@ checkValidity(HashRouter& router, STTx const& tx, Rules const& rules)
 void
 forceValidity(HashRouter& router, uint256 const& txid, Validity validity)
 {
+    // Callers reach here when they deliberately skip signature verification,
+    // such as a cluster peer that trusts its neighbor's checks, or a
+    // configuration that turns signature checks off. Nothing was verified, so
+    // there is no prefix era to record. Mark both of checkValidity's signature
+    // slots good: otherwise the forced verdict is ignored for a role-signature
+    // transaction until fixCleanup3_4_0 is enabled, and the signature the
+    // caller meant to skip gets verified after all. Marking both cannot leak a
+    // verdict across eras, because no verdict was reached, and this is the only
+    // place the distinction can be recorded: kSfSiggood alone does not say
+    // whether checkValidity verified a post-fix signature or a caller forced
+    // the result. An already cached bad verdict still wins, since checkValidity
+    // tests its bad flag first. Drop kSfSiggoodOldPrefix when Cleanup3_4_0 is
+    // retired.
     HashRouterFlags flags = HashRouterFlags::UNDEFINED;
     switch (validity)
     {
@@ -101,7 +160,7 @@ forceValidity(HashRouter& router, uint256 const& txid, Validity validity)
             flags |= kSfLocalgood;
             [[fallthrough]];
         case Validity::SigGoodOnly:
-            flags |= kSfSiggood;
+            flags |= kSfSiggood | kSfSiggoodOldPrefix;
             [[fallthrough]];
         case Validity::SigBad:
             // would be silly to call directly
diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
index 0a604d4c39..272e52f09a 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,61 @@ 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, Privilege::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 de3245fcd2..a3d4892289 100644
--- a/src/libxrpl/tx/invariants/InvariantCheck.cpp
+++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -40,12 +41,15 @@
 
 namespace xrpl {
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \
-    case tag: {                                                              \
-        return (privileges) & priv;                                          \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                  \
+    case tag: {                                                                       \
+        return ((TxSettings UNWRAP settings).privileges & priv) != Privilege::NoPriv; \
     }
 
 bool
@@ -63,6 +67,8 @@ hasPrivilege(STTx const& tx, Privilege priv)
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
 // Returns the human-readable name of a ledger entry's type, falling back to
 // the numeric type if the format is somehow unknown.
@@ -436,7 +442,7 @@ AccountRootsNotDeleted::finalize(
     // transaction when the total AMM LP Tokens balance goes to 0.
     // A successful AccountDelete or AMMDelete MUST delete exactly
     // one account root.
-    if (hasPrivilege(tx, MustDeleteAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::MustDeleteAcct) && isTesSuccess(result))
     {
         if (accountsDeleted_ == 1)
             return true;
@@ -457,7 +463,7 @@ AccountRootsNotDeleted::finalize(
     // A successful AMMWithdraw/AMMClawback MAY delete one account root
     // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw
     // deletes the AMM account, accountsDeleted_ is set if it is deleted.
-    if (hasPrivilege(tx, MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
+    if (hasPrivilege(tx, Privilege::MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
         return true;
 
     if (accountsDeleted_ == 0)
@@ -760,14 +766,15 @@ ValidNewAccountRoot::finalize(
     }
 
     // From this point on we know exactly one account was created.
-    if (hasPrivilege(tx, CreateAcct | CreatePseudoAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::CreateAcct | Privilege::CreatePseudoAcct) &&
+        isTesSuccess(result))
     {
         bool const pseudoAccount =
             (pseudoAccount_ &&
              (view.rules().enabled(featureSingleAssetVault) ||
               view.rules().enabled(featureLendingProtocol)));
 
-        if (pseudoAccount && !hasPrivilege(tx, CreatePseudoAcct))
+        if (pseudoAccount && !hasPrivilege(tx, Privilege::CreatePseudoAcct))
         {
             JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a "
                                "wrong transaction type";
@@ -1117,30 +1124,34 @@ NoModifiedUnmodifiableFields::finalize(
     ReadView const& view,
     beast::Journal const& j)
 {
-    static auto const kFieldChanged = [](auto const& before, auto const& after, auto const& field) {
+    auto const kFieldChanged = [&j, &tx](auto const& before, auto const& after, auto const& field) {
         bool const beforeField = before->isFieldPresent(field);
         bool const afterField = after->isFieldPresent(field);
-        return beforeField != afterField || (afterField && before->at(field) != after->at(field));
+        bool const changed =
+            beforeField != afterField || (afterField && before->at(field) != after->at(field));
+        if (changed)
+        {
+            JLOG(j.fatal()) << "Invariant failed: " << field.getName()
+                            << " changed on immutable ledger entry in " << tx.getTransactionID();
+        }
+        return changed;
     };
     for (auto const& slePair : changedEntries_)
     {
         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) ||
@@ -1151,15 +1162,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,20 +1180,59 @@ NoModifiedUnmodifiableFields::finalize(
                     kFieldChanged(before, after, sfPaymentInterval) ||
                     kFieldChanged(before, after, sfGracePeriod) ||
                     kFieldChanged(before, after, sfLoanScale);
+
+                // lsfLoanOverpayment must never toggle. lsfLoanDefault may only
+                // transition from unset to set, which combined with ValidLoan's rule that
+                // only LoanManage may change it makes the flag write-once.
+                if (view.rules().enabled(featureLendingProtocolV1_1))
+                {
+                    std::uint32_t const beforeFlags = before->getFlags();
+                    std::uint32_t const afterFlags = after->getFlags();
+                    bool const overpaymentChanged =
+                        (beforeFlags & lsfLoanOverpayment) != (afterFlags & lsfLoanOverpayment);
+                    if (overpaymentChanged)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: lsfLoanOverpayment flag "
+                                           "toggled on immutable ledger entry in "
+                                        << tx.getTransactionID();
+                    }
+                    bad = bad || overpaymentChanged;
+                    bool const defaultCleared =
+                        (beforeFlags & lsfLoanDefault) != 0 && (afterFlags & lsfLoanDefault) == 0;
+                    if (defaultCleared)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault flag "
+                                           "cleared on immutable ledger entry in "
+                                        << tx.getTransactionID();
+                    }
+                    bad = bad || defaultCleared;
+                }
+                break;
+            case ltVAULT:
+                /*
+                 * All the fields below are only immutable from
+                 * featureLendingProtocolV1_1 onwards; some of them only exist on
+                 * V1_1 vaults. Before that amendment, sfAsset, sfAccount and
+                 * sfShareMPTID are checked by VaultInvariant instead.
+                 */
+                if (view.rules().enabled(featureLendingProtocolV1_1))
+                {
+                    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) ||
+                        kFieldChanged(before, after, sfAsset) ||
+                        kFieldChanged(before, after, sfAccount) ||
+                        kFieldChanged(before, after, sfShareMPTID);
+                }
                 break;
             default:
-                /*
-                 * 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.
-                 */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex);
+                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/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
index b70c02947f..e15921b7b2 100644
--- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
@@ -1,13 +1,18 @@
 #include 
 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -22,6 +27,24 @@ namespace xrpl {
 void
 ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
+    // Track LoanBroker deletions so finalize() can enforce:
+    //   (a) only ttLOAN_BROKER_DELETE removes a broker
+    //   (b) at most one broker is removed per transaction
+    //   (c) DebtTotal and OwnerCount were zero before deletion
+    // `before` is the pre-transaction state, which is what
+    // LoanBrokerDelete::preclaim reads. Erased trust lines and MPTokens need no
+    // special handling here: the `if (after)` branch below already records them.
+    if (isDelete && before && before->getType() == ltLOAN_BROKER)
+    {
+        if (deletedBroker_)
+        {
+            multipleBrokerDeletions_ = true;
+        }
+        else
+        {
+            deletedBroker_ = before;
+        }
+    }
     if (after)
     {
         if (after->getType() == ltLOAN_BROKER)
@@ -99,6 +122,64 @@ ValidLoanBroker::finalize(
     // Loan Brokers will not exist on ledger if the Lending Protocol amendment
     // is not enabled, so there's no need to check it.
 
+    // Deletion invariants (featureLendingProtocolV1_1). At most one
+    // LoanBroker may be removed per transaction, and only by
+    // ttLOAN_BROKER_DELETE, and only when its pre-state OwnerCount is zero and
+    // its pre-state DebtTotal is zero to the precision of the vault asset. The
+    // DebtTotal check complements ValidLoan's
+    // LoanBrokerDelete-must-not-touch-any-loan rule: even a broker that has
+    // finished paying off every loan may still hold non-zero exposure until
+    // its LoanBrokerCoverWithdraw settles, and neither state is safe to
+    // delete.
+    if (view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        if (multipleBrokerDeletions_)
+        {
+            JLOG(j.fatal())
+                << "Invariant failed: more than one Loan Broker deleted in a single transaction";
+            return false;
+        }
+        if (deletedBroker_)
+        {
+            if (tx.getTxnType() != ttLOAN_BROKER_DELETE)
+            {
+                JLOG(j.fatal()) << "Invariant failed: " <<  //
+                    "Loan Broker deleted by a transaction other than LoanBrokerDelete";
+                return false;
+            }
+            // Mirror LoanBrokerDelete::preclaim, which accepts a DebtTotal
+            // that rounds to zero at the vault's AssetsTotal scale rather than
+            // requiring an exact zero. Requiring more here would turn a
+            // transaction the transactor deliberately permits into an
+            // invariant failure.
+            if (auto const debtTotal = deletedBroker_->at(sfDebtTotal); debtTotal != beast::kZero)
+            {
+                // The erased broker is also collected in brokers_, and that
+                // loop reports a missing vault, so no separate diagnostic is
+                // needed here. Without a vault there is no scale to round at,
+                // so the residue cannot be excused as dust.
+                auto const vault = view.read(keylet::vault(deletedBroker_->at(sfVaultID)));
+                if (!vault ||
+                    roundToAsset(
+                        Asset{vault->at(sfAsset)},
+                        debtTotal,
+                        getAssetsTotalScale(vault),
+                        Number::RoundingMode::TowardsZero) != beast::kZero)
+                {
+                    JLOG(j.fatal())
+                        << "Invariant failed: Loan Broker deleted with non-zero debt total";
+                    return false;
+                }
+            }
+            if (deletedBroker_->at(sfOwnerCount) != 0)
+            {
+                JLOG(j.fatal())
+                    << "Invariant failed: Loan Broker deleted with non-zero owner count";
+                return false;
+            }
+        }
+    }
+
     for (auto const& line : lines_)
     {
         for (auto const& field : {&sfLowLimit, &sfHighLimit})
@@ -142,7 +223,6 @@ ValidLoanBroker::finalize(
 
         auto const& before = broker.brokerBefore;
 
-        // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants
         // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most
         // one node (the root), which will only hold entries for `RippleState`
         // or `MPToken` objects.
diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp
index ce9a7c6e03..b34d7088be 100644
--- a/src/libxrpl/tx/invariants/LoanInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp
@@ -1,23 +1,39 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 #include 
 #include 
+#include 
 #include 
 
+#include 
+
 namespace xrpl {
 
 void
 ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
 {
-    if (after && after->getType() == ltLOAN)
+    // Classify here, but leave the decision about which checks apply to
+    // finalize(), which is the only place that can see the Rules.
+    if (isDelete)
+    {
+        if (before && before->getType() == ltLOAN)
+            deletedLoans_.emplace_back(before, after);
+    }
+    else if (after && after->getType() == ltLOAN)
     {
         loans_.emplace_back(before, after);
     }
@@ -26,7 +42,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)
@@ -34,8 +50,50 @@ ValidLoan::finalize(
     // Loans will not exist on ledger if the Lending Protocol amendment
     // is not enabled, so there's no need to check it.
 
+    auto const txType = tx.getTxnType();
+    bool const lpV11Enabled = view.rules().enabled(featureLendingProtocolV1_1);
+
+    // Without featureLendingProtocolV1_1 an erased Loan is subject to the same
+    // per-entry checks as any modified Loan. From V1_1 onward it is only subject
+    // to the ttLOAN_DELETE check below.
+    if (!lpV11Enabled)
+        loans_.insert(loans_.end(), deletedLoans_.begin(), deletedLoans_.end());
+
+    // Ledger entry validation checks.
     for (auto const& [before, after] : loans_)
     {
+        // A closed-ended vault must not accept a loan whose final scheduled payment falls fewer
+        // than kLoanRedemptionBuffer seconds before the vault's RedemptionDate. This mirrors the
+        // LoanSet::preclaim gate and only fires on loan creation; once the loan exists, its
+        // StartDate / PaymentInterval are immutable and PaymentRemaining only decreases, so the
+        // bound is preserved.
+        if (!before && isTesSuccess(result))
+        {
+            auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
+            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) +
+                            kLoanRedemptionBuffer >
+                        redemption)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment "
+                                           "must precede RedemptionDate by at least "
+                                           "kLoanRedemptionBuffer";
+                        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 &&
@@ -57,7 +115,11 @@ ValidLoan::finalize(
             JLOG(j.fatal()) << "Invariant failed: Fully paid off Loan still has payments remaining";
             return false;
         }
-        if (before && (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
+
+        // From featureLendingProtocolV1_1 onwards this flag is immutable by way of
+        // NoModifiedUnmodifiableFields.
+        if (!lpV11Enabled && before &&
+            (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
         {
             JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed";
             return false;
@@ -89,6 +151,125 @@ ValidLoan::finalize(
                 return false;
             }
         }
+        if (lpV11Enabled)
+        {
+            // Only LoanSet may create a loan.
+            if (!before && txType != ttLOAN_SET)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Loan created by a transaction "
+                                   "other than LoanSet";
+                return false;
+            }
+
+            if (after->at(sfPaymentRemaining) == 0 &&
+                after->at(~sfNextPaymentDueDate).value_or(0) != 0)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Loan with zero payments must have zero next "
+                                   "payment due date";
+                return false;
+            }
+
+            if (before)
+            {
+                bool const wasImpaired = before->isFlag(lsfLoanImpaired);
+                bool const isImpaired = after->isFlag(lsfLoanImpaired);
+                bool const wasDefaulted = before->isFlag(lsfLoanDefault);
+                bool const isDefaulted = after->isFlag(lsfLoanDefault);
+
+                if (wasImpaired != isImpaired && txType != ttLOAN_MANAGE && txType != ttLOAN_PAY)
+                {
+                    JLOG(j.fatal()) << "Invariant failed: lsfLoanImpaired changed "
+                                       "outside LoanManage or LoanPay";
+                    return false;
+                }
+                if (wasDefaulted != isDefaulted && txType != ttLOAN_MANAGE)
+                {
+                    JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault changed "
+                                       "outside LoanManage";
+                    return false;
+                }
+            }
+
+            // A loan must reference a live loan broker, and that broker must
+            // reference a live vault; otherwise the loan is orphaned and its
+            // balances have no counterparty on the ledger.
+            auto const brokerSle = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
+            if (!brokerSle)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Loan broker does not exist";
+                return false;
+            }
+            auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID)));
+            if (!vaultSle)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Loan broker vault does not exist";
+                return false;
+            }
+
+            // Interest due (the total value owed less principal and management fee)
+            // must never be negative. TotalValueOutstanding, PrincipalOutstanding and
+            // ManagementFeeOutstanding are each independently rounded to sfLoanScale
+            // by the accounting code, so their difference can carry one unit of
+            // quantization noise even when the underlying flow is correct. Absorb
+            // one unit at that scale, matching the pattern used in ValidVault.
+            auto const interestDue = after->at(sfTotalValueOutstanding) -
+                after->at(sfPrincipalOutstanding) - after->at(sfManagementFeeOutstanding);
+
+            // Only IOU amounts can accumulate STAmount quantization noise. For integral-domain
+            // assets (XRP/MPT) enforce the boundary strictly.
+            bool const integral = Asset{vaultSle->at(sfAsset)}.integral();
+
+            Number const tolerance = integral ? Number{} : Number{-1, after->at(sfLoanScale)};
+            if (interestDue < tolerance)
+            {
+                JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
+                return false;
+            }
+
+            // Transaction success post-conditions. A successful loan pay makes at least
+            // one scheduled payment, so a loan left with payments still outstanding
+            // must show that payment in its balance and schedule. A payment that clears
+            // the loan outright instead drives PaymentRemaining to zero, which the
+            // fully-paid-off and zero due-date checks above pin.
+            if (isTesSuccess(result) && txType == ttLOAN_PAY)
+            {
+                if (before && after->at(sfPaymentRemaining) != 0)
+                {
+                    if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding)))
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease "
+                                           "PrincipalOutstanding on a non-full-repayment";
+                        return false;
+                    }
+                    if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining)))
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: loan pay must decrease "
+                                           "PaymentRemaining on a non-full-repayment";
+                        return false;
+                    }
+
+                    std::uint32_t const beforeDue = before->at(~sfNextPaymentDueDate).value_or(0);
+                    std::uint32_t const afterDue = after->at(~sfNextPaymentDueDate).value_or(0);
+                    std::uint32_t const interval = after->at(sfPaymentInterval);
+                    if (afterDue <= beforeDue || interval == 0 ||
+                        (afterDue - beforeDue) % interval != 0)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: loan pay must advance "
+                                           "NextPaymentDueDate by a positive multiple of "
+                                           "PaymentInterval on a non-full-repayment";
+                        return false;
+                    }
+                }
+            }
+        }
+    }
+
+    // Only LoanDelete may delete a loan.
+    if (lpV11Enabled && txType != ttLOAN_DELETE && !deletedLoans_.empty())
+    {
+        JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
+                           "other than LoanDelete";
+        return false;
     }
     return true;
 }
diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
index 77c5ad781e..e38e8f2b93 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 
@@ -143,6 +144,8 @@ ValidMPTIssuance::finalize(
     //     must not dangle outside that controlled lifecycle.
     if (rules.enabled(fixCleanup3_2_0))
     {
+        // Not an amendment gate like the same-named flags below, just an
+        // accumulator, so that every violation gets logged before returning.
         bool invariantPasses = true;
         if (referenceHoldingMutated_)
         {
@@ -208,7 +211,7 @@ ValidMPTIssuance::finalize(
         }
 
         auto const txnType = tx.getTxnType();
-        if (hasPrivilege(tx, CreateMptIssuance))
+        if (hasPrivilege(tx, Privilege::CreateMptIssuance))
         {
             if (mptIssuancesCreated_ == 0)
             {
@@ -229,7 +232,7 @@ ValidMPTIssuance::finalize(
             return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
         }
 
-        if (hasPrivilege(tx, DestroyMptIssuance))
+        if (hasPrivilege(tx, Privilege::DestroyMptIssuance))
         {
             if (mptIssuancesDeleted_ == 0)
             {
@@ -256,7 +259,8 @@ ValidMPTIssuance::finalize(
         // non-amendment-gated side effects.
         bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
             (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
-        if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish)
+        if (hasPrivilege(tx, Privilege::MustAuthorizeMpt | Privilege::MayAuthorizeMpt) ||
+            enforceEscrowFinish)
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -272,7 +276,7 @@ ValidMPTIssuance::finalize(
                                    "succeeded but deleted issuances";
                 return false;
             }
-            if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) &&
+            if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) &&
                 (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
             {
                 if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
@@ -282,12 +286,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";
@@ -307,7 +312,7 @@ ValidMPTIssuance::finalize(
                 return false;
             }
             else if (
-                !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) &&
+                !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) &&
                 (mptokensCreated_ + mptokensDeleted_ != 1))
             {
                 // if the holder submitted this tx, then a mptoken must be
@@ -320,7 +325,7 @@ ValidMPTIssuance::finalize(
             return true;
         }
 
-        if (hasPrivilege(tx, MayCreateMpt))
+        if (hasPrivilege(tx, Privilege::MayCreateMpt))
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -375,7 +380,7 @@ ValidMPTIssuance::finalize(
             return true;
         }
 
-        if (hasPrivilege(tx, MayDeleteMpt) &&
+        if (hasPrivilege(tx, Privilege::MayDeleteMpt) &&
             ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) &&
             mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0)
             return true;
@@ -472,7 +477,9 @@ ValidMPTBalanceChanges::finalize(
     ReadView const& view,
     beast::Journal const& j)
 {
-    if (isTesSuccess(result))
+    auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
+
+    if (isTesSuccess(result) || fix340Enabled)
     {
         // Confidential transactions are validated by ValidConfidentialMPToken.
         // They modify encrypted fields and sfConfidentialOutstandingAmount
@@ -484,7 +491,9 @@ ValidMPTBalanceChanges::finalize(
             return true;
         }
 
-        bool const invariantPasses = !view.rules().enabled(featureMPTokensV2);
+        // Returned when a violation is found below, so this is the log-only
+        // condition. Either amendment makes the checks enforcing.
+        auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
         if (overflow_)
         {
             JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
@@ -508,6 +517,18 @@ ValidMPTBalanceChanges::finalize(
                                 << " " << data.mptAmount;
                 return invariantPasses;
             }
+
+            // A failed transaction must not have moved MPT value; the check
+            // above ties mptAmount to the OutstandingAmount delta. No result
+            // code is exempt: on any tec the transactor discards the view and
+            // re-applies only offer, trust line, NFT offer and credential
+            // deletions (Transactor::typesForResult), none of which touch MPTs.
+            if (!isTesSuccess(result) && data.mptAmount != 0)
+            {
+                JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure "
+                                << tx.getTxnType() << " " << result;
+                return invariantPasses;
+            }
         }
     }
 
@@ -803,6 +824,14 @@ ValidMPTTransfer::visitEntry(
 
     if (after)
         update(*after, false);
+
+    // Record whether every touched AccountRoot was a pseudo-account BEFORE
+    // the transaction applied (true and false). A transaction that erases a
+    // pseudo-account (and moves MPT out of it) in the same transaction leaves
+    // no trace of its pseudo-account status in the post-transaction view
+    // isAuthorized() sees at finalize() time.
+    if (before && before->getType() == ltACCOUNT_ROOT)
+        pseudoAccountsBefore_[before->at(sfAccount)] = isPseudoAccount(before);
 }
 
 bool
@@ -815,10 +844,19 @@ ValidMPTTransfer::isAuthorized(
     // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their
     // participants and are implicitly authorized for any MPT they hold,
     // including vault shares whose underlying asset would otherwise require
-    // auth.  Exempt them here rather than relying on requireAuth: the recursive
+    // auth. Exempt them here rather than relying on requireAuth: the recursive
     // share -> underlying descent in requireAuth fails for a pseudo-account
     // that holds the share but not the underlying.
-    if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
+    //
+    // Use the pre-transaction classification for any account this
+    // transaction touched (pseudoAccountsBefore_): the post-transaction view
+    // is wrong for an account this same transaction erased. Untouched
+    // accounts aren't in the map, so fall back to the current view, which is
+    // still accurate for them since nothing changed.
+    auto const pseudoIt = pseudoAccountsBefore_.find(holder);
+    bool const isPseudo =
+        pseudoIt != pseudoAccountsBefore_.end() ? pseudoIt->second : isPseudoAccount(view, holder);
+    if (isPseudo)
         return true;
 
     auto const key = keylet::mptoken(mptid, holder);
@@ -831,14 +869,22 @@ ValidMPTTransfer::isAuthorized(
 bool
 ValidMPTTransfer::finalize(
     STTx const& tx,
-    TER const,
+    TER const result,
     XRPAmount const,
     ReadView const& view,
     beast::Journal const& j)
 {
-    if (hasPrivilege(tx, OverrideFreeze))
+    if (hasPrivilege(tx, Privilege::OverrideFreeze))
         return true;
 
+    // XLS-0066: a broker must be able to default an already-late loan
+    // 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.
@@ -854,9 +900,19 @@ ValidMPTTransfer::finalize(
         return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
     }();
 
-    // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes.
-    // Log invariant failure error even if MPTokensV2 is disabled.
-    auto const invariantPasses = !view.rules().enabled(featureMPTokensV2);
+    auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
+    // Returned when a violation is found below, so this is the log-only
+    // condition. Either amendment makes the checks enforcing.
+    auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
+
+    // A failed transaction must not persist an MPToken deletion. Pre-loop
+    // because deletedAuthorized_ is not issuance-scoped and orphans continue.
+    if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty())
+    {
+        JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " "
+                        << result;
+        return invariantPasses;
+    }
 
     for (auto const& [mptID, values] : amount_)
     {
@@ -866,6 +922,20 @@ ValidMPTTransfer::finalize(
         auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
         if (!sleIssuance)
         {
+            // MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so
+            // an orphaned MPToken can outlive its issuance and be cleaned up
+            // later by a transaction of any type. There are no transfer rules
+            // left to check, but its balance is zero and nothing can raise it,
+            // so any change other than deletion is a bug.
+            for (auto const& [account, value] : values)
+            {
+                if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
+                {
+                    JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed "
+                                    << txnType << " " << result;
+                    return invariantPasses;
+                }
+            }
             continue;
         }
 
@@ -880,6 +950,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 +975,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;
@@ -915,6 +999,16 @@ ValidMPTTransfer::finalize(
             JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
             return invariantPasses;
         }
+
+        // A failed transaction must not have changed a holder's balance. One
+        // side is enough, unlike the transfer check above, so this also catches
+        // a lock/unlock moving value between sfMPTAmount and sfLockedAmount.
+        if (fix340Enabled && !isTesSuccess(result) && (senders > 0 || receivers > 0))
+        {
+            JLOG(j.fatal()) << "Invariant failed: MPToken balance changed on failure " << txnType
+                            << " " << result;
+            return invariantPasses;
+        }
     }
 
     return true;
diff --git a/src/libxrpl/tx/invariants/NFTInvariant.cpp b/src/libxrpl/tx/invariants/NFTInvariant.cpp
index f935f893cc..92063fc183 100644
--- a/src/libxrpl/tx/invariants/NFTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp
@@ -208,7 +208,7 @@ NFTokenCountTracking::finalize(
     ReadView const& view,
     beast::Journal const& j) const
 {
-    if (!hasPrivilege(tx, ChangeNftCounts))
+    if (!hasPrivilege(tx, Privilege::ChangeNftCounts))
     {
         if (beforeMintedTotal_ != afterMintedTotal_)
         {
diff --git a/src/libxrpl/tx/invariants/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..69c3ce92e0 100644
--- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
+++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
@@ -3,9 +3,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -19,16 +21,33 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #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 +63,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;
 }
 
@@ -214,21 +236,57 @@ ValidVault::deltaAssets(AccountID const& id) const
         vaultAsset.value());
 }
 
+std::optional
+ValidVault::feePayerAccountRoot(ReadView const& view, STTx const& tx)
+{
+    auto const feePayer = Transactor::getFeePayer(view, tx);
+    if (feePayer.type == FeePayerType::SponsorPreFunded)
+        return std::nullopt;
+    return feePayer.id;
+}
+
 std::optional
-ValidVault::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const
+ValidVault::deltaAssetsForParty(
+    ReadView const& view,
+    AccountID const& id,
+    STTx const& tx,
+    XRPAmount fee,
+    bool fix340Enabled) const
 {
     auto const& vaultAsset = afterVault_[0].asset;
-    auto ret = deltaAssets(tx[sfAccount]);
+    auto ret = deltaAssets(id);
     if (!ret.has_value() || !vaultAsset.native())
         return ret;
 
-    // Only add the fee back if tx[sfAccount] actually paid it. When the fee is
-    // paid by someone else (a delegate or a fee sponsor), the
-    // account's XRP balance moved only by the vault amount.
-    if (tx.getFeePayerID() != tx[sfAccount])
-        return ret;
+    if (!fix340Enabled)
+    {
+        // Legacy behaviour: only tx[sfAccount] was ever considered for a fee
+        // correction, and only when STTx::getFeePayerID identified it as the
+        // fee payer (which is never true for a sponsor, since
+        // self-sponsorship is disallowed). After that sender-only correction
+        // a zero delta is collapsed to absence; if the correction does not
+        // apply, a present-zero is returned as-is.
+        if (id != tx[sfAccount] || tx.getFeePayerID() != id)
+            return ret;
 
-    ret->delta += fee.drops();
+        ret->delta += fee.drops();
+        if (ret->delta == kZero)
+            return std::nullopt;
+
+        return ret;
+    }
+
+    // Add the fee back only onto the AccountRoot that actually paid it: an
+    // ordinary sender, a delegate, or a co-signed fee sponsor -- but never a
+    // pre-funded sponsorship, whose fee is drawn from the ltSponsorship
+    // object rather than the sponsor's own XRP balance.
+    if (auto const payer = feePayerAccountRoot(view, tx); payer && *payer == id)
+        ret->delta += fee.drops();
+
+    // Normalize an economically zero delta to absence regardless of who (if
+    // anyone) paid the fee, so a touched-but-unchanged AccountRoot (e.g. the
+    // sender in a third-party withdrawal, touched only for sequence/ticket
+    // processing) is never misread as a second payout recipient.
     if (ret->delta == kZero)
         return std::nullopt;
 
@@ -254,6 +312,76 @@ 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;
+}
+
+namespace {
+
+// sfAssetsTotal, sfAssetsAvailable and sfLossUnrealized are STNumber fields
+// with kSmdNeedsAsset, so IOU writes go through associateAsset -> roundToAsset
+// -> STAmount quantization. Since assetsTotal is the largest number, it lands
+// on the coarsest decimal grid, and strict equality on the deltas can fire on
+// a single unit of quantization noise even when the underlying flow is
+// correct. Absorb one unit at the coarsest scale.
+//
+// XRP and MPT are integer-domain assets (Asset::integral() is true) with no
+// sub-ULP quantization; treating a whole drop / MPT unit as "noise" would
+// hide real accounting bugs. Keep the strict comparison there. Note that
+// gating on the sign of `scale` would be wrong: IOU amounts >= 1e15 have a
+// non-negative STAmount exponent but still quantize.
+[[nodiscard]] bool
+agreesWithinOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
+{
+    if (asset.integral())
+        return lhs == rhs;
+    auto const diff = lhs - rhs;
+    Number const tolerance{1, scale};
+    return (diff < beast::kZero ? -diff : diff) <= tolerance;
+}
+
+// L, T and A are each independently quantized; the strict L <= T - A check
+// can fire on residual noise even when the true relationship holds. Tolerate
+// one unit at scale(assetsTotal) - the coarsest of the three grids. As with
+// the delta check above, the tolerance is meaningful only for IOU
+// (Asset::integral() is false); XRP and MPT keep the strict comparison.
+[[nodiscard]] bool
+lessOrEqualPlusOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
+{
+    if (asset.integral())
+        return lhs <= rhs;
+    return lhs <= rhs + Number{1, scale};
+}
+
+}  // namespace
+
 std::int32_t
 ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
 {
@@ -289,13 +417,14 @@ ValidVault::finalize(
     beast::Journal const& j)
 {
     bool const enforce = view.rules().enabled(featureSingleAssetVault);
+    bool const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
 
     if (!isTesSuccess(ret))
         return true;  // Do not perform checks
 
     if (afterVault_.empty() && beforeVault_.empty())
     {
-        if (hasPrivilege(tx, MustModifyVault))
+        if (hasPrivilege(tx, Privilege::MustModifyVault))
         {
             JLOG(j.fatal()) <<  //
                 "Invariant failed: vault operation succeeded without modifying "
@@ -306,7 +435,8 @@ ValidVault::finalize(
 
         return true;  // Not a vault operation
     }
-    if (!(hasPrivilege(tx, MustModifyVault) || hasPrivilege(tx, MayModifyVault)))
+    if (!(hasPrivilege(tx, Privilege::MustModifyVault) ||
+          hasPrivilege(tx, Privilege::MayModifyVault)))
     {
         JLOG(j.fatal()) <<  //
             "Invariant failed: vault updated by a wrong transaction type";
@@ -422,7 +552,8 @@ ValidVault::finalize(
     bool result = true;
 
     // Universal transaction checks
-    if (!beforeVault_.empty())
+    // From LendingProtocolV1_1 onwards, vault immutability check is moved to InvariantCheck.cpp
+    if (!beforeVault_.empty() && !view.rules().enabled(featureLendingProtocolV1_1))
     {
         auto const& beforeVault = beforeVault_[0];
         if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||
@@ -475,15 +606,32 @@ ValidVault::finalize(
                            "not be greater than assets outstanding";
         result = false;
     }
-    else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable)
+    else
     {
-        JLOG(j.fatal())  //
-            << "Invariant failed: loss unrealized must not exceed "
-               "the difference between assets outstanding and available";
-        result = false;
+        bool const gapExceeded = [&] {
+            if (!fix340Enabled)
+            {
+                return afterVault.lossUnrealized >
+                    afterVault.assetsTotal - afterVault.assetsAvailable;
+            }
+
+            auto const s = scale(afterVault.assetsTotal, afterVault.asset);
+            return !lessOrEqualPlusOneUnit(
+                afterVault.lossUnrealized,
+                afterVault.assetsTotal - afterVault.assetsAvailable,
+                afterVault.asset,
+                s);
+        }();
+        if (gapExceeded)
+        {
+            JLOG(j.fatal())  //
+                << "Invariant failed: loss unrealized must not exceed "
+                   "the difference between assets outstanding and available";
+            result = false;
+        }
     }
 
-    if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero)
+    if (fix340Enabled && afterVault.lossUnrealized < kZero)
     {
         JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative";
         result = false;
@@ -520,6 +668,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 +757,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: {
@@ -631,8 +802,13 @@ ValidVault::finalize(
                     result = false;
                 }
 
+                // AssetsTotal may exceed AssetsMaximum when the excess is interest. After
+                // fixCleanup3_4_0, only reject a VaultSet that supplies sfAssetsMaximum or
+                // otherwise changes the cap to a nonzero value still below AssetsTotal.
                 if (afterVault.assetsMaximum > kZero &&
-                    afterVault.assetsTotal > afterVault.assetsMaximum)
+                    afterVault.assetsTotal > afterVault.assetsMaximum &&
+                    (!fix340Enabled || tx.isFieldPresent(sfAssetsMaximum) ||
+                     beforeVault.assetsMaximum != afterVault.assetsMaximum))
                 {
                     JLOG(j.fatal()) <<  //
                         "Invariant failed: set assets outstanding must not "
@@ -666,6 +842,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)
                 {
@@ -706,7 +897,8 @@ ValidVault::finalize(
 
                 if (!issuerDeposit)
                 {
-                    auto const maybeAccDeltaAssets = deltaAssetsTxAccount(tx, fee);
+                    auto const maybeAccDeltaAssets =
+                        deltaAssetsForParty(view, tx[sfAccount], tx, fee, fix340Enabled);
                     if (!maybeAccDeltaAssets)
                     {
                         JLOG(j.fatal())
@@ -731,7 +923,14 @@ ValidVault::finalize(
                         result = false;
                     }
 
-                    if (localVaultDeltaAssets * -1 != accountDeltaAssets)
+                    bool const acctVaultAddsUp = fix340Enabled
+                        ? agreesWithinOneUnit(
+                              localVaultDeltaAssets * -1,
+                              accountDeltaAssets,
+                              vaultAsset,
+                              localMinScale)
+                        : localVaultDeltaAssets * -1 == accountDeltaAssets;
+                    if (!acctVaultAddsUp)
                     {
                         JLOG(j.fatal()) << "Invariant failed: " <<  //
                             "deposit must change vault and depositor balance by equal amount";
@@ -779,7 +978,10 @@ ValidVault::finalize(
 
                 auto const assetTotalDelta = roundToAsset(
                     vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
-                if (assetTotalDelta != vaultDeltaAssets)
+                bool const totalAddsUp = fix340Enabled
+                    ? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
+                    : assetTotalDelta == vaultDeltaAssets;
+                if (!totalAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: deposit and assets outstanding must add up";
@@ -788,7 +990,11 @@ ValidVault::finalize(
 
                 auto const assetAvailableDelta = roundToAsset(
                     vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
-                if (assetAvailableDelta != vaultDeltaAssets)
+                bool const availableAddsUp = fix340Enabled
+                    ? agreesWithinOneUnit(
+                          assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
+                    : assetAvailableDelta == vaultDeltaAssets;
+                if (!availableAddsUp)
                 {
                     JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up";
                     result = false;
@@ -804,20 +1010,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 = fix340Enabled && !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;
@@ -834,73 +1071,107 @@ ValidVault::finalize(
 
                 if (!issuerWithdrawal)
                 {
-                    auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee);
-                    auto const maybeOtherAccDelta = [&]() -> std::optional {
-                        if (auto const destination = tx[~sfDestination];
-                            destination && *destination != tx[sfAccount])
-                            return deltaAssets(*destination);
-                        return std::nullopt;
-                    }();
+                    // Identify the intended recipient explicitly from
+                    // sfDestination (falling back to sfAccount for a
+                    // self-withdrawal), rather than inferring it from which
+                    // side happens to show a delta. When a distinct
+                    // destination is named, the sending account must not
+                    // also show a real economic delta -- that would mean two
+                    // accounts were paid, which is always a bug, regardless
+                    // of what (if anything) the named destination received.
+                    auto const destinationField = tx[~sfDestination];
+                    AccountID const recipient = destinationField.value_or(tx[sfAccount]);
+                    bool const distinctDestination =
+                        destinationField.has_value() && *destinationField != tx[sfAccount];
 
-                    if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
+                    // Intentionally ungated: `fix340Enabled &&` here would let the
+                    // pre-amendment sponsored case succeed and change consensus.
+                    if (distinctDestination &&
+                        deltaAssetsForParty(view, tx[sfAccount], tx, fee, fix340Enabled)
+                            .has_value())
                     {
                         JLOG(j.fatal()) <<  //
                             "Invariant failed: withdrawal must change one destination balance";
                         return false;
                     }
 
-                    auto const destinationDelta =  //
-                        maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
+                    auto const maybeRecipientDelta =
+                        deltaAssetsForParty(view, recipient, tx, fee, fix340Enabled);
 
-                    // 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)
+                    if (!maybeRecipientDelta.has_value())
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must increase destination balance";
-                        result = false;
+                        // A legitimate zero-value withdrawal moves nothing to
+                        // the recipient either; there is nothing left to
+                        // cross-check.
+                        if (!zeroDeltaIsLegitimate)
+                        {
+                            JLOG(j.fatal()) <<  //
+                                "Invariant failed: withdrawal must change one destination balance";
+                            return 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,
-                            maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
-                            destinationScale,
-                            Number::RoundingMode::Downward) == kZero;
-                    if (!destroyedIsSubUlp &&
-                        localPseudoDeltaAssets * -1 != roundedDestinationDelta)
+                    else
                     {
-                        JLOG(j.fatal()) << "Invariant failed: " <<  //
-                            "withdrawal must change vault and destination balance by equal "
-                            "amount";
-                        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 = *maybeRecipientDelta;
+
+                        // 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 for rounding artifacts.
+                        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;
+                        bool const withdrawAddsUp = fix340Enabled
+                            ? agreesWithinOneUnit(
+                                  localPseudoDeltaAssets * -1,
+                                  roundedDestinationDelta,
+                                  vaultAsset,
+                                  localMinScale)
+                            : localPseudoDeltaAssets * -1 == roundedDestinationDelta;
+                        if (!destroyedIsSubUlp && !withdrawAddsUp)
+                        {
+                            JLOG(j.fatal()) << "Invariant failed: " <<  //
+                                "withdrawal must change vault and destination balance by equal "
+                                "amount";
+                            result = false;
+                        }
                     }
                 }
 
@@ -937,7 +1208,11 @@ ValidVault::finalize(
                 auto const assetTotalDelta = roundToAsset(
                     vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
                 // Note, vaultBalance is negative (see check above)
-                if (assetTotalDelta != vaultPseudoDeltaAssets)
+                bool const totalAddsUp = fix340Enabled
+                    ? agreesWithinOneUnit(
+                          assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
+                    : assetTotalDelta == vaultPseudoDeltaAssets;
+                if (!totalAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: withdrawal and assets outstanding must add up";
@@ -947,7 +1222,11 @@ ValidVault::finalize(
                 auto const assetAvailableDelta = roundToAsset(
                     vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
 
-                if (assetAvailableDelta != vaultPseudoDeltaAssets)
+                bool const availableAddsUp = fix340Enabled
+                    ? agreesWithinOneUnit(
+                          assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
+                    : assetAvailableDelta == vaultPseudoDeltaAssets;
+                if (!availableAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: withdrawal and assets available must add up";
@@ -992,7 +1271,11 @@ ValidVault::finalize(
 
                     auto const assetsTotalDelta = roundToAsset(
                         vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
-                    if (assetsTotalDelta != vaultDeltaAssets)
+                    bool const totalAddsUp = fix340Enabled
+                        ? agreesWithinOneUnit(
+                              assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
+                        : assetsTotalDelta == vaultDeltaAssets;
+                    if (!totalAddsUp)
                     {
                         JLOG(j.fatal()) <<  //
                             "Invariant failed: clawback and assets outstanding must add up";
@@ -1003,7 +1286,11 @@ ValidVault::finalize(
                         vaultAsset,
                         afterVault.assetsAvailable - beforeVault.assetsAvailable,
                         minScale);
-                    if (assetAvailableDelta != vaultDeltaAssets)
+                    bool const availableAddsUp = fix340Enabled
+                        ? agreesWithinOneUnit(
+                              assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
+                        : assetAvailableDelta == vaultDeltaAssets;
+                    if (!availableAddsUp)
                     {
                         JLOG(j.fatal()) <<  //
                             "Invariant failed: clawback and assets available must add up";
@@ -1052,6 +1339,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..ae218a4cff 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);
 
@@ -1408,6 +1500,13 @@ template 
 bool
 BookStep::checkMPTDEX(ReadView const& view, AccountID const& owner) const
 {
+    // Offer-owner locks on book_.in and book_.out are handled by the
+    // liquidity sources before an offer reaches this point. OfferStream
+    // filters CLOB offers through the assetIn deep-freeze check and the
+    // assetOut owner-funds check using FreezeHandling::ZeroIfFrozen, while
+    // AMMLiquidity gets pool balances through ammAccountHolds(), which zeroes
+    // locked holdings. This method only enforces MPT trade and transfer
+    // permissions.
     if (!isTesSuccess(canTrade(view, book_.in)) || !isTesSuccess(canTrade(view, book_.out)))
         return false;
 
@@ -1421,14 +1520,8 @@ BookStep::checkMPTDEX(ReadView const& view, AccountID const
             // Offer's owner is an issuer
             if (asset.getIssuer() == owner)
                 return true;
-            // The previous step could be MPTEndpointStep with non issuer account or
-            // BookStep. Fail both if in asset is locked. In the former case it is holder
-            // to locked holder transfer. In the latter case it is not possible to tell if
-            // it is issuer to holder or holder to holder transfer.
-            if (isFrozen(view, owner, book_.in.get()))
-                return false;
-            // Previous step is BookStep. BookStep only sends if CanTransfer is
-            // set and not locked or the offer is owned by an issuer
+            // Previous BookStep already enforced transferability for the asset
+            // it sends to this offer.
             if (prevStep_->bookStepBook())
                 return true;
             // Previous step is MPTEndpointStep and offer's owner is not an
diff --git a/src/libxrpl/tx/paths/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..8fd69d3106 100644
--- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,6 +90,13 @@ protected:
     void
     resetCache(DebtDirection dir);
 
+    [[nodiscard]] TER
+    sendWithMPTCreate(
+        ApplyView& view,
+        AccountID const& src,
+        AccountID const& dst,
+        MPTAmount const& amount);
+
 private:
     MPTEndpointStep(
         StrandContext const& ctx,
@@ -274,7 +282,7 @@ public:
 
     // Not applicable for payment
     static TER
-    checkCreateMPT(ApplyView&, DebtDirection)
+    checkCreateMPT(ApplyView&)
     {
         return tesSUCCESS;
     }
@@ -322,7 +330,7 @@ public:
 
     // Can be created in rev or fwd (if limiting step) direction.
     TER
-    checkCreateMPT(ApplyView& view, DebtDirection srcDebtDir);
+    checkCreateMPT(ApplyView& view);
 };
 
 //------------------------------------------------------------------------------
@@ -401,7 +409,7 @@ MPTEndpointOfferCrossingStep::check(StrandContext const& ctx, SLE::const_ref)
 }
 
 TER
-MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirection srcDebtDir)
+MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view)
 {
     // TakerPays is the last step if offer crossing
     if (isLast_)
@@ -410,12 +418,16 @@ 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))
         {
+            // Unreachable: offer-crossing checks reject an offer whose owner
+            // could fail to create the MPToken.
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::MPTEndpointOfferCrossingStep::checkCreateMPT : create MPToken failed");
             JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT";
-            resetCache(srcDebtDir);
             return err;
+            // LCOV_EXCL_STOP
         }
     }
     return tesSUCCESS;
@@ -423,6 +435,30 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio
 
 //------------------------------------------------------------------------------
 
+template 
+TER
+MPTEndpointStep::sendWithMPTCreate(
+    ApplyView& view,
+    AccountID const& src,
+    AccountID const& dst,
+    MPTAmount const& amount)
+{
+    // Only offer crossing can fail here (payment checkCreateMPT is a no-op),
+    // via the unreachable path excluded in checkCreateMPT() above.
+    if (auto const err = static_cast(this)->checkCreateMPT(view); !isTesSuccess(err))
+        return err;  // LCOV_EXCL_LINE
+
+    return directSendNoFee(
+        view,
+        src,
+        dst,
+        toSTAmount(amount, mptIssue_),
+        /*checkIssuer*/ false,
+        j_);
+}
+
+//------------------------------------------------------------------------------
+
 template 
 std::pair
 MPTEndpointStep::maxPaymentFlow(ReadView const& sb) const
@@ -479,8 +515,6 @@ MPTEndpointStep::revImp(
     auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Reverse);
     (void)dstQIn;
 
-    MPTIssue const srcToDstIss(mptIssue_);
-
     JLOG(j_.trace()) << "MPTEndpointStep::rev"
                      << " srcRedeems: " << redeems(srcDebtDir) << " outReq: " << to_string(out)
                      << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut
@@ -493,59 +527,41 @@ MPTEndpointStep::revImp(
         return {beast::kZero, beast::kZero};
     }
 
-    if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir);
-        !isTesSuccess(err))
-        return {beast::kZero, beast::kZero};
+    // When a previous step feeds this issuing step, srcQOut is the issuer's
+    // transfer rate and maxPaymentFlow() returns the issuance maximum rather
+    // than a real limit, so srcToDst * srcQOut need not be representable. Cap
+    // srcToDst at the largest amount whose input is; the previous step then
+    // limits the flow to what the source actually holds.
+    MPTAmount const maxRepresentable =
+        mulRatio(MPTAmount(kMaxMpTokenAmount), QUALITY_ONE, srcQOut, /*roundUp*/ false);
 
     // Don't have to factor in dstQIn since it is always QUALITY_ONE
-    MPTAmount const srcToDst = out;
+    MPTAmount const srcToDst = std::min({out, maxSrcToDst, maxRepresentable});
 
-    if (srcToDst <= maxSrcToDst)
-    {
-        MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-        cache_.emplace(in, srcToDst, srcToDst, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
-        {
-            JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter;
-            resetCache(srcDebtDir);
-            return {beast::kZero, beast::kZero};
-        }
-        JLOG(j_.trace()) << "MPTEndpointStep::rev: Non-limiting"
-                         << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
-                         << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
-        return {in, out};
-    }
+    // Can't overflow: srcToDst <= kMaxMpTokenAmount * QUALITY_ONE / srcQOut,
+    // so the rounded up product is at most kMaxMpTokenAmount.
+    MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
 
-    // limiting node
-    MPTAmount const in = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-    // Don't have to factor in dsqQIn since it's always QUALITY_ONE
-    MPTAmount const actualOut = maxSrcToDst;
-    cache_.emplace(in, maxSrcToDst, actualOut, srcDebtDir);
+    cache_.emplace(in, srcToDst, srcToDst, srcDebtDir);
 
-    auto const ter = directSendNoFee(
-        sb,
-        src_,
-        dst_,
-        toSTAmount(maxSrcToDst, srcToDstIss),
-        /*checkIssuer*/ false,
-        j_);
+    auto const ter = sendWithMPTCreate(sb, src_, dst_, srcToDst);
     if (!isTesSuccess(ter))
     {
+        // Unreachable: send fails only on funds/auth/overflow, precluded by
+        // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::revImp : send failed");
         JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter;
         resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
     }
-    JLOG(j_.trace()) << "MPTEndpointStep::rev: Limiting"
+
+    JLOG(j_.trace()) << "MPTEndpointStep::rev: " << (srcToDst < out ? "Limiting" : "Non-limiting")
                      << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
-                     << " srcToDst: " << to_string(maxSrcToDst) << " out: " << to_string(out);
-    return {in, actualOut};
+                     << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
+
+    return {in, srcToDst};
 }
 
 // The forward pass should never have more liquidity than the reverse
@@ -610,8 +626,6 @@ MPTEndpointStep::fwdImp(
     auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Forward);
     (void)dstQIn;
 
-    MPTIssue const srcToDstIss(mptIssue_);
-
     JLOG(j_.trace()) << "MPTEndpointStep::fwd"
                      << " srcRedeems: " << redeems(srcDebtDir) << " inReq: " << to_string(in)
                      << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut
@@ -619,63 +633,81 @@ MPTEndpointStep::fwdImp(
 
     if (maxSrcToDst.signum() <= 0)
     {
+        // Unreachable: the reverse pass owns dry detection; every path that
+        // reaches fwdImp (see StrandFlow::flow) has a funded source.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : dry source");
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: dry";
         resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
     }
 
-    if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir);
-        !isTesSuccess(err))
+    auto const maybeSrcToDst = tryMulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false);
+    if (!maybeSrcToDst)
+    {
+        // Unreachable: divides by srcQOut >= QUALITY_ONE, so result <= in <=
+        // maxMPTAmount and can never overflow int64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : source to destination overflow");
+        JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow";
+        resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
+    }
 
-    MPTAmount const srcToDst = mulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false);
+    MPTAmount const srcToDst = *maybeSrcToDst;
 
     if (srcToDst <= maxSrcToDst)
     {
         // Don't have to factor in dstQIn since it's always QUALITY_ONE
         MPTAmount const out = srcToDst;
         setCacheLimiting(in, srcToDst, out, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(cache_->srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
-        {
-            JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
-            resetCache(srcDebtDir);
-            return {beast::kZero, beast::kZero};
-        }
+
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: Non-limiting"
                          << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
                          << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
     }
     else
     {
+        // Unreachable: the reverse pass owns all limiting; the forward driver
+        // (StrandFlow::flow) never re-finds a limit, so srcToDst <= maxSrcToDst.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : forward pass limiting");
         // limiting node
-        MPTAmount const actualIn = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-        // Don't have to factor in dstQIn since it's always QUALITY_ONE
-        MPTAmount const out = maxSrcToDst;
-        setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(cache_->srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
+        auto const maybeActualIn = tryMulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
+        if (!maybeActualIn)
         {
-            JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
+            JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow";
             resetCache(srcDebtDir);
             return {beast::kZero, beast::kZero};
         }
+
+        MPTAmount const actualIn = *maybeActualIn;
+
+        // Don't have to factor in dstQIn since it's always QUALITY_ONE
+        MPTAmount const out = maxSrcToDst;
+        setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir);
+
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: Limiting"
                          << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(actualIn)
                          << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
+        // LCOV_EXCL_STOP
     }
+
+    auto const ter = sendWithMPTCreate(sb, src_, dst_, cache_->srcToDst);
+    if (!isTesSuccess(ter))
+    {
+        // Unreachable: send fails only on funds/auth/overflow, precluded by
+        // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : send failed");
+        JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
+        resetCache(srcDebtDir);
+        return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
+    }
+
     return {cache_->in, cache_->out};
     // NOLINTEND(bugprone-unchecked-optional-access)
 }
diff --git a/src/libxrpl/tx/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 c6ece11aed..bf36ff39ad 100644
--- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp
+++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
@@ -51,7 +51,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/AMMBid.cpp b/src/libxrpl/tx/transactors/dex/AMMBid.cpp
index 3454559e82..154e64ca8e 100644
--- a/src/libxrpl/tx/transactors/dex/AMMBid.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMBid.cpp
@@ -193,10 +193,10 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa
     auto const current =
         duration_cast(ctx.view().header().parentCloseTime.time_since_epoch()).count();
     // Auction slot discounted fee
-    auto const discountedFee = (*ammSle)[sfTradingFee] / kAuctionSlotDiscountedFeeFraction;
-    auto const tradingFee = getFee((*ammSle)[sfTradingFee]);
+    auto const ammTradingFee = (*ammSle)[sfTradingFee];
+    auto const discountedFee = ammTradingFee / kAuctionSlotDiscountedFeeFraction;
     // Min price
-    auto const minSlotPrice = lptAMMBalance * tradingFee / kAuctionSlotMinFeeFraction;
+    auto const minSlotPrice = ammAuctionMinSlotPrice(lptAMMBalance, ammTradingFee);
 
     static constexpr std::uint32_t kTailingSlot = kAuctionSlotTimeIntervals - 1;
 
@@ -260,31 +260,37 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa
     auto const bidMax = ctx.tx[~sfBidMax];
 
     auto getPayPrice = [&](Number const& computedPrice) -> std::expected {
+        auto effectivePrice = computedPrice;
+        if (ctx.view().rules().enabled(fixCleanup3_4_0) && ammTradingFee == 0)
+        {
+            // Prevent zero-fee pools from granting auction slots at zero or dust prices.
+            effectivePrice = std::max(effectivePrice, ammAuctionMinSlotPrice(lptAMMBalance, 1));
+        }
         auto const payPrice = [&]() -> std::optional {
             // Both min/max bid price are defined
             if (bidMin && bidMax)
             {
-                if (computedPrice <= *bidMax)
-                    return std::max(computedPrice, Number(*bidMin));
-                JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << computedPrice << " "
+                if (effectivePrice <= *bidMax)
+                    return std::max(effectivePrice, Number(*bidMin));
+                JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << effectivePrice << " "
                                           << *bidMin << " " << *bidMax;
                 return std::nullopt;
             }
-            // Bidder pays max(bidPrice, computedPrice)
+            // Bidder pays max(bidPrice, effectivePrice)
             if (bidMin)
             {
-                return std::max(computedPrice, Number(*bidMin));
+                return std::max(effectivePrice, Number(*bidMin));
             }
             if (bidMax)
             {
-                if (computedPrice <= *bidMax)
-                    return computedPrice;
+                if (effectivePrice <= *bidMax)
+                    return effectivePrice;
                 JLOG(ctx.journal.debug())
-                    << "AMM Bid: not in range " << computedPrice << " " << *bidMax;
+                    << "AMM Bid: not in range " << effectivePrice << " " << *bidMax;
                 return std::nullopt;
             }
 
-            return computedPrice;
+            return effectivePrice;
         }();
         if (!payPrice)
         {
diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
index c1ef9f875e..f95f257ab6 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,
@@ -236,6 +237,7 @@ AMMClawback::applyGuts(Sandbox& sb)
                 0,
                 FreezeHandling::IgnoreFreeze,
                 AuthHandling::IgnoreAuth,
+                ReserveHandling::IgnoreReserve,
                 WithdrawAll::Yes,
                 preFeeBalance_,
                 ctx_.journal);
@@ -256,7 +258,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,19 +313,30 @@ 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;
 
     auto const lpTokensWithdraw = toSTAmount(lptAMMBalance.asset(), lptAMMBalance * frac);
-    if (lpTokensWithdraw > holdLPtokens)
+    auto const& rules = sb.rules();
+    // Pre-fixCleanup3_4_0 only a strictly greater computed LP amount takes
+    // the withdraw-all path. Equality left the last holder unable to be
+    // fully clawed. The amendment treats equality as withdraw-all.
+    if (rules.enabled(fixCleanup3_4_0) ? lpTokensWithdraw >= holdLPtokens
+                                       : lpTokensWithdraw > holdLPtokens)
     {
-        // if lptoken balance less than what the issuer intended to clawback,
-        // clawback all the tokens. Because we are doing a two-asset withdrawal,
-        // tfee is actually not used, so pass tfee as 0.
         return AMMWithdraw::equalWithdrawTokens(
             sb,
             ammSle,
             holder,
+            issuer,
             ammAccount,
             amountBalance,
             amount2Balance,
@@ -333,12 +346,12 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             0,
             FreezeHandling::IgnoreFreeze,
             AuthHandling::IgnoreAuth,
+            ReserveHandling::IgnoreReserve,
             WithdrawAll::Yes,
             preFeeBalance_,
             ctx_.journal);
     }
 
-    auto const& rules = sb.rules();
     if (rules.enabled(fixAMMClawbackRounding))
     {
         auto tokensAdj = getRoundedLPTokens(rules, lptAMMBalance, frac, IsDeposit::No);
@@ -353,10 +366,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,
@@ -366,6 +387,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             0,
             FreezeHandling::IgnoreFreeze,
             AuthHandling::IgnoreAuth,
+            ReserveHandling::IgnoreReserve,
             WithdrawAll::No,
             preFeeBalance_,
             ctx_.journal);
@@ -377,6 +399,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
         sb,
         ammSle,
         ammAccount,
+        issuer,
         holder,
         amountBalance,
         amount,
@@ -386,6 +409,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
         0,
         FreezeHandling::IgnoreFreeze,
         AuthHandling::IgnoreAuth,
+        ReserveHandling::IgnoreReserve,
         WithdrawAll::No,
         preFeeBalance_,
         ctx_.journal);
diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
index 5294dd0c7f..77b9071cf8 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,
@@ -525,6 +527,7 @@ AMMWithdraw::withdraw(
         tfee,
         issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
+        ReserveHandling::EnforceReserve,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
         j_);
@@ -536,6 +539,7 @@ AMMWithdraw::withdraw(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const& ammAccount,
+    std::optional const& clawbackIssuer,
     AccountID const& account,
     STAmount const& amountBalance,
     STAmount const& amountWithdraw,
@@ -545,6 +549,7 @@ AMMWithdraw::withdraw(
     std::uint16_t tfee,
     FreezeHandling freezeHandling,
     AuthHandling authHandling,
+    ReserveHandling reserveHandling,
     WithdrawAll withdrawAll,
     XRPAmount const& priorBalance,
     beast::Journal const& journal)
@@ -666,19 +671,26 @@ AMMWithdraw::withdraw(
         mptokenKey = std::nullopt;
         if (!enabledFixAmMv12 || isXRP(asset))
             return tesSUCCESS;
-        bool const isIssue = asset.holds();
-        bool const assetNotExists = [&] {
-            if (isIssue)
-                return !view.exists(keylet::trustLine(account, asset.get()));
-            auto const issuanceKey = keylet::mptokenIssuance(asset.get());
-            mptokenKey = keylet::mptoken(issuanceKey.key, account);
-            if (!view.exists(*mptokenKey))
-                return true;
-            mptokenKey = std::nullopt;
-            return false;
-        }();
+        bool const assetNotExists = asset.visit(
+            [&](Issue const& issue) { return !view.exists(keylet::trustLine(account, issue)); },
+            [&](MPTIssue const& issue) {
+                auto const issuanceKey = keylet::mptokenIssuance(issue);
+                mptokenKey = keylet::mptoken(issuanceKey.key, account);
+                if (!view.exists(*mptokenKey))
+                    return true;
+                mptokenKey = std::nullopt;
+                return false;
+            });
         if (assetNotExists)
         {
+            // Intentionally ignore the reserve check for AMMClawback, so the
+            // holder can not avoid clawback by deleting the trustline/MPToken
+            // and keeping a low spendable balance. AMMClawback has a higher
+            // priority than the reserve check.
+            if (view.rules().enabled(fixCleanup3_4_0) &&
+                reserveHandling == ReserveHandling::IgnoreReserve)
+                return tesSUCCESS;
+
             auto sleAccount = view.peek(keylet::account(account));
             if (!sleAccount)
                 return tecINTERNAL;  // LCOV_EXCL_LINE
@@ -690,7 +702,7 @@ AMMWithdraw::withdraw(
                     ? XRPAmount(beast::kZero)
                     : accountReserve(view, sleAccount, journal, {.ownerCountDelta = 1}));
 
-            auto const balanceAdj = isIssue ? std::max(priorBalance, balance) : priorBalance;
+            auto const balanceAdj = std::max(priorBalance, balance);
             if (balanceAdj < reserve)
                 return tecINSUFFICIENT_RESERVE;
         }
@@ -703,14 +715,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 +850,7 @@ AMMWithdraw::equalWithdrawTokens(
         view,
         ammSle,
         accountID_,
+        std::nullopt,
         ammAccount,
         amountBalance,
         amount2Balance,
@@ -813,6 +860,7 @@ AMMWithdraw::equalWithdrawTokens(
         tfee,
         issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
+        ReserveHandling::EnforceReserve,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
         ctx_.journal);
@@ -856,6 +904,7 @@ AMMWithdraw::equalWithdrawTokens(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const account,
+    std::optional const& clawbackIssuer,
     AccountID const& ammAccount,
     STAmount const& amountBalance,
     STAmount const& amount2Balance,
@@ -865,6 +914,7 @@ AMMWithdraw::equalWithdrawTokens(
     std::uint16_t tfee,
     FreezeHandling freezeHandling,
     AuthHandling authHandling,
+    ReserveHandling reserveHandling,
     WithdrawAll withdrawAll,
     XRPAmount const& priorBalance,
     beast::Journal const& journal)
@@ -878,6 +928,7 @@ AMMWithdraw::equalWithdrawTokens(
                 view,
                 ammSle,
                 ammAccount,
+                clawbackIssuer,
                 account,
                 amountBalance,
                 amountBalance,
@@ -887,6 +938,7 @@ AMMWithdraw::equalWithdrawTokens(
                 tfee,
                 freezeHandling,
                 authHandling,
+                reserveHandling,
                 WithdrawAll::Yes,
                 priorBalance,
                 journal);
@@ -913,6 +965,7 @@ AMMWithdraw::equalWithdrawTokens(
             view,
             ammSle,
             ammAccount,
+            clawbackIssuer,
             account,
             amountBalance,
             amountWithdraw,
@@ -922,6 +975,7 @@ AMMWithdraw::equalWithdrawTokens(
             tfee,
             freezeHandling,
             authHandling,
+            reserveHandling,
             withdrawAll,
             priorBalance,
             journal);
diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
index 0492f9c062..57ba6eff0d 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
@@ -11,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -242,8 +243,31 @@ OfferCreate::preclaim(PreclaimContext const& ctx)
     // is part of the domain
     if (ctx.tx.isFieldPresent(sfDomainID))
     {
-        if (!permissioned_dex::accountInDomain(ctx.view, id, ctx.tx[sfDomainID]))
-            return tecNO_PERMISSION;
+        if (ctx.view.rules().enabled(fixCleanup3_4_0))
+        {
+            auto const domainID = ctx.tx[sfDomainID];
+            auto const sleDomain = ctx.view.read(keylet::permissionedDomain(domainID));
+            if (!sleDomain)
+                return tecNO_PERMISSION;
+
+            // Domain owner is always considered in the domain, no credential check
+            // needed. For all other accounts, use validDomain which detects expired
+            // credentials. Suppress tecEXPIRED here so doApply can run and delete
+            // the expired credential SLEs from the ledger.
+            if (sleDomain->getAccountID(sfOwner) != id)
+            {
+                // validDomain returns tecNO_AUTH when no matching credential is
+                // found. Map it to tecNO_PERMISSION to preserve existing behavior.
+                if (auto const err = credentials::validDomain(ctx.view, domainID, id);
+                    !isTesSuccess(err) && err != tecEXPIRED)
+                    return tecNO_PERMISSION;
+            }
+        }
+        else
+        {
+            if (!permissioned_dex::accountInDomain(ctx.view, id, ctx.tx[sfDomainID]))
+                return tecNO_PERMISSION;
+        }
     }
 
     if (auto const ter = canTrade(ctx.view, saTakerPays.asset()); !isTesSuccess(ter))
@@ -672,6 +696,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     }
 
     bool crossed = false;
+    bool const mptV2 = ctx_.view().rules().enabled(featureMPTokensV2);
 
     if (isTesSuccess(result))
     {
@@ -694,7 +719,12 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
             if (sle && sle->isFieldPresent(sfTickSize))
                 uTickSize = std::min(uTickSize, (*sle)[sfTickSize]);
         }
-        if (uTickSize < Quality::kMaxTickSize)
+        // Quality's ctor is the same getRate() call that produced uRate, and
+        // round() maps zero to zero, so an unrepresentable quality would make
+        // divide() below throw (tefEXCEPTION). Skip the rounding instead: the
+        // offer still crosses, and any residual is stopped before placement.
+        bool const unrepresentableRate = mptV2 && uRate == 0;
+        if (uTickSize < Quality::kMaxTickSize && !unrepresentableRate)
         {
             auto const rate = Quality{saTakerGets, saTakerPays}.round(uTickSize).rate();
 
@@ -841,6 +871,20 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
         return {tesSUCCESS, true};
     }
 
+    // The remainder rests at uRate, the original pre-crossing rate. A zero
+    // rate (quality not representable) puts it in the directory whose index
+    // equals getBookBase(book), and BookTip scans keys strictly greater, so it
+    // could never be crossed while holding the owner's reserve. Don't place
+    // it; anything that crossed is kept, and a fully crossed offer has already
+    // returned above. Gated to preserve pre-amendment behavior.
+    if (mptV2 && uRate == 0)
+    {
+        JLOG(j_.debug()) << "Unrepresentable quality: remainder not placed";
+        if (!crossed)
+            return {tecKILLED, false};
+        return {tesSUCCESS, true};
+    }
+
     auto const sleCreator = sb.peek(keylet::account(accountID_));
     if (!sleCreator)
         return {tefINTERNAL, false};
@@ -980,6 +1024,27 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
 TER
 OfferCreate::doApply()
 {
+    // If a DomainID is present, verify the account is still in the domain and
+    // delete any expired credential SLEs. This must happen before the Sandboxes
+    // are created: if we return a tec error, the engine applies sbCancel (not
+    // sb) to rawView, so deletions made inside sb would be lost. Deletions made
+    // directly to ctx_.view() here are preserved regardless of which branch
+    // applyGuts takes.
+    if (ctx_.tx.isFieldPresent(sfDomainID) && ctx_.view().rules().enabled(fixCleanup3_4_0))
+    {
+        auto const domainID = ctx_.tx[sfDomainID];
+        auto const sleDomain = ctx_.view().read(keylet::permissionedDomain(domainID));
+        if (!sleDomain)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        if (sleDomain->getAccountID(sfOwner) != accountID_)
+        {
+            if (auto const err = verifyValidDomain(ctx_.view(), accountID_, domainID, j_);
+                !isTesSuccess(err))
+                return err;
+        }
+    }
+
     // This is the ledger view that we work against. Transactions are applied
     // as we go on processing transactions.
     Sandbox sb(&ctx_.view());
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
index b1cfcb3df1..cb396be4d8 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
@@ -169,6 +169,13 @@ EscrowCancel::doApply()
     auto const sle = ctx_.view().peek(keylet::account(account));
     STAmount const amount = slep->getFieldAmount(sfAmount);
 
+    // The return can re-create a holding the owner deleted while the escrow
+    // was pending; the removed escrow must not be counted against its reserve.
+    bool const recycleReserve = ctx_.view().rules().enabled(fixCleanup3_4_0);
+    auto const reserveToSubtract = calculateAdditionalReserve((*slep)[~sfBytecode]);
+    if (recycleReserve)
+        decreaseOwnerCountForObject(ctx_.view(), sle, slep, reserveToSubtract, ctx_.journal);
+
     // Transfer amount back to the owner
     if (isXRP(amount))
     {
@@ -212,8 +219,8 @@ EscrowCancel::doApply()
         }
     }
 
-    auto const reserveToSubtract = calculateAdditionalReserve((*slep)[~sfBytecode]);
-    decreaseOwnerCountForObject(ctx_.view(), sle, slep, reserveToSubtract, ctx_.journal);
+    if (!recycleReserve)
+        decreaseOwnerCountForObject(ctx_.view(), sle, slep, reserveToSubtract, ctx_.journal);
 
     // Remove escrow from ledger
     ctx_.view().erase(slep);
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
index a117f79b6b..169f99b25b 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
@@ -387,11 +387,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 8e8ff24e69..40fd59314c 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
@@ -151,6 +151,9 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -231,7 +234,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;
@@ -492,14 +495,12 @@ EscrowFinish::doApply()
 
     auto const reserveToSubtract = calculateAdditionalReserve((*slep)[~sfBytecode]);
 
-    // With the Sponsor amendment, release the escrow reserve before delivery.
-    // Token delivery can auto-create a destination holding, and the same
-    // sponsor (or the same account, for a self-escrow) may cover both the
-    // escrow being removed and the holding being created. Without the
-    // amendment, keep the legacy order: releasing early changes the reserve
-    // arithmetic for self-escrows and would break consensus if not gated.
-    bool const sponsorEnabled = ctx_.view().rules().enabled(featureSponsor);
-    if (sponsorEnabled)
+    // Delivery can auto-create the destination's holding; the removed escrow
+    // must not be counted against its reserve. The two share a reserve payer
+    // for a self-escrow, or when one sponsor covers both.
+    bool const recycleReserve =
+        ctx_.view().rules().enabled(featureSponsor) || ctx_.view().rules().enabled(fixCleanup3_4_0);
+    if (recycleReserve)
         decreaseOwnerCountForObject(ctx_.view(), account, slep, reserveToSubtract, ctx_.journal);
 
     STAmount const amount = slep->getFieldAmount(sfAmount);
@@ -551,8 +552,7 @@ EscrowFinish::doApply()
 
     ctx_.view().update(sled);
 
-    // Adjust source owner count (legacy position, pre-Sponsor)
-    if (!sponsorEnabled)
+    if (!recycleReserve)
         decreaseOwnerCountForObject(ctx_.view(), account, slep, reserveToSubtract, ctx_.journal);
 
     // Remove escrow from ledger
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
index 498f3c99eb..88b6f8c38b 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -25,7 +26,11 @@ namespace xrpl {
 bool
 LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx)
 {
-    return checkLendingProtocolDependencies(ctx.rules, ctx.tx);
+    if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx))
+        return false;
+
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) ||
+        (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0));
 }
 
 NotTEC
@@ -49,6 +54,9 @@ LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -57,6 +65,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
 {
     auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
     auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
+    auto const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0);
     auto const& tx = ctx.tx;
 
     auto const account = tx[sfAccount];
@@ -109,6 +118,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct, waive))
         return ret;
 
+    // Validate credentials (if any) before canWithdraw, since canWithdraw may
+    // call credentials::authorizedDepositPreauth which assumes credentials
+    // already exist.
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err))
+        return err;
+
     // Withdrawal to a 3rd party destination account is essentially a transfer.
     // Enforce all the usual asset transfer checks.
     AuthType authType = AuthType::WeakAuth;
@@ -126,6 +141,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType))
         return ter;
 
+    if (fix340Enabled && account == dstAcct && !holdingExists(ctx.view, dstAcct, vaultAsset))
+    {
+        if (auto const ter = canAddHolding(ctx.view, vaultAsset); !isTesSuccess(ter))
+            return ter;
+    }
+
     if (fix330Enabled)
     {
         if (auto const ret =
diff --git a/src/libxrpl/tx/transactors/lending/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/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
index d6cda9c326..1ab4eb2ce0 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -8,7 +8,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,6 +146,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
     }
     else
     {
+        // LP V1.1: only closed-ended vaults may host a loan broker. The
+        // lending protocol relies on the closed-ended Subscription /
+        // Investment / Redemption phase structure; attaching a broker to
+        // an open-ended vault has no well-defined lifecycle. VaultCreate
+        // stays unrestricted so existing open-ended flows keep working;
+        // the constraint is enforced here, at the point where the vault
+        // is first bound to the lending protocol.
+        if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
+            getVaultKind(sleVault) != VaultKind::ClosedEnded)
+        {
+            JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault.";
+            return tecNO_PERMISSION;
+        }
+
         if (auto const ter = canAddHolding(ctx.view, asset))
             return ter;
 
diff --git a/src/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/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
index a312dba3b3..2d710ceebe 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -104,7 +104,11 @@ LoanManage::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
     if (tx.isFlag(tfLoanDefault) &&
-        !hasExpired(ctx.view, loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod)))
+        !hasExpired(
+            ctx.view,
+            loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod),
+            ctx.view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                                      : ExpiryComparison::Inclusive))
     {
         JLOG(ctx.j.warn()) << "A loan can not be defaulted before the next payment due date.";
         return tecTOO_SOON;
@@ -287,6 +291,14 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
+    bool const fixEnabled340 = view.rules().enabled(fixCleanup3_4_0);
+
+    if (fixEnabled340 && !isPaymentLate(view, loanSle))
+    {
+        JLOG(j.warn()) << "Cannot impair a loan that is not late";
+        return tecTOO_SOON;
+    }
+
     Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
 
     // The vault may be at a different scale than the loan. Reduce rounding
@@ -301,20 +313,22 @@ LoanManage::impairLoan(
     {
         // Having a loss greater than the vault's unavailable assets
         // will leave the vault in an invalid / inconsistent state.
-        JLOG(j.warn()) << "Vault unrealized loss is too large, and will "
-                          "corrupt the vault.";
+        JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
         return tecLIMIT_EXCEEDED;
     }
     view.update(vaultSle);
 
     // Update the Loan object
     loanSle->setFlag(lsfLoanImpaired);
-    auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
-    if (!hasExpired(view, loanNextDueProxy))
+
+    if (!fixEnabled340)
     {
-        // loan payment is not yet late -
-        // move the next payment due date to now
-        loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
+        if (!isPaymentLate(view, loanSle))
+        {
+            // loan payment is not yet late move the next payment due date to now
+            loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        }
     }
     view.update(loanSle);
 
@@ -351,19 +365,24 @@ LoanManage::unimpairLoan(
 
     // Update the Loan object
     loanSle->clearFlag(lsfLoanImpaired);
-    auto const paymentInterval = loanSle->at(sfPaymentInterval);
-    auto const normalPaymentDueDate =
-        std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval;
-    if (!hasExpired(view, normalPaymentDueDate))
+    if (!view.rules().enabled(fixCleanup3_4_0))
     {
-        // loan was unimpaired within the payment interval
-        loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
-    }
-    else
-    {
-        // loan was unimpaired after the original payment due date
-        loanSle->at(sfNextPaymentDueDate) =
-            view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        auto const paymentInterval = loanSle->at(sfPaymentInterval);
+        auto const normalPaymentDueDate =
+            std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) +
+            paymentInterval;
+
+        if (!hasExpired(view, normalPaymentDueDate))
+        {
+            // loan was unimpaired within the payment interval
+            loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
+        }
+        else
+        {
+            // loan was unimpaired after the original payment due date
+            loanSle->at(sfNextPaymentDueDate) =
+                view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        }
     }
     view.update(loanSle);
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index 4619540295..18886b2682 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -2,13 +2,15 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +35,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)
 {
@@ -103,10 +133,13 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
         return normalCost;
     }
 
-    if (hasExpired(view, loanSle->at(sfNextPaymentDueDate)))
+    if (isPaymentLate(view, loanSle))
     {
         // If the payment is late, and the late payment flag is not set, it'll
-        // fail
+        // fail. Uses isPaymentLate() so the fee matches apply at the exact
+        // NextPaymentDueDate boundary (Exclusive once fixCleanup3_4_0 is
+        // enabled): a catch-up at that instant can still process up to
+        // kLoanMaximumPaymentsPerTransaction payments.
         return normalCost;
     }
 
@@ -581,36 +614,20 @@ 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)
+    // Only ledgers without the rule below reach these payee checks. Once it is in force
+    // requireAuth can no longer reject a pseudo-account, so the whole block goes away with the
+    // gate.
+    bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0);
+
+    if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero)
     {
         if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
             return ter;
@@ -634,8 +651,11 @@ LoanPay::doApply()
                 return ter;
             }
         }
-        if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
-            return ter;
+        if (!skipPayeeAuth)
+        {
+            if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
+                return ter;
+        }
     }
 
     if (auto const ter = accountSendMulti(
@@ -664,33 +684,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..f7b97dfedf 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 
@@ -39,6 +40,12 @@
 
 namespace xrpl {
 
+// StartDate is strictly after SubscriptionDate. A min-gap vault must still
+// fit a minimum-interval loan plus kLoanRedemptionBuffer. The interval and
+// buffer constants are independent; only their sum (plus the +1 for a
+// strictly-later StartDate) is required to fit in kMinInvestmentPeriod.
+static_assert(kMinInvestmentPeriod >= LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer + 1);
+
 bool
 LoanSet::checkExtraFeatures(PreflightContext const& ctx)
 {
@@ -225,6 +232,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 +247,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,7 +316,39 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     }
 
-    if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum))
+    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 + kLoanRedemptionBuffer > vault->at(sfRedemptionDate))
+            {
+                JLOG(ctx.j.warn())
+                    << "Final loan payment date is fewer than " << kLoanRedemptionBuffer
+                    << " seconds before the vault's redemption date.";
+                return tecNO_PERMISSION;
+            }
+        }
+    }
+
+    // Accrual origination credits interestDue into AssetsTotal, so a vault
+    // already at AssetsMaximum cannot take another loan. Cash-basis origination
+    // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so
+    // this leftover accrual gate must not apply there.
+    if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 &&
+        vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum))
     {
         JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan.";
         return tecLIMIT_EXCEEDED;
@@ -334,8 +372,24 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         }
     }
 
-    if (auto const ter = canAddHolding(ctx.view, asset))
-        return ter;
+    // canAddHolding is an issuer-level check (DefaultRipple for IOU,
+    // lsfMPTCanTransfer for MPT); neither overload looks at the
+    // destination, so the holdingExists() clauses only decide whether a
+    // create path is reachable at all. It always runs before
+    // fixCleanup3_4_0: IOU addEmptyHolding checks DefaultRipple ahead of
+    // the existing-line case, so only preclaim can turn an existing line
+    // under a cleared DefaultRipple into terNO_RIPPLE rather than
+    // tecINTERNAL. After the amendment an existing line short-circuits to
+    // tecDUPLICATE, which doApply ignores, so run the check only when the
+    // borrower lacks a holding, or the origination fee is nonzero and the
+    // broker owner lacks one.
+    auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{});
+    if (!ctx.view.rules().enabled(fixCleanup3_4_0) || !holdingExists(ctx.view, borrower, asset) ||
+        (originationFee != beast::kZero && !holdingExists(ctx.view, brokerOwner, asset)))
+    {
+        if (auto const ter = canAddHolding(ctx.view, asset))
+            return ter;
+    }
 
     // vaultPseudo is going to send funds, so it can't be frozen.
     if (auto const ret = checkFrozen(ctx.view, vaultPseudo, asset))
@@ -441,9 +495,11 @@ LoanSet::doApply()
         properties.loanState.managementFeeDue);
 
     XRPL_ASSERT_PARTS(
-        *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
+        *vaultSle->at(sfAssetsMaximum) == 0 ||
+            getVaultVersion(vaultSle) == VaultVersion::CashBasis ||
+            *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
         "xrpl::LoanSet::doApply",
-        "Vault is below maximum limit");
+        "accrual vault is below maximum limit");
 
     if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
     {
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
index 797d3dcbb3..dd8df0eedb 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 
@@ -48,6 +50,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..c4c2f9227b 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;
@@ -458,11 +458,41 @@ Payment::preclaim(PreclaimContext const& ctx)
 
     if (ctx.tx.isFieldPresent(sfDomainID))
     {
-        if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfAccount], ctx.tx[sfDomainID]))
-            return tecNO_PERMISSION;
+        if (ctx.view.rules().enabled(fixCleanup3_4_0))
+        {
+            auto const domainID = ctx.tx[sfDomainID];
+            auto const sleDomain = ctx.view.read(keylet::permissionedDomain(domainID));
+            if (!sleDomain)
+                return tecNO_PERMISSION;
 
-        if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfDestination], ctx.tx[sfDomainID]))
-            return tecNO_PERMISSION;
+            // Domain owner is always considered in the domain. For other accounts,
+            // suppress tecEXPIRED so doApply can run and delete expired credential
+            // SLEs from the ledger.
+            auto const checkAccount = [&](AccountID const& acct) -> TER {
+                if (sleDomain->getAccountID(sfOwner) == acct)
+                    return tesSUCCESS;
+                // validDomain returns tecNO_AUTH when no matching credential is
+                // found. Map it to tecNO_PERMISSION to preserve existing behavior.
+                if (auto const err = credentials::validDomain(ctx.view, domainID, acct);
+                    !isTesSuccess(err) && err != tecEXPIRED)
+                    return tecNO_PERMISSION;
+                return tesSUCCESS;
+            };
+
+            if (auto const err = checkAccount(ctx.tx[sfAccount]); !isTesSuccess(err))
+                return err;
+            if (auto const err = checkAccount(ctx.tx[sfDestination]); !isTesSuccess(err))
+                return err;
+        }
+        else
+        {
+            if (!permissioned_dex::accountInDomain(ctx.view, ctx.tx[sfAccount], ctx.tx[sfDomainID]))
+                return tecNO_PERMISSION;
+
+            if (!permissioned_dex::accountInDomain(
+                    ctx.view, ctx.tx[sfDestination], ctx.tx[sfDomainID]))
+                return tecNO_PERMISSION;
+        }
     }
 
     return tesSUCCESS;
@@ -471,6 +501,31 @@ Payment::preclaim(PreclaimContext const& ctx)
 TER
 Payment::doApply()
 {
+    // If a DomainID is present, verify both sender and destination are still in
+    // the domain and delete any expired credential SLEs from the ledger.
+    if (ctx_.tx.isFieldPresent(sfDomainID) && ctx_.view().rules().enabled(fixCleanup3_4_0))
+    {
+        auto const domainID = ctx_.tx[sfDomainID];
+        auto const sleDomain = ctx_.view().read(keylet::permissionedDomain(domainID));
+        if (!sleDomain)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        auto const cleanupFor = [&](AccountID const& acct) -> TER {
+            if (sleDomain->getAccountID(sfOwner) == acct)
+                return tesSUCCESS;
+            return verifyValidDomain(ctx_.view(), acct, domainID, j_);
+        };
+
+        auto const destination = ctx_.tx[sfDestination];
+        auto const senderErr = cleanupFor(accountID_);
+        auto const destinationErr = accountID_ == destination ? senderErr : cleanupFor(destination);
+
+        if (!isTesSuccess(senderErr))
+            return senderErr;
+        if (!isTesSuccess(destinationErr))
+            return destinationErr;
+    }
+
     auto const deliverMin = ctx_.tx[~sfDeliverMin];
 
     // Ripple if source or destination is non-native or if there are paths.
diff --git a/src/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..60b5c6d3af 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;
 
@@ -153,7 +150,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     // always authorized. No need to amendment gate since Vault and LoanBroker
     // can only be created if the Vault amendment is enabled; AMM with MPToken asset
     // can only be created if MPTokensV2 is enabled.
-    if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
+    if (isPseudoAccount(ctx.view, *holderID))
         return tecNO_PERMISSION;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
index e8fd2e22b6..6bd7140631 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
@@ -119,7 +119,15 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
     if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey))
         return temMALFORMED;
 
-    if (hasAuditorElGamalKey && !hasIssuerElGamalKey)
+    // Pre-ConfidentialMPTKeyRotation amendment, the auditor key could not be
+    // registered independently of the issuer key. The issuer could either:
+    // - Register only the issuer key (in which case an auditor key could not be added later), or
+    // - Register both the issuer and auditor keys simultaneously.
+    //
+    // Post-ConfidentialMPTKeyRotation amendment, the auditor key can be
+    // registered after the issuer key has already been registered.
+    if (hasAuditorElGamalKey && !hasIssuerElGamalKey &&
+        !ctx.rules.enabled(featureConfidentialMPTKeyRotation))
         return temMALFORMED;
 
     if (hasIssuerElGamalKey && !isValidCompressedECPoint(ctx.tx[sfIssuerEncryptionKey]))
@@ -219,18 +227,57 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
             return tecNO_PERMISSION;
     }
 
-    // cannot update issuer public key
-    if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
-        sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey))
-    {
-        return tecNO_PERMISSION;
-    }
+    // Updating an existing encryption key requires the
+    // ConfidentialMPTKeyRotation amendment.
+    bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialMPTKeyRotation);
 
-    // cannot update auditor public key
-    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
-        sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey))
+    bool const txHasIssuerKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey);
+    bool const txHasAuditorKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey);
+    bool const sleHasIssuerKey = sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey);
+    bool const sleHasAuditorKey = sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey);
+
+    if (canRotateKey)
     {
-        return tecNO_PERMISSION;  // LCOV_EXCL_LINE
+        // Post-ConfidentialMPTKeyRotation amendment, the encryption keys can be updated.
+        // A first-time auditor key registration requires an issuer key,
+        // either already on the issuance or set by the same transaction.
+        bool const registersAuditorKey = txHasAuditorKey && !sleHasAuditorKey;
+        bool const issuerKeyExists = sleHasIssuerKey || txHasIssuerKey;
+        if (registersAuditorKey && !issuerKeyExists)
+            return tecNO_PERMISSION;
+
+        // Rotating a key to its current value is not permitted: a key epoch
+        // increment must always correspond to an actual key change.
+        if (txHasIssuerKey && sleHasIssuerKey &&
+            ctx.tx[sfIssuerEncryptionKey] == (*sleMptIssuance)[sfIssuerEncryptionKey])
+            return tecDUPLICATE;
+
+        if (txHasAuditorKey && sleHasAuditorKey &&
+            ctx.tx[sfAuditorEncryptionKey] == (*sleMptIssuance)[sfAuditorEncryptionKey])
+            return tecDUPLICATE;
+
+        // Key epochs must never wrap. Epoch 0 serves as the sentinel for "never
+        // rotated." Holders' mirror epochs are checked against it for equality,
+        // so a wrap would cause stale mirror ciphertexts to appear valid instead
+        // of failing loudly.
+        if (txHasIssuerKey && sleHasIssuerKey &&
+            (*sleMptIssuance)[~sfIssuerKeyEpoch].value_or(0) == kMaxKeyEpoch)
+            return tecNO_PERMISSION;
+
+        if (txHasAuditorKey && sleHasAuditorKey &&
+            (*sleMptIssuance)[~sfAuditorKeyEpoch].value_or(0) == kMaxKeyEpoch)
+            return tecNO_PERMISSION;
+    }
+    else
+    {
+        // Pre-ConfidentialMPTKeyRotation amendment, the encryption keys can not be updated.
+        // cannot update issuer public key
+        if (txHasIssuerKey && sleHasIssuerKey)
+            return tecNO_PERMISSION;
+
+        // cannot update auditor public key
+        if (txHasAuditorKey && sleHasAuditorKey)
+            return tecNO_PERMISSION;  // LCOV_EXCL_LINE
     }
 
     auto const enablesConfidentialBalance =
@@ -241,25 +288,30 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
 
     // Encryption keys can only be set if confidential amounts are already
     // enabled on the issuance OR if the transaction is enabling it
-    if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
-        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance)
+    if (txHasIssuerKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) &&
+        !enablesConfidentialBalance)
     {
         return tecNO_PERMISSION;
     }
 
-    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
-        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance)
+    if (txHasAuditorKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) &&
+        !enablesConfidentialBalance)
     {
         return tecNO_PERMISSION;
     }
 
-    // cannot upload key if there's circulating supply of COA
-    if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) ||
-         ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) &&
-        (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
-    {
+    bool const hasConfidentialOA =
+        (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0;
+
+    // Pre-ConfidentialMPTKeyRotation amendment, keys cannot be uploaded while
+    // COA > 0. Post-amendment they can be uploaded even if COA > 0.
+    if (!canRotateKey && (txHasIssuerKey || txHasAuditorKey) && hasConfidentialOA)
         return tecNO_PERMISSION;  // LCOV_EXCL_LINE
-    }
+
+    // Enabling confidential balances when COA > 0 is not permitted, regardless of
+    // ConfidentialMPTKeyRotation.
+    if (enablesConfidentialBalance && hasConfidentialOA)
+        return tecNO_PERMISSION;
 
     return tesSUCCESS;
 }
@@ -377,25 +429,69 @@ MPTokenIssuanceSet::doApply()
         }
     }
 
-    if (auto const pubKey = ctx_.tx[~sfIssuerEncryptionKey])
-    {
-        // This is enforced in preflight.
+    // Sets an encryption key on the issuance. Overwriting an existing key
+    // (a rotation) increments the corresponding key epoch; a first-time
+    // registration leaves the epoch absent (epoch 0), matching issuances
+    // whose keys were registered before the ConfidentialMPTKeyRotation
+    // amendment.
+    bool const canRotateKey = view().rules().enabled(featureConfidentialMPTKeyRotation);
+    auto const setEncryptionKey = [&](SF_VL const& keyField, SF_UINT32 const& epochField) -> TER {
+        auto const pubKey = ctx_.tx[~keyField];
+        if (!pubKey)
+            return tesSUCCESS;
+
+        // This is enforced in preflight, which rejects a transaction carrying
+        // both sfHolder and an encryption key.
         XRPL_ASSERT(
             sle->getType() == ltMPTOKEN_ISSUANCE,
             "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
 
-        sle->setFieldVL(sfIssuerEncryptionKey, *pubKey);
-    }
+        // Add sanity check under the amendment ConfidentialMPTKeyRotation.
+        // Pre-confidentialMPTKeyRotation did not return tecINTERNAL so
+        // this should be under the amendment guard.
+        if (canRotateKey && sle->getType() != ltMPTOKEN_ISSUANCE)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
 
-    if (auto const pubKey = ctx_.tx[~sfAuditorEncryptionKey])
-    {
-        // This is enforced in preflight.
-        XRPL_ASSERT(
-            sle->getType() == ltMPTOKEN_ISSUANCE,
-            "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
+        // NOTE: presence must be checked before the key is overwritten below.
+        bool const isRotation = sle->isFieldPresent(keyField);
+        sle->setFieldVL(keyField, *pubKey);
 
-        sle->setFieldVL(sfAuditorEncryptionKey, *pubKey);
-    }
+        if (isRotation)
+        {
+            // Preclaim rejects overwriting an existing key unless the amendment is
+            // enabled.
+            if (!canRotateKey)
+            {
+                // LCOV_EXCL_START
+                UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : rotation without amendment");
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
+
+            auto const epoch = (*sle)[~epochField].valueOr(0);
+
+            // Preclaim rejects a rotation that would wrap the epoch. So this should never happen.
+            if (epoch >= kMaxKeyEpoch)
+            {
+                // LCOV_EXCL_START
+                UNREACHABLE("xrpl::MPTokenIssuanceSet::doApply : key epoch overflow");
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
+
+            (*sle)[epochField] = epoch + 1;
+        }
+
+        return tesSUCCESS;
+    };
+
+    if (auto const ter = setEncryptionKey(sfIssuerEncryptionKey, sfIssuerKeyEpoch);
+        !isTesSuccess(ter))
+        return ter;  // LCOV_EXCL_LINE
+
+    if (auto const ter = setEncryptionKey(sfAuditorEncryptionKey, sfAuditorKeyEpoch);
+        !isTesSuccess(ter))
+        return ter;  // LCOV_EXCL_LINE
 
     view().update(sle);
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index d77286b667..059da7cc0f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -95,6 +96,17 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // A pseudo-account holds no vault shares, so a clawback naming one is a no-op: the vault's own
+    // pseudo-account issues the shares, and no flow hands them to another one.
+    // Pre-fixCleanup3_4_0: an implicit amount ends in tecPRECISION_LOSS, an explicit one debits the
+    // vault and trips the "shares must move" invariant.
+    // Post-fixCleanup3_4_0: refused here.
+    if (ctx.view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(ctx.view, holder))
+    {
+        JLOG(ctx.j.debug()) << "VaultClawback: holder is a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     Asset const share = MPTIssue{mptIssuanceID};
 
     // Ambiguous case: If Issuer is Owner they must specify the asset
@@ -225,6 +237,7 @@ VaultClawback::assetsToClawback(
     AccountID const& holder,
     STAmount const& clawbackAmount)
 {
+    bool const fix340Enabled = ctx_.view().rules().enabled(fixCleanup3_4_0);
     if (clawbackAmount.asset() != vault->at(sfAsset))
     {
         // preclaim should have blocked this , now it's an internal error
@@ -256,14 +269,35 @@ VaultClawback::assetsToClawback(
     STAmount sharesDestroyed;
     STAmount assetsRecovered;
 
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
+        // Do not discount a sole holder's shares: clawing back AssetsAvailable
+        // at the discounted rate can burn every share while loan assets remain.
+        auto const waiveUnrealizedLoss =
+            fix340Enabled && isSoleShareholder(view(), holder, sleShareIssuance)
+            ? WaiveUnrealizedLoss::Yes
+            : WaiveUnrealizedLoss::No;
+
         if (clawbackAmount == beast::kZero)
         {
-            sharesDestroyed = accountHolds(
-                view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            // Zero amount means clawback all shares the holder has; derive the corresponding asset
+            // amount from the share balance.
+            // isSoleShareholder already established that the holder owns the
+            // entire outstanding share supply whenever the waiver applies, so
+            // sfOutstandingAmount gives sharesDestroyed directly, avoiding a
+            // redundant MPToken read via accountHolds.
+            sharesDestroyed = waiveUnrealizedLoss == WaiveUnrealizedLoss::Yes
+                ? STAmount{share, sleShareIssuance->at(sfOutstandingAmount)}
+                : accountHolds(
+                      view(),
+                      holder,
+                      share,
+                      FreezeHandling::IgnoreFreeze,
+                      AuthHandling::IgnoreAuth,
+                      j_);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 
@@ -271,38 +305,48 @@ VaultClawback::assetsToClawback(
         }
         else
         {
-            auto const maybeShares =
-                assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
+            // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
+            // round-trip back to assets could exceed clawbackAmount.
+            // Post-amendment: truncate shares so assetsRecovered <=
+            // clawbackAmount by construction (matches the clamp branch
+            // below).
+            auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No;
+            auto const maybeShares = assetsToSharesWithdraw(
+                vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
             if (!maybeShares)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             sharesDestroyed = *maybeShares;
 
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
         }
-        // Clamp to maximum.
+        // Clamp assetsRecovered to sfAssetsAvailable, then re-derive shares and assets so the pair
+        // stays consistent.
         if (assetsRecovered > *assetsAvailable)
         {
             assetsRecovered = *assetsAvailable;
-            // Note, it is important to truncate the number of shares,
-            // otherwise the corresponding assets might breach the
-            // AssetsAvailable
             {
                 auto const maybeShares = assetsToSharesWithdraw(
-                    vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
+                    vault,
+                    sleShareIssuance,
+                    assetsRecovered,
+                    TruncateShares::Yes,
+                    waiveUnrealizedLoss);
                 if (!maybeShares)
                     return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 sharesDestroyed = *maybeShares;
             }
 
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
+            // Truncation should guarantee the invariant holds. If it does not, a conversion
+            // helper is broken; refuse rather than over-recover.
             if (assetsRecovered > *assetsAvailable)
             {
                 // LCOV_EXCL_START
@@ -311,6 +355,18 @@ VaultClawback::assetsToClawback(
                 // LCOV_EXCL_STOP
             }
         }
+
+        // Post-fixCleanup3_4_0: round the recovery down at the posterior sfAssetsTotal scale so all
+        // rails change by the same representable delta. sharesDestroyed is intentionally NOT
+        // re-derived here: the holder's shares are burned for their pre-clamp value, so any
+        // sub-ULP trimmed off stays in the vault for the remaining shareholders.
+        if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero)
+        {
+            auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsRecovered);
+            if (!maybeClamped)
+                return std::unexpected(maybeClamped.error());
+            assetsRecovered = *maybeClamped;
+        }
     }
     catch (std::overflow_error const&)
     {
@@ -322,6 +378,8 @@ VaultClawback::assetsToClawback(
             << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
             << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount)
             << ", amount=" << clawbackAmount.value();
+        // Overflow means this transaction cannot apply, but ledger state is still consistent.
+        // Return tecPATH_DRY rather than a hard internal error.
         return std::unexpected(tecPATH_DRY);
     }
 
@@ -353,11 +411,6 @@ VaultClawback::doApply()
     auto assetsAvailable = vault->at(sfAssetsAvailable);
     auto assetsTotal = vault->at(sfAssetsTotal);
 
-    [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
-    XRPL_ASSERT(
-        lossUnrealized <= (assetsTotal - assetsAvailable),
-        "xrpl::VaultClawback::doApply : loss and assets do balance");
-
     AccountID const holder = tx[sfHolder];
     STAmount sharesDestroyed = {share};
     STAmount assetsRecovered = {vault->at(sfAsset)};
@@ -380,9 +433,46 @@ VaultClawback::doApply()
         sharesDestroyed = clawbackParts->second;
     }
 
+    // The holder has no shares (or the recovery clamped to zero). Nothing to burn; refuse rather
+    // than modifying vault state.
     if (sharesDestroyed == beast::kZero)
         return tecPRECISION_LOSS;
 
+    // Number arithmetic can throw overflow_error when Scale and totals are large.
+    if (view().rules().enabled(fixCleanup3_4_0))
+    {
+        try
+        {
+            // A non-zero recovery can be too small to change the stored sfAssetsTotal at
+            // STAmount's precision. Shares would still be burned, reject it instead.
+            if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered))
+            {
+                // LCOV_EXCL_START
+                JLOG(j_.debug())
+                    << "VaultClawback: clawback amount too small to change stored vault"
+                       " balance";
+                return tecPRECISION_LOSS;
+                // LCOV_EXCL_STOP
+            }
+        }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultClawback: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still
+            // consistent. Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
+    }
+
     assetsTotal -= assetsRecovered;
     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/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
index 497a2f2465..9c6c41654b 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
@@ -33,6 +33,7 @@ VaultDelete::preflight(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
         return temDISABLED;
 
+    // The sfMemoData field is an optional field used to record the deletion reason.
     if (!validDataLength(ctx.tx[~sfMemoData], kMaxDataPayloadLength))
         return temMALFORMED;
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
index aa9cfc8537..fc72159444 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
@@ -2,17 +2,20 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +49,39 @@ roundToVaultScale(STAmount const& amount, SLE::const_ref vault)
     return roundToScale(amount, postScale, Number::RoundingMode::Downward);
 }
 
+// True if debiting `assets` would leave the depositor's balance where it started, so the deposit
+// would mint shares against a transfer that never happened. Asking the balance directly whether it
+// notices the debit avoids having to infer the rounding step: it has to be the stored balance that
+// answers, because that magnitude is what governs the rounding, and it is not the same as the
+// spendable amount, which also counts what the counterparty's limit allows.
+[[nodiscard]]
+static bool
+roundsToZeroForDepositor(
+    ReadView const& view,
+    AccountID const& account,
+    STAmount const& assets,
+    beast::Journal j)
+{
+    if (assets.integral())
+        return false;
+
+    auto const balance = accountHolds(
+        view,
+        account,
+        assets.asset(),
+        FreezeHandling::ZeroIfFrozen,
+        AuthHandling::ZeroIfUnauthorized,
+        j,
+        SpendableHandling::SimpleBalance);
+
+    if (balance - assets != balance)
+        return false;
+
+    JLOG(j.warn()) << "VaultDeposit: amount " << assets.getFullText()
+                   << " leaves the depositor's balance " << balance.getFullText() << " unchanged";
+    return true;
+}
+
 NotTEC
 VaultDeposit::preflight(PreflightContext const& ctx)
 {
@@ -71,6 +107,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);
@@ -127,26 +174,13 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
             return tecLOCKED;
     }
 
+    // The vault owner is authorized to deposit unconditionally. An expired
+    // credential is tolerated here because doApply deletes it.
     if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner))
     {
-        auto const maybeDomainID = sleIssuance->at(~sfDomainID);
-        // Since this is a private vault and the account is not its owner, we
-        // perform authorization check based on DomainID read from sleIssuance.
-        // Had the vault shares been a regular MPToken, we would allow
-        // authorization granted by the Issuer explicitly, but Vault uses Issuer
-        // pseudo-account, which cannot grant an authorization.
-        if (maybeDomainID)
-        {
-            // As per validDomain documentation, we suppress tecEXPIRED error
-            // here, so we can delete any expired credentials inside doApply.
-            if (auto const err = credentials::validDomain(ctx.view, *maybeDomainID, account);
-                !isTesSuccess(err) && err != tecEXPIRED)
-                return err;
-        }
-        else
-        {
-            return tecNO_AUTH;
-        }
+        if (auto const err = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::Yes);
+            !isTesSuccess(err))
+            return err;
     }
 
     // Source MPToken must exist (if asset is an MPT)
@@ -196,6 +230,7 @@ TER
 VaultDeposit::doApply()
 {
     bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
+    bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     auto applyViewContext = ctx_.getApplyViewContext();
     if (!vault)
@@ -272,6 +307,8 @@ VaultDeposit::doApply()
     }
 
     STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
+
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
         // Compute exchange before transferring any amounts.
@@ -281,14 +318,20 @@ VaultDeposit::doApply()
                 return tecINTERNAL;  // LCOV_EXCL_LINE
             sharesCreated = *maybeShares;
         }
+
         if (sharesCreated == beast::kZero)
             return tecPRECISION_LOSS;
 
+        // Convert shares back to assets so the depositor is debited for the amount actually minted.
+        // The truncated share count is worth <= amount; without this the difference would be
+        // credited to the vault for free.
         auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
         if (!maybeAssets)
         {
             return tecINTERNAL;  // LCOV_EXCL_LINE
         }
+        // The round-trip must never return more than the original amount. If it does, a conversion
+        // helper is broken. Reject rather than overcharge the depositor.
         if (*maybeAssets > amount)
         {
             // LCOV_EXCL_START
@@ -297,6 +340,27 @@ VaultDeposit::doApply()
             // LCOV_EXCL_STOP
         }
         assetsDeposited = *maybeAssets;
+
+        // Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting
+        // fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same
+        // representable delta.
+        if (fix340Enabled)
+        {
+            // Round down at the posterior sfAssetsTotal scale so the vault is credited by no more
+            // than the depositor paid. Keep the share count from the first round trip: the clamp
+            // only drops a last digit of the new total. Converting the clamped amount back to
+            // shares would mint fewer shares while still charging the N-share debit.
+            auto const maybeClamped = clampToAssetsTotalScale(vault, assetsDeposited);
+            if (!maybeClamped)
+                return maybeClamped.error();
+            assetsDeposited = *maybeClamped;
+
+            // The actual deposit amount is truncated to whole shares, converted back to assets,
+            // and clamped to the sfAssetsTotal scale (post-fixCleanup3_4_0). Check the depositor's
+            // balance here—after clamping—before making any state changes.
+            if (roundsToZeroForDepositor(view(), accountID_, assetsDeposited, j_))
+                return tecPRECISION_LOSS;
+        }
     }
     catch (std::overflow_error const&)
     {
diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
index 353b72c30d..4dc5b95c89 100644
--- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
@@ -1,11 +1,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -27,6 +30,13 @@
 
 namespace xrpl {
 
+bool
+VaultWithdraw::checkExtraFeatures(PreflightContext const& ctx)
+{
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) ||
+        (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0));
+}
+
 static WaiveUnrealizedLoss
 shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
 {
@@ -59,6 +69,9 @@ VaultWithdraw::preflight(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -68,11 +81,22 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     auto const fix313Enabled = ctx.view.rules().enabled(fixCleanup3_1_3);
     auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
     auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
+    auto const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0);
 
     auto const vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID]));
     if (!vault)
         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);
@@ -103,6 +127,23 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // Validate credentials (if any) before canWithdraw, since canWithdraw may
+    // call credentials::authorizedDepositPreauth which assumes credentials
+    // already exist.
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err))
+        return err;
+
+    // A pseudo-account belongs to a ledger object rather than to a person and
+    // must never receive funds from a user-initiated transaction. Deposit
+    // authorization, which every pseudo-account carries, already refuses the
+    // payout, but it reports only that the destination declines deposits and
+    // leaves the real reason unsaid.
+    if (fix340Enabled && isPseudoAccount(ctx.view, dstAcct))
+    {
+        JLOG(ctx.j.debug()) << "VaultWithdraw: cannot withdraw into a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     if (fix313Enabled && amount.asset() == vaultShare)
     {
         // Post-fixCleanup3_1_3: if the user specified shares, convert
@@ -134,7 +175,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
                     account,
                     dstAcct,
                     *maybeAssets,
-                    ctx.tx.isFieldPresent(sfDestinationTag)))
+                    ctx.tx.isFieldPresent(sfDestinationTag),
+                    ctx.tx[~sfCredentialIDs]))
                 return ret;
         }
         catch (std::overflow_error const&)
@@ -163,6 +205,48 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter))
         return ter;
 
+    // Fail early when self-destination would have to create a holding.
+    // Skip when a holding already exists: canAddHolding does not look at that,
+    // and would block a no-op create (the DefaultRipple-cleared self-withdraw).
+    if (fix340Enabled && account == dstAcct && !holdingExists(ctx.view, dstAcct, vaultAsset))
+    {
+        if (auto const ter = canAddHolding(ctx.view, vaultAsset); !isTesSuccess(ter))
+            return ter;
+    }
+
+    // The checks above only establish that an account may hold the asset. A
+    // private vault additionally restricts who may take part in it, so paying
+    // its asset out to a third party requires both ends of that payout to be
+    // inside the vault's permissioned domain. VaultDeposit applies the same
+    // domain check on the way in.
+    //
+    // Two cases deliberately skip the check. Withdrawing to self is never
+    // restricted: losing vault access must not strand funds already deposited.
+    // The asset issuer is always allowed to receive, which keeps the return
+    // path for frozen assets open even for a submitter who lost access.
+    if (fix340Enabled && vault->isFlag(lsfVaultPrivate) && dstAcct != account &&
+        dstAcct != vaultAsset.getIssuer())
+    {
+        auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(vaultShare));
+        if (!sleIssuance)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx.j.error()) << "VaultWithdraw: missing issuance of vault shares.";
+            return tefINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        // Unlike VaultDeposit we do not suppress tecEXPIRED: there is no
+        // doApply step here that would clean up the expired credential.
+        if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::No);
+            !isTesSuccess(ter))
+            return ter;
+
+        if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, dstAcct, SuppressExpired::No);
+            !isTesSuccess(ter))
+            return ter;
+    }
+
     if (fix330Enabled)
     {
         // checkWithdrawFreeze checks the underlying asset on the source
@@ -193,6 +277,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
 TER
 VaultWithdraw::doApply()
 {
+    bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     auto applyViewContext = ctx_.getApplyViewContext();
     if (!vault)
@@ -211,7 +296,9 @@ VaultWithdraw::doApply()
     // Note, we intentionally do not check lsfVaultPrivate flag on the Vault. If
     // you have a share in the vault, it means you were at some point authorized
     // to deposit into it, and this means you are also indefinitely authorized
-    // to withdraw from it.
+    // to withdraw it to yourself. Sending the proceeds to somebody else is a
+    // different matter, and preclaim checks such a withdrawal against the
+    // vault's permissioned domain.
 
     auto const amount = ctx_.tx[sfAmount];
     Asset const vaultAsset = vault->at(sfAsset);
@@ -224,21 +311,37 @@ VaultWithdraw::doApply()
     // We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their
     // shares but keeping future value in the vault.
     auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance);
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
         if (amount.asset() == vaultAsset)
         {
             // Fixed assets, variable shares.
+            //
+            // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
+            // round-trip back to assets could exceed the requested amount.
+            // That over-delivers to the depositor and can bypass the
+            // preclaim canWithdraw check on the destination, which was
+            // validated against the requested amount only.
+            // Post-amendment: truncate shares so assetsWithdrawn <=
+            // requested amount by construction. If truncation yields zero
+            // shares, the tecPRECISION_LOSS guard below fires.
+            auto const truncate =
+                view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
             {
                 auto const maybeShares = assetsToSharesWithdraw(
-                    vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
+                    vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
                 if (!maybeShares)
                     return tecINTERNAL;  // LCOV_EXCL_LINE
                 sharesRedeemed = *maybeShares;
             }
 
+            // Shares are MPT (integer). Small requested amounts truncate to zero; refuse rather
+            // than burn nothing while paying out assets.
             if (sharesRedeemed == beast::kZero)
                 return tecPRECISION_LOSS;
+            // Convert shares back to assets so the payout matches the shares actually burned, not
+            // the requested amount. The extra would otherwise be paid from the vault for free.
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
             if (!maybeAssets)
@@ -247,7 +350,8 @@ VaultWithdraw::doApply()
         }
         else if (amount.asset() == share)
         {
-            // Fixed shares, variable assets.
+            // Fixed shares, variable assets. No round-trip: the share count is exactly what the
+            // caller specified; only the payout amount is derived.
             sharesRedeemed = amount;
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
@@ -270,9 +374,62 @@ VaultWithdraw::doApply()
             << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
             << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
             << ", amount=" << amount.value();
+        // Overflow means this transaction cannot apply, but ledger state is still consistent.
+        // Return tecPATH_DRY rather than a hard internal error.
         return tecPATH_DRY;
     }
 
+    // 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);
+
+    if (fix340Enabled && !isFinalWithdrawal)
+    {
+        // Fixed-shares path: a small share count can round to zero assets even though the vault has
+        // backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch
+        // above has already rejected zero via the sharesRedeemed check.
+        if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
+            assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets";
+            return tecPRECISION_LOSS;
+        }
+
+        // Number arithmetic can throw overflow_error when Scale and totals are large.
+        try
+        {
+            // A non-zero payout can be too small to change the stored sfAssetsTotal at
+            // STAmount's precision. Shares would still be burned, reject it instead.
+            if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn))
+            {
+                JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
+                                    " vault balance";
+                return tecPRECISION_LOSS;
+            }
+        }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultWithdraw: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still consistent.
+            // Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
+    }
+
     // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
     // (checkWithdrawFreeze), so IgnoreFreeze avoids a redundant check that
     // would incorrectly return zero for vault pseudo-accounts whose shares
@@ -287,12 +444,53 @@ 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");
+    // Post-fixCleanup3_4_0: round the payout to the sfAssetsTotal scale so all three rails
+    // (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same representable delta.
+    // Skip when assetsWithdrawn is already zero: the earlier fix340 guard above deliberately
+    // permits fixed-share zero-asset withdrawals in a fully-impaired vault (where
+    // assetsTotalForWithdrawal == 0), and clamping-then-rejecting would undo that. Also skip on
+    // the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below.
+    if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero)
+    {
+        // Check availability against the unclamped amount first, so a withdrawal that is both
+        // over the vault's available balance and sub-ULP at the posterior sfAssetsTotal scale
+        // reports tecINSUFFICIENT_FUNDS rather than tecPRECISION_LOSS. The clamp below only ever
+        // shrinks assetsWithdrawn, so this check stays valid; the post-clamp check further down
+        // remains in place to catch the (now smaller) clamped value too.
+        if (*assetsAvailable < assetsWithdrawn)
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
+            return tecINSUFFICIENT_FUNDS;
+        }
+
+        // Number arithmetic can throw overflow_error when Scale and totals are large.
+        try
+        {
+            // Round down at the posterior sfAssetsTotal scale so the payout never exceeds the
+            // value represented by the redeemed shares. sharesRedeemed is intentionally not
+            // re-derived: any trimmed residue stays with remaining shareholders.
+            auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn);
+            if (!maybeClamped)
+                return maybeClamped.error();  // LCOV_EXCL_LINE
+            assetsWithdrawn = *maybeClamped;
+        }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultWithdraw: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still consistent.
+            // Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
+    }
 
     // The vault must have enough assets on hand.
     if (*assetsAvailable < assetsWithdrawn)
@@ -301,16 +499,12 @@ VaultWithdraw::doApply()
         return tecINSUFFICIENT_FUNDS;
     }
 
-    // Post-fixCleanup3_2_0 "final withdrawal" rule:
-    // a transaction that would burn every outstanding share is only permitted when the vault is in
-    // a clean state — no outstanding receivables and no unrealized loss. Otherwise the resulting
-    // (shares == 0, assetsTotal > 0) state would violate the zero-sized-vault invariant.
+    // Post-fixCleanup3_2_0: burning every outstanding share is only allowed when the vault has no
+    // unrealized loss. Otherwise the resulting (shares == 0, assetsTotal > 0) state would violate
+    // the zero-sized-vault invariant.
     //
-    // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
-    // the helper result should already equal that value, and any mismatch is a rounding artifact
-    // worth logging.
-    bool const isFinalWithdrawal =
-        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
+    // The payout is set to the remaining sfAssetsAvailable. The helper result should already
+    // equal that value in a clean vault; any mismatch is a rounding artifact and is logged.
     if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
     {
         // Unreachable: a final withdrawal with lossUnrealized > 0 has
@@ -344,6 +538,8 @@ VaultWithdraw::doApply()
     }
     else
     {
+        // Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step,
+        // as required by the ValidVault invariant.
         assetsTotal -= assetsWithdrawn;
         assetsAvailable -= assetsWithdrawn;
     }
diff --git a/src/test/app/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..44eb61395a 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,282 @@ 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
+    testClawbackBypassesReserve(FeatureBitset features)
+    {
+        // Same as the IOU case, but the paired asset is an MPT alice does not
+        // hold yet. The reserve check is skipped on the clawback path while
+        // createMPToken() still runs, so alice's MPToken is created even though
+        // neither she nor the low-XRP issuer can cover the owner reserve.
+        testcase("test clawback bypasses recipient reserve (MPT)");
+        using namespace jtx;
+
+        Env env(*this, features);
+        Account const gw{"gateway"};    // IOU issuer + claw authority, low XRP
+        Account const gw2{"gateway2"};  // MPT issuer of the paired asset
+        Account const carol{"carol"};
+        Account const alice{"alice"};
+
+        auto const usd = gw["USD"];
+        auto const baseFee = env.current()->fees().base;
+
+        env.fund(XRP(1'000'000), gw2, carol);
+        // Low XRP so the legacy issuer-balance check cannot pass.
+        env.fund(env.current()->fees().accountReserve(0, 1) + baseFee * 10, gw);
+        // Reserve for the USD trustline and LP token trustline.
+        env.fund(env.current()->fees().accountReserve(2, 1) + baseFee * 5, alice);
+        env.close();
+
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        // The paired MPT: transferable so an AMM can hold it, and no
+        // RequireAuth so createMPToken()'s WeakAuth check passes.
+        MPT const btc = MPTTester(
+            {.env = env,
+             .issuer = gw2,
+             .holders = {carol},
+             .pay = 1'000'000,
+             .flags = kMptDexFlags});
+
+        env.trust(usd(1'000'000), carol);
+        env(pay(gw, carol, usd(100'000)));
+        env.close();
+        AMM amm(env, carol, usd(1'000), btc(1'000), Ter(tesSUCCESS));
+        env.close();
+
+        // alice holds a USD trustline and LP tokens, but no BTC MPToken.
+        env.trust(usd(100'000), alice);
+        env(pay(gw, alice, usd(1'000)));
+        env.close();
+        amm.deposit(alice, usd(100));
+
+        BEAST_EXPECT(env.ownerCount(alice) == 2);
+        BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id())));
+
+        // AMMWithdraw still enforces the reserve check.
+        amm.withdrawAll(alice, std::nullopt, Ter(tecINSUFFICIENT_RESERVE));
+        BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id())));
+        BEAST_EXPECT(env.ownerCount(alice) == 2);
+        // alice cannot afford a third owner object.
+        BEAST_EXPECT(env.balance(alice) < STAmount(env.current()->fees().accountReserve(3, 1)));
+
+        if (features[fixCleanup3_4_0])
+        {
+            // Reserve check skipped; the paired BTC returns to alice on a
+            // newly created MPToken.
+            env(amm::ammClawback(gw, alice, usd, btc, usd(10)), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID, alice.id())));
+            BEAST_EXPECT(env.balance(alice, btc) > btc(0));
+            BEAST_EXPECT(env.ownerCount(alice) == 3);
+        }
+        else
+        {
+            // Legacy path: the check runs against max(issuer, holder) XRP,
+            // neither of which covers a third owner object.
+            env(amm::ammClawback(gw, alice, usd, btc, usd(10)), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID, alice.id())));
+            BEAST_EXPECT(env.ownerCount(alice) == 2);
+        }
+    }
+
     void
     run() override
     {
@@ -1819,11 +2289,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);
@@ -1832,6 +2308,8 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             featureLendingProtocol);
         testLastHolderLPTokenBalance(all - fixAMMClawbackRounding);
         testClawAssetCheck(all);
+        testClawbackBypassesReserve(all);
+        testClawbackBypassesReserve(all - fixCleanup3_4_0);
     }
 };
 
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index ba416d8192..4f025f08eb 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -13,7 +13,10 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -2155,6 +2158,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
@@ -2510,6 +2716,142 @@ class AMMClawback_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testClawbackBypassesReserve(FeatureBitset features)
+    {
+        // Clawback must not fail the holder-side reserve check: a holder could
+        // otherwise veto it by omitting the paired trustline. AMMWithdraw still
+        // enforces the check. Pre-fixCleanup3_4_0 the holder's reserve was
+        // compared against max(issuer pre-fee, holder current) XRP, so the
+        // clawback was blocked when neither balance covered it.
+        testcase("test clawback bypasses recipient reserve");
+        using namespace jtx;
+
+        Env env(*this, features);
+        Account const gw{"gateway"};
+        Account const carol{"carol"};
+        Account const alice{"alice"};
+
+        auto const usd = gw["USD"];
+        auto const eur = gw["EUR"];
+        auto const baseFee = env.current()->fees().base;
+
+        env.fund(XRP(1'000'000), carol);
+        // Low XRP so the legacy issuer-balance check cannot pass.
+        env.fund(env.current()->fees().accountReserve(0, 1) + baseFee * 10, gw);
+        // Reserve for the USD trustline and LP token trustline.
+        env.fund(env.current()->fees().accountReserve(2, 1) + baseFee * 5, alice);
+        env.close();
+
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        env.trust(usd(1'000'000), carol);
+        env.trust(eur(1'000'000), carol);
+        env(pay(gw, carol, usd(100'000)));
+        env(pay(gw, carol, eur(100'000)));
+        env.close();
+        AMM amm(env, carol, usd(1'000), eur(1'000), Ter(tesSUCCESS));
+        env.close();
+
+        // alice holds a USD trustline and LP tokens, but no EUR trustline.
+        env.trust(usd(100'000), alice);
+        env(pay(gw, alice, usd(1'000)));
+        env.close();
+        amm.deposit(alice, usd(100));
+
+        BEAST_EXPECT(env.ownerCount(alice) == 2);
+        // alice cannot afford a third owner object.
+        BEAST_EXPECT(env.balance(alice) < STAmount(env.current()->fees().accountReserve(3, 1)));
+
+        // AMMWithdraw still enforces the reserve check.
+        amm.withdraw(
+            WithdrawArg{
+                .account = alice, .asset1Out = eur(1), .err = Ter(tecINSUFFICIENT_RESERVE)});
+        BEAST_EXPECT(env.ownerCount(alice) == 2);
+
+        if (features[fixCleanup3_4_0])
+        {
+            // Reserve check skipped; the paired EUR returns to alice on a
+            // newly created EUR trustline.
+            env(amm::ammClawback(gw, alice, usd, eur, usd(10)), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), eur.issue())));
+            BEAST_EXPECT(env.balance(alice, eur) > eur(0));
+            BEAST_EXPECT(env.ownerCount(alice) == 3);
+        }
+        else
+        {
+            // Legacy path: the check runs against max(issuer, holder) XRP,
+            // neither of which covers a third owner object.
+            env(amm::ammClawback(gw, alice, usd, eur, usd(10)), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
+            BEAST_EXPECT(env.ownerCount(alice) == 2);
+        }
+    }
+
+    void
+    testExactLPTokenEquality(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        if (!features[fixAMMv1_3] || !features[fixAMMClawbackRounding])
+            return;
+
+        testcase("test exact LP token equality boundary");
+
+        Env env(*this, features);
+        Account const gw{"gateway"}, alice{"alice"}, bob{"bob"};
+        env.fund(XRP(100000), gw, alice, bob);
+        env.close();
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        auto const usd = gw["USD"];
+        env.trust(usd(100000), alice);
+        env(pay(gw, alice, usd(50000)));
+        env.trust(usd(100000), bob);
+        env(pay(gw, bob, usd(40000)));
+        env.close();
+
+        // bob keeps alice from being the sole LP, otherwise the clawback
+        // first rewrites the AMM's LP balance to alice's tokens and the
+        // boundary is no longer distinguishable.
+        AMM amm(env, alice, XRP(2), usd(1));
+        amm.deposit(alice, IOUAmount{1'876123487565916, -15});
+        amm.deposit(bob, IOUAmount{1'000'000});
+
+        auto const [amountBalance, amount2Balance, lptAMMBalance] = amm.balances(usd, XRP);
+        auto const aliceLP = amm.getLPTokensBalance(alice);
+        auto const holderLPTokens = STAmount{aliceLP, amm.lptIssue()};
+        BEAST_EXPECT(lptAMMBalance > holderLPTokens);
+
+        // Clawing alice's pro-rata share lands the transactor's computed LP
+        // amount exactly on her balance.
+        auto const amount = toSTAmount(usd, Number{amountBalance} * holderLPTokens / lptAMMBalance);
+        BEAST_EXPECT(
+            toSTAmount(lptAMMBalance.asset(), lptAMMBalance * (Number{amount} / amountBalance)) ==
+            holderLPTokens);
+
+        env(amm::ammClawback(gw, alice, usd, XRP, amount));
+        env.close();
+
+        auto const aliceLPAfter = amm.getLPTokensBalance(alice);
+        if (features[fixCleanup3_4_0])
+        {
+            // Equality takes the withdraw-all path, redeeming alice's tokens
+            // exactly.
+            BEAST_EXPECT(aliceLPAfter == IOUAmount(0));
+        }
+        else
+        {
+            // The fall-through re-rounds the LP amount against the much
+            // larger pool balance, leaving alice with dust.
+            BEAST_EXPECT(aliceLPAfter != IOUAmount(0) && aliceLPAfter < aliceLP);
+        }
+    }
+
     void
     run() override
     {
@@ -2530,6 +2872,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);
@@ -2542,6 +2885,8 @@ class AMMClawback_test : public beast::unit_test::Suite
             testAssetFrozen(features);
             testSingleDepositAndClawback(features);
             testLastHolderLPTokenBalance(features);
+            testClawbackBypassesReserve(features);
+            testExactLPTokenEquality(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..971a540ff7 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(
@@ -1284,6 +1303,78 @@ private:
         BEAST_EXPECT(expectHolding(env, bob_, USD(0)));
     }
 
+    // Same shape as testRequireAuth, except the issuer never authorizes the AMM's own trust line.
+    // An AMM holds the asset for its liquidity providers and cannot sign a TrustSet for itself, so
+    // once pseudo-accounts are implicitly authorized the pool keeps trading. Before that the offer
+    // stream drops it and the taker's offer stays on the book.
+    void
+    testPseudoAccountRequireAuth(FeatureBitset features)
+    {
+        testcase("lsfRequireAuth, unauthorized AMM pseudo-account");
+
+        using namespace jtx;
+
+        bool const pseudoExempt = features[fixCleanup3_4_0];
+
+        Env env{*this, features};
+
+        auto const aliceUSD = alice_["USD"];
+        auto const bobUSD = bob_["USD"];
+
+        env.fund(XRP(400'000), gw_, alice_, bob_);
+        env.close();
+
+        env(fset(gw_, asfRequireAuth));
+        env.close();
+
+        env(trust(gw_, bobUSD(100)), Txflags(tfSetfAuth));
+        env(trust(bob_, USD(100)));
+        env(trust(gw_, aliceUSD(100)), Txflags(tfSetfAuth));
+        env(trust(alice_, USD(2'000)));
+        env(pay(gw_, alice_, USD(1'000)));
+        env.close();
+
+        AMM const ammAlice(env, alice_, USD(1'000), XRP(1'050));
+
+        // The pool's own line stays unauthorized: AMMCreate opens it without the flag, and the
+        // pseudo-account has no key to ask for one.
+        auto const ammLineAuthorized = [&]() -> bool {
+            auto const line =
+                env.le(keylet::trustLine(ammAlice.ammAccount(), USD.issue().account, USD.currency));
+            if (!BEAST_EXPECT(line))
+                return false;
+            return line->isFlag(
+                ammAlice.ammAccount() > USD.issue().account ? lsfLowAuth : lsfHighAuth);
+        };
+        BEAST_EXPECT(!ammLineAuthorized());
+
+        env(pay(gw_, bob_, USD(50)));
+        env.close();
+        BEAST_EXPECT(expectHolding(env, bob_, USD(50)));
+
+        // Bob sells USD into the pool, so the pool is the side that has to be authorized to hold
+        // the asset.
+        env(offer(bob_, XRP(50), USD(50)));
+        env.close();
+
+        if (pseudoExempt)
+        {
+            BEAST_EXPECT(ammAlice.expectBalances(USD(1'050), XRP(1'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, bob_, 0));
+            BEAST_EXPECT(expectHolding(env, bob_, USD(0)));
+        }
+        else
+        {
+            // The pool is skipped, so nothing crosses and the offer rests on the book.
+            BEAST_EXPECT(ammAlice.expectBalances(USD(1'000), XRP(1'050), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, bob_, 1));
+            BEAST_EXPECT(expectHolding(env, bob_, USD(50)));
+        }
+
+        // Either way the exemption skips the check rather than setting the flag.
+        BEAST_EXPECT(!ammLineAuthorized());
+    }
+
     void
     testMissingAuth(FeatureBitset features)
     {
@@ -1359,6 +1450,7 @@ private:
         testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3);
         testEnforceNoRipple(all_);
         testFillModes(all_);
+        testFillModes(all_ - featureMPTokensV2);
         testOfferCrossWithXRP(all_);
         testOfferCrossWithLimitOverride(all_);
         testCurrencyConversionEntire(all_);
@@ -1380,6 +1472,8 @@ private:
         testDirectToDirectPath(all_);
         testDirectToDirectPath(all_ - fixAMMv1_1 - fixAMMv1_3);
         testRequireAuth(all_);
+        testPseudoAccountRequireAuth(all_);
+        testPseudoAccountRequireAuth(all_ - fixCleanup3_4_0);
         testMissingAuth(all_);
     }
 
diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp
index 7078ea6769..37e0ed585d 100644
--- a/src/test/app/AMMMPT_test.cpp
+++ b/src/test/app/AMMMPT_test.cpp
@@ -15,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,19 +28,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 +3275,107 @@ 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
+    testWithdrawReserveUsesLiveBalance()
+    {
+        testcase("Withdraw reserve check uses live balance");
+
+        using namespace jtx;
+
+        auto const test = [&](auto&& makeToken) {
+            Env env(*this);
+            env.fund(XRP(30'000), gw_, alice_, bob_);
+            env.close();
+
+            auto const token = makeToken(env);
+            AMM amm(env, gw_, XRP(100), token(100));
+
+            // The EUR trustline is an unrelated owner object. The XRP-only
+            // AMM deposit adds the LP token trustline, so Alice has 2 owners.
+            env.trust(gw_["EUR"](1), alice_);
+            amm.deposit(DepositArg{.account = alice_, .asset1In = XRP(10)});
+            BEAST_EXPECT(env.ownerCount(alice_) == 2);
+            env.require(Balance(alice_, token(kNone)));
+
+            // Drain Alice to one drop below the reserve for a third owner
+            // object, accounting for the fee on the drain payment.
+            auto const reserveForToken = reserve(env, 3);
+            auto const targetBalance = reserveForToken - XRPAmount{1};
+            auto const baseFee = env.current()->fees().base;
+            auto const currentBalance = env.balance(alice_).value().xrp();
+            auto const drainAmount = currentBalance - targetBalance - baseFee;
+            BEAST_EXPECT(drainAmount > XRPAmount{0});
+            env(pay(alice_, bob_, drops(drainAmount)));
+            env.close();
+
+            // AMMWithdraw captures priorBalance before the fee, then the XRP
+            // leg raises the live sandbox balance before the token leg.
+            // XRP(2) keeps the integral MPT side positive after rounding.
+            auto const xrpOut = XRP(2);
+            auto const tokenOut = token(2);
+            auto const priorBalance = env.balance(alice_).value().xrp();
+            auto const liveBalanceAfterXrpLeg = priorBalance - baseFee + xrpOut.value().xrp();
+            BEAST_EXPECT(priorBalance < reserveForToken);
+            BEAST_EXPECT(liveBalanceAfterXrpLeg > priorBalance);
+            BEAST_EXPECT(liveBalanceAfterXrpLeg >= reserveForToken);
+
+            // The XRP leg runs first, so the missing IOU trustline or MPToken
+            // is reserved against the updated sandbox balance.
+            amm.withdraw(
+                WithdrawArg{.account = alice_, .asset1Out = xrpOut, .asset2Out = tokenOut});
+
+            // The withdrawal succeeds only if the missing token holding can be
+            // reserved from the live balance after the XRP leg.
+            BEAST_EXPECT(env.ownerCount(alice_) == 3);
+            BEAST_EXPECT(env.balance(alice_, token).value().signum() > 0);
+        };
+
+        test([&](Env&) -> PrettyAsset { return gw_["USD"]; });
+        test([&](Env& env) -> PrettyAsset { return MPTTester({.env = env, .issuer = gw_}); });
     }
 
     void
@@ -3945,24 +4052,30 @@ private:
             [&](AMM& ammAlice, Env& env) {
                 // Bid a tiny amount
                 auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset};
+                auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0);
+                auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)};
+                auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny};
                 env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}}));
-                // Auction slot purchase price is equal to the tiny amount
-                // since the minSlotPrice is 0 with no trading fee.
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny}));
-                // The purchase price is too small to affect the total tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}}
+                               : ammAlice.tokens()));
                 // Bid the tiny amount
                 env(ammAlice.bid({
                     .account = alice_,
                     .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset},
                 }));
                 // Pay slightly higher price
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}}));
-                // The purchase price is still too small to affect the total
-                // tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(
+                    0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}}));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340
+                        ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}}
+                        : ammAlice.tokens()));
             },
             {{gAmmmpt(10'000'000'000), USD(10'000)}});
 
@@ -4041,9 +4154,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 +4166,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 +5014,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 +5123,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 +5178,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 +5785,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 +7436,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 +7489,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
     {
@@ -7306,10 +7551,12 @@ private:
         testDeposit();
         testInvalidWithdraw();
         testWithdraw();
+        testWithdrawReserveUsesLiveBalance();
         testInvalidFeeVote();
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testClawback();
         testClawbackFromAMMAccount(all);
         testClawbackFromAMMAccount(all - featureSingleAssetVault);
@@ -7318,6 +7565,7 @@ private:
         testAMMTokens();
         testAmendment();
         testAMMAndCLOB(all);
+        testAMMOfferGenerationPolicy(all);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
         testAdjustedTokens(all);
@@ -7334,6 +7582,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 77037cc67a..58d41c40b7 100644
--- a/src/test/app/AMM_test.cpp
+++ b/src/test/app/AMM_test.cpp
@@ -3127,27 +3127,59 @@ private:
             std::nullopt,
             {features});
 
+        // Zero-fee bid without an explicit price pays a floor with fixCleanup3_4_0.
+        testAMM(
+            [&](AMM& ammAlice, Env& env) {
+                auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)};
+                auto const cleanup340 = features[fixCleanup3_4_0];
+                auto const expectedPrice = cleanup340 ? minBidPrice : IOUAmount{0};
+                auto const expectedTokens = cleanup340
+                    ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}}
+                    : ammAlice.tokens();
+
+                env.close(seconds(kTotalTimeSlotSecs + 1));
+                env.close();
+                env(ammAlice.bid({.account = alice_}));
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, expectedPrice));
+                BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), expectedTokens));
+
+                ammAlice.vote(alice_, 1'000);
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(100, 0, expectedPrice));
+            },
+            std::nullopt,
+            0,
+            std::nullopt,
+            {features});
+
         // Bid tiny amount
         testAMM(
             [&](AMM& ammAlice, Env& env) {
                 // Bid a tiny amount
                 auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset};
+                auto const cleanup340 = features[fixCleanup3_4_0];
+                auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)};
+                auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny};
                 env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}}));
-                // Auction slot purchase price is equal to the tiny amount
-                // since the minSlotPrice is 0 with no trading fee.
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny}));
-                // The purchase price is too small to affect the total tokens
-                BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens()));
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice));
+                BEAST_EXPECT(ammAlice.expectBalances(
+                    XRP(10'000),
+                    USD(10'000),
+                    cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}}
+                               : ammAlice.tokens()));
                 // Bid the tiny amount
                 env(ammAlice.bid({
                     .account = alice_,
                     .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset},
                 }));
                 // Pay slightly higher price
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}}));
-                // The purchase price is still too small to affect the total
-                // tokens
-                BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens()));
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(
+                    0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}}));
+                BEAST_EXPECT(ammAlice.expectBalances(
+                    XRP(10'000),
+                    USD(10'000),
+                    cleanup340
+                        ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}}
+                        : ammAlice.tokens()));
             },
             std::nullopt,
             0,
@@ -3778,6 +3810,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 +3835,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 +6498,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 +6511,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 +6535,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}}}}));
+            }
         }
     }
 
@@ -7388,6 +7468,7 @@ private:
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testBid(all - fixAMMv1_3);
         testBid(all - fixAMMv1_1 - fixAMMv1_3);
         testInvalidAMMPayment();
@@ -7400,6 +7481,7 @@ private:
         testFlags();
         testRippling();
         testAMMAndCLOB(all);
+        testAMMAndCLOB(all - featureMPTokensV2);
         testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
@@ -7419,8 +7501,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/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp
index 15668d4d71..aa7fe898e3 100644
--- a/src/test/app/AccountDelete_test.cpp
+++ b/src/test/app/AccountDelete_test.cpp
@@ -29,6 +29,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -67,7 +69,8 @@ private:
         // We can't use env.meta() here, because meta() doesn't include
         // delivered_amount.
         env.close();
-        json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
+        json::Value const txResult = env.rpc("tx", txHash)[jss::result];
+        json::Value const meta = txResult[jss::meta];
 
         // Expect there to be a DeliveredAmount field.
         if (!BEAST_EXPECT(meta.isMember(sfDeliveredAmount.jsonName)))
@@ -78,6 +81,21 @@ private:
         json::Value const jsonExpect{amount.getJson(JsonOptions::Values::None)};
         BEAST_EXPECT(meta[sfDeliveredAmount.jsonName] == jsonExpect);
         BEAST_EXPECT(meta[jss::delivered_amount] == jsonExpect);
+
+        // The `ledger` RPC (with expanded transactions) should also report
+        // delivered_amount for this transaction, matching the `tx` RPC.
+        json::Value ledgerParams;
+        ledgerParams[jss::ledger_index] = txResult[jss::ledger_index].asUInt();
+        ledgerParams[jss::transactions] = true;
+        ledgerParams[jss::expand] = true;
+
+        auto const ledgerResult = env.rpc("json", "ledger", to_string(ledgerParams));
+        auto const& ledgerTx = ledgerResult[jss::result][jss::ledger][jss::transactions][0u];
+        BEAST_EXPECT(ledgerTx[jss::hash].asString() == txHash);
+
+        json::Value const& ledgerMeta = ledgerTx[jss::metaData];
+        BEAST_EXPECT(ledgerMeta[sfDeliveredAmount.jsonName] == jsonExpect);
+        BEAST_EXPECT(ledgerMeta[jss::delivered_amount] == jsonExpect);
     }
 
     // Helper function to create a payment channel.
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index c332b26a5b..7e6ecfb8ca 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -3169,7 +3169,12 @@ class Batch_test : public beast::unit_test::Suite
         auto const debtMaximumValue = asset(25'000).value();
         auto const coverDepositValue = asset(1000).value();
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a subscription
+        // window that lets the lender deposit now, then advance the clock
+        // past SubscriptionDate before creating loans.
+        auto [tx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = asset});
         env(tx);
         env.close();
         BEAST_EXPECT(env.le(vaultKeylet));
@@ -3177,6 +3182,9 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
+        // Move into the Investment phase before creating loans.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp
new file mode 100644
index 0000000000..c4e8e607da
--- /dev/null
+++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp
@@ -0,0 +1,634 @@
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase
+{
+    void
+    testMPTokenIssuanceSetRotateIssuerKey(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet rotate issuer key");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+
+        // First-time registration.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+        });
+
+        // Verify that no epochs are set when registering for the first time.
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+        // Rotating the issuer key requires the key rotation amendment
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(bob),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
+        });
+
+        // A rotation replaces the issuer key and bumps its epoch. The auditor
+        // key was never registered, so it and its epoch stay absent.
+        if (rotationEnabled)
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+        }
+        else
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+        }
+
+        if (rotationEnabled)
+        {
+            // A second rotation increments the epoch again
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, std::nullopt));
+        }
+    }
+
+    void
+    testMPTokenIssuanceSetRotateBothKeys(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet rotate both issuer and auditor keys");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(auditor);
+
+        // Register both keys together.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        // Verify that no epochs are set when registering for the first time.
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+        // Rotating both keys requires the amendment
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(bob),
+            .auditorPubKey = mptAlice.getPubKey(alice),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
+        });
+
+        if (rotationEnabled)
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, alice));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u));
+        }
+        else
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+        }
+
+        if (rotationEnabled)
+        {
+            // Rotating the issuer key to its current value fails.
+            // Current issuer key is bob, duplicate.
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(bob),
+                .err = tecDUPLICATE,
+            });
+
+            // Rotating the auditor key to its current value fails.
+            // Current auditor key is alice, duplicate.
+            mptAlice.set({
+                .account = alice,
+                .auditorPubKey = mptAlice.getPubKey(alice),
+                .err = tecDUPLICATE,
+            });
+
+            // The whole transaction fails when one key is unchanged, even if
+            // the other key is rotated to a new value.
+            // Current issuer key is bob, duplicate.
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(bob),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+                .err = tecDUPLICATE,
+            });
+
+            // Current auditor key is alice, duplicate.
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(auditor),
+                .auditorPubKey = mptAlice.getPubKey(alice),
+                .err = tecDUPLICATE,
+            });
+
+            // Nothing changed: keys and epochs are untouched
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, 1u));
+
+            // A second rotation increments both epochs again
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, 2u));
+        }
+    }
+
+    void
+    testMPTokenIssuanceSetRotateAuditorKeyOnly(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet rotate auditor key only");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(auditor);
+
+        // Register both keys together.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        // A transaction carrying only the auditor key fails preflight
+        // pre-ConfidentialMPTKeyRotation; post-ConfidentialMPTKeyRotation it rotates the auditor
+        // key
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(bob),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
+        });
+
+        // The issuer key keeps unchanged, and rotating only the auditor key
+        // bumps only its epoch.
+        if (rotationEnabled)
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, bob));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
+        }
+        else
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+        }
+
+        if (rotationEnabled)
+        {
+            // A second rotation increments the epoch again
+            mptAlice.set({
+                .account = alice,
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // The issuer key epoch is still untouched.
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 2u));
+        }
+    }
+
+    void
+    testMPTokenIssuanceSetRegisterAuditorKeyLater(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet register auditor key after issuer key");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const auditor("auditor");
+        MPTTester mptAlice(env, alice);
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(auditor);
+
+        // Register the issuer key first. We'll register the auditor key in a separate transaction.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+        });
+
+        // Register the auditor key separately.
+        // pre-ConfidentialMPTKeyRotation it fails preflight; post-ConfidentialMPTKeyRotation it
+        // succeeds without touching any epoch because it's a first-time registration.
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
+        });
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(
+            alice, rotationEnabled ? std::optional(auditor) : std::nullopt));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+    }
+
+    void
+    testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet register auditor key later with circulating supply");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(auditor);
+
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+        });
+
+        // Convert some of bob's balance so that COA > 0
+        mptAlice.convert({
+            .account = bob,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(bob),
+        });
+
+        auto const sleIssuanceBefore = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
+        if (!BEAST_EXPECT(sleIssuanceBefore))
+            return;
+        auto const coaBefore = (*sleIssuanceBefore)[~sfConfidentialOutstandingAmount].value_or(0);
+        BEAST_EXPECT(coaBefore > 0);
+
+        // Registering the auditor key for the first time while confidential
+        // supply is circulating: pre-ConfidentialMPTKeyRotation an auditor-only
+        // transaction fails preflight; post-ConfidentialMPTKeyRotation it
+        // succeeds as a first-time late-registration even COA > 0.
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(temMALFORMED),
+        });
+
+        auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
+        if (!BEAST_EXPECT(sleIssuance))
+            return;
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(
+            alice, rotationEnabled ? std::optional(auditor) : std::nullopt));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+        // The circulating supply itself is not affected.
+        BEAST_EXPECT((*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBefore);
+    }
+
+    void
+    testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet auditor key requires issuer key");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const auditor("auditor");
+        MPTTester mptAlice(env, alice);
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(auditor);
+        // The issuer key was never registered. pre-ConfidentialMPTKeyRotation an auditor-only
+        // transaction fails preflight; post-ConfidentialMPTKeyRotation it passes preflight
+        // but preclaim rejects registering an auditor key on an issuance
+        // without an issuer key.
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+            .err = rotationEnabled ? TER(tecNO_PERMISSION) : TER(temMALFORMED),
+        });
+
+        // The rejected transaction leaves the issuance without either key.
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(std::nullopt, std::nullopt));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+    }
+
+    void
+    testMPTokenIssuanceSetRotateWithCOA(FeatureBitset features)
+    {
+        testcase("MPTokenIssuanceSet rotate with circulating confidential supply");
+        using namespace test::jtx;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.authorize({.account = bob});
+        mptAlice.pay(alice, bob, 100);
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+        });
+
+        // Convert some of bob's balance to confidential spending, so that the
+        // issuance has confidential supply. COA > 0.
+        mptAlice.convert({
+            .account = bob,
+            .amt = 50,
+            .holderPubKey = mptAlice.getPubKey(bob),
+        });
+
+        auto const sleIssuanceBeforeRotation =
+            env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
+        if (!BEAST_EXPECT(sleIssuanceBeforeRotation))
+            return;
+        auto const coaBeforeRotation =
+            (*sleIssuanceBeforeRotation)[~sfConfidentialOutstandingAmount].value_or(0);
+        BEAST_EXPECT(coaBeforeRotation > 0);
+
+        // Rotating key requires the
+        // amendment.
+        bool const rotationEnabled = features[featureConfidentialMPTKeyRotation];
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(carol),
+            .err = rotationEnabled ? TER(tesSUCCESS) : TER(tecNO_PERMISSION),
+        });
+
+        auto const sleIssuance = env.le(keylet::mptokenIssuance(mptAlice.issuanceID()));
+        if (!BEAST_EXPECT(sleIssuance))
+            return;
+        if (rotationEnabled)
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(carol, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+        }
+        else
+        {
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+        }
+
+        // The confidential outstanding amount is not affected by the rotation
+        BEAST_EXPECT(
+            (*sleIssuance)[~sfConfidentialOutstandingAmount].value_or(0) == coaBeforeRotation);
+
+        // Re-enabling confidential balances while supply is circulating is
+        // rejected regardless of the ConfidentialMPTKeyRotation amendment.
+        mptAlice.set({
+            .account = alice,
+            .flags = tfMPTSetCanHoldConfidentialBalance,
+            .err = tecNO_PERMISSION,
+        });
+    }
+
+    void
+    testMPTokenIssuanceSetKeyEpochAtMax(FeatureBitset features)
+    {
+        using namespace test::jtx;
+        if (!features[featureConfidentialMPTKeyRotation])
+            return;
+
+        testcase("MPTokenIssuanceSet key epoch cannot wrap");
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const auditor("auditor");
+
+        // Keep the ledger open so that we can write the key epochs directly into it.
+        MPTTester mptAlice(env, alice, {.holders = {bob}, .close = false});
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+
+        mptAlice.generateKeyPair(alice);
+        mptAlice.generateKeyPair(bob);
+        mptAlice.generateKeyPair(carol);
+        mptAlice.generateKeyPair(auditor);
+
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        auto const issuanceKeylet = keylet::mptokenIssuance(mptAlice.issuanceID());
+
+        // Writes the supplied key epochs straight into the open ledger so that
+        // the maximum epoch is reachable without submitting four billion
+        // rotations.
+        auto setEpochs = [&](std::optional const& issuerKeyEpoch,
+                             std::optional const& auditorKeyEpoch) {
+            env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+                auto const sle = view.read(issuanceKeylet);
+                if (!sle)
+                    return false;  // LCOV_EXCL_LINE
+
+                auto replacement = std::make_shared(*sle);
+                if (issuerKeyEpoch)
+                    (*replacement)[sfIssuerKeyEpoch] = *issuerKeyEpoch;
+                if (auditorKeyEpoch)
+                    (*replacement)[sfAuditorKeyEpoch] = *auditorKeyEpoch;
+                view.rawReplace(replacement);
+                return true;
+            });
+        };
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+        // Increment the auditor epoch to kMaxKeyEpoch - 1, leaving the issuer epoch absent.
+        setEpochs(std::nullopt, kMaxKeyEpoch - 1);
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch - 1));
+
+        // Rotating the auditor key to kMaxKeyEpoch succeeds.
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(carol),
+        });
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch));
+
+        // A further auditor rotation is rejected because the epoch is exhausted.
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(bob),
+            .err = tecNO_PERMISSION,
+        });
+
+        // Rotating both keys at once is rejected as a whole because the auditor
+        // epoch is exhausted.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(auditor),
+            .auditorPubKey = mptAlice.getPubKey(bob),
+            .err = tecNO_PERMISSION,
+        });
+
+        // Both rejections leave every key and epoch as it was.
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, kMaxKeyEpoch));
+
+        // The issuer key is unaffected by the exhausted auditor epoch.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(bob),
+        });
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, kMaxKeyEpoch));
+
+        // Increment the issuer epoch to kMaxKeyEpoch - 1.
+        setEpochs(kMaxKeyEpoch - 1, std::nullopt);
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(bob, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch - 1, kMaxKeyEpoch));
+
+        // Rotating the issuer key to kMaxKeyEpoch succeeds.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(auditor),
+        });
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch));
+
+        // With both epochs exhausted neither key can be rotated again.
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .err = tecNO_PERMISSION,
+        });
+        mptAlice.set({
+            .account = alice,
+            .auditorPubKey = mptAlice.getPubKey(bob),
+            .err = tecNO_PERMISSION,
+        });
+        mptAlice.set({
+            .account = alice,
+            .issuerPubKey = mptAlice.getPubKey(alice),
+            .auditorPubKey = mptAlice.getPubKey(bob),
+            .err = tecNO_PERMISSION,
+        });
+
+        BEAST_EXPECT(mptAlice.checkEncryptionKeys(auditor, carol));
+        BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch));
+    }
+
+    void
+    testMPTokenIssuanceSetWithFeats(FeatureBitset features)
+    {
+        testMPTokenIssuanceSetRotateIssuerKey(features);
+        testMPTokenIssuanceSetRotateBothKeys(features);
+        testMPTokenIssuanceSetRotateAuditorKeyOnly(features);
+        testMPTokenIssuanceSetRegisterAuditorKeyLater(features);
+        testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(features);
+        testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(features);
+        testMPTokenIssuanceSetRotateWithCOA(features);
+        testMPTokenIssuanceSetKeyEpochAtMax(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        using namespace test::jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        testMPTokenIssuanceSetWithFeats(all);
+        testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ConfidentialMPTKeyRotation, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp
index 6450ceeb61..a964193c1a 100644
--- a/src/test/app/ConfidentialTransfer_test.cpp
+++ b/src/test/app/ConfidentialTransfer_test.cpp
@@ -736,12 +736,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .err = temMALFORMED,
             });
 
-            // Cannot set auditor key without issuer key
-            mptAlice.set({
-                .account = alice,
-                .auditorPubKey = mptAlice.getPubKey(alice),
-                .err = temMALFORMED,
-            });
+            // Note: "auditor key without issuer key" (temMALFORMED before
+            // ConfidentialMPTKeyRotation) is covered in ConfidentialMPTKeyRotation_test
 
             // Cannot set Holder and issuer Keys in the same transaction
             mptAlice.set({
@@ -787,9 +783,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             });
         }
 
-        // Cannot update issuer public key once set
+        // Cannot update issuer public key once set (pre-ConfidentialMPTKeyRotation behavior)
         {
-            Env env{*this, features};
+            Env env{*this, features - featureConfidentialMPTKeyRotation};
             Account const alice("alice");
             Account const bob("bob");
             MPTTester mptAlice(env, alice, {.holders = {bob}});
@@ -819,8 +815,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         // Cannot update issuer and auditor public keys once set
         // Note: trying to set only auditor key fails in preflight (temMALFORMED)
         // so we must provide both keys, which fails on issuer key check first
+        // (pre-ConfidentialMPTKeyRotation behavior)
         {
-            Env env{*this, features};
+            Env env{*this, features - featureConfidentialMPTKeyRotation};
             Account const alice("alice");
             Account const bob("bob");
             Account const auditor("auditor");
@@ -900,8 +897,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         }
 
         // Set issuer key first, then auditor key in a separate tx
+        // (pre-ConfidentialMPTKeyRotation behavior)
         {
-            Env env{*this, features};
+            Env env{*this, features - featureConfidentialMPTKeyRotation};
             Account const alice("alice");
             Account const auditor("auditor");
             MPTTester mptAlice(env, alice, {.holders = {}, .auditor = auditor});
diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp
index c69e95a654..fae34a6a18 100644
--- a/src/test/app/Delegate_test.cpp
+++ b/src/test/app/Delegate_test.cpp
@@ -47,6 +47,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -2718,19 +2719,24 @@ class Delegate_test : public beast::unit_test::Suite
 
         std::size_t delegableCount = 0;
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, txDelegable, ...) \
-    if (txDelegable == Delegation::Delegable)           \
-    {                                                   \
-        delegableCount++;                               \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                 \
+    if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \
+    {                                                                                \
+        delegableCount++;                                                            \
     }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
         // ====================================================================
         // IMPORTANT NOTICE:
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..3a2bc14183 100644
--- a/src/test/app/EscrowToken_test.cpp
+++ b/src/test/app/EscrowToken_test.cpp
@@ -951,6 +951,99 @@ struct EscrowToken_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testIOUCancelReserveRecycle(FeatureBitset features)
+    {
+        testcase("IOU Cancel Reserve Recycle");
+        using namespace jtx;
+        using namespace std::literals;
+
+        // Escrowing the whole IOU balance lets the owner delete the now-zero
+        // trust line, so cancelling has to re-create it: one object destroyed,
+        // one created, and the reserve requirement unchanged.
+        Env env{*this, features};
+        bool const fixEnabled = env.current()->rules().enabled(fixCleanup3_4_0);
+
+        auto const baseFee = env.current()->fees().base;
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gw");
+        auto const usd = gw["USD"];
+
+        env.fund(XRP(10'000), alice, bob, gw);
+        env.close();
+
+        env(fset(gw, asfAllowTrustLineLocking));
+        env.close();
+
+        env.trust(usd(10'000), alice);
+        env.close();
+
+        env(pay(gw, alice, usd(10'000)));
+        env.close();
+        BEAST_EXPECT(env.ownerCount(alice) == 1);
+
+        auto const cancelAfter = env.now() + 100s;
+        auto const seq = env.seq(alice);
+        env(escrow::create(alice, bob, usd(10'000)),
+            escrow::kFinishTime(env.now() + 1s),
+            escrow::kCancelTime(cancelAfter),
+            Fee(baseFee));
+        env.close();
+        BEAST_EXPECT(env.ownerCount(alice) == 2);
+
+        auto const trustLineKey = keylet::trustLine(alice.id(), gw.id(), usd.currency);
+        env(trust(alice, usd(0)));
+        env.close();
+        BEAST_EXPECT(!env.current()->exists(trustLineKey));
+        BEAST_EXPECT(env.ownerCount(alice) == 1);
+
+        // Leave alice holding the reserve for exactly one owned object. That
+        // is the escrow now and the re-created trust line after the cancel.
+        auto const oneObject = env.current()->fees().accountReserve(1, 1);
+        auto const twoObjects = env.current()->fees().accountReserve(2, 1);
+        auto const balance = env.balance(alice).value().xrp();
+        auto const feeCushion = baseFee.drops() * 20;
+        env(pay(alice, bob, drops(balance.drops() - oneObject.drops() - feeCushion)));
+        env.close();
+        BEAST_EXPECT(env.balance(alice).value().xrp() >= oneObject);
+        BEAST_EXPECT(env.balance(alice).value().xrp() < twoObjects);
+
+        for (; env.now() < cancelAfter; env.close())
+        {
+        }
+        env.close();
+        env.close();
+
+        auto const expectedResult = fixEnabled ? Ter(tesSUCCESS) : Ter(tecNO_LINE_INSUF_RESERVE);
+        env(escrow::cancel(alice, alice, seq), Fee(baseFee), expectedResult);
+        env.close();
+
+        auto const escrowKey = keylet::escrow(alice.id(), SeqProxy::rawSequence(seq));
+        if (fixEnabled)
+        {
+            BEAST_EXPECT(!env.le(escrowKey));
+            BEAST_EXPECT(env.current()->exists(trustLineKey));
+            BEAST_EXPECT(env.balance(alice, usd) == usd(10'000));
+            BEAST_EXPECT(env.ownerCount(alice) == 1);
+        }
+        else
+        {
+            // The tec keeps the escrow, so one more owner reserve lets the
+            // retry through.
+            BEAST_EXPECT(env.le(escrowKey) != nullptr);
+            BEAST_EXPECT(!env.current()->exists(trustLineKey));
+            BEAST_EXPECT(env.ownerCount(alice) == 1);
+
+            env(pay(bob, alice, drops(twoObjects.drops() - oneObject.drops())));
+            env.close();
+            env(escrow::cancel(alice, alice, seq), Fee(baseFee), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(!env.le(escrowKey));
+            BEAST_EXPECT(env.balance(alice, usd) == usd(10'000));
+        }
+    }
+
     void
     testIOUBalances(FeatureBitset features)
     {
@@ -3749,6 +3842,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 +4320,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
         testMPTMetaAndOwnership(features);
         testMPTGateway(features);
         testMPTLockedRate(features);
+        testMPTLargeLockedRate(features);
         testMPTRequireAuth(features);
         testMPTLock(features);
         testMPTCanTransfer(features);
@@ -4069,6 +4343,8 @@ public:
         }
         testMPTSplitEscrowTransferFee(all - fixCleanup3_4_0);
         testMPTSplitEscrowTransferFee(all);
+        testIOUCancelReserveRecycle(all - fixCleanup3_4_0);
+        testIOUCancelReserveRecycle(all);
     }
 };
 
diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp
index a94834eb28..0f88814d4f 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -26,8 +26,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -637,6 +639,149 @@ struct FlowMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testMPTEndpointTransferRateOverflow(FeatureBitset features)
+    {
+        testcase("MPT Endpoint transfer rate overflow");
+
+        using namespace jtx;
+
+        Account const iouGW("iou_gateway");
+        Account const mptGW("mpt_gateway");
+        Account const alice("alice");
+        Account const bob("bob");
+
+        {
+            // Control: the same issuer-owned offer path works when the
+            // transfer-fee-adjusted input amount remains representable.
+            Env env(*this, features);
+
+            std::int64_t constexpr deliverAmount = 1'000'000'000'000'000'000LL;
+            std::int64_t constexpr offerAmount = deliverAmount + (deliverAmount / 2);
+
+            env.fund(XRP(10'000), iouGW, mptGW, alice, bob);
+            env.close();
+
+            auto const usd = iouGW["USD"];
+            env.trust(usd(offerAmount), alice);
+            env.trust(usd(offerAmount), mptGW);
+            env(pay(iouGW, alice, usd(offerAmount)));
+
+            MPTTester const mpt(
+                {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee});
+
+            env(offer(mptGW, usd(offerAmount), mpt(offerAmount)));
+
+            env(pay(alice, bob, mpt(deliverAmount)),
+                Path(~mpt),
+                Sendmax(usd(offerAmount)),
+                Txflags(tfNoRippleDirect | tfPartialPayment));
+
+            env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliverAmount)));
+            BEAST_EXPECT(!isOffer(env, mptGW, usd(offerAmount), mpt(offerAmount)));
+        }
+        {
+            // Regression: an extreme transfer-fee-adjusted MPT amount used to
+            // throw from MPTAmount::mulRatio during the endpoint reverse pass.
+            // The reverse pass now caps srcToDst at the largest amount whose
+            // transfer-fee-adjusted input is representable, so the offer limits
+            // the strand and a partial payment goes through.
+            Env env(*this, features);
+
+            std::int64_t constexpr overflowAmount = 7'000'000'000'000'000'000LL;
+            // The offer caps the input at overflowAmount, which the maximum
+            // transfer rate of 1.5 scales down to 7e18 * 2 / 3, rounded down
+            std::int64_t constexpr deliveredAmount = 4'666'666'666'666'666'666LL;
+
+            env.fund(XRP(10'000), iouGW, mptGW, alice, bob);
+            env.close();
+
+            auto const usd = iouGW["USD"];
+            env.trust(usd(overflowAmount), alice);
+            env.trust(usd(overflowAmount), mptGW);
+            env(pay(iouGW, alice, usd(overflowAmount)));
+
+            MPTTester const mpt(
+                {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee});
+
+            env(offer(mptGW, usd(overflowAmount), mpt(overflowAmount)));
+
+            env(pay(alice, bob, mpt(overflowAmount)),
+                Path(~mpt),
+                Sendmax(usd(overflowAmount)),
+                Txflags(tfNoRippleDirect | tfPartialPayment));
+
+            env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliveredAmount)));
+            BEAST_EXPECT(!isOffer(env, mptGW, usd(overflowAmount), mpt(overflowAmount)));
+        }
+    }
+
+    void
+    testMPTEndpointRipplingInputOverflow(FeatureBitset features)
+    {
+        // A payment between the holders of an MPT with a transfer fee ripples
+        // through the issuer, and the issuing step has to charge the transfer
+        // rate on the amount it receives. maxPaymentFlow() returns the issuance
+        // maximum for that step, so srcToDst * transferRate is not necessarily
+        // representable as an MPT amount. The reverse pass must cap the flow at
+        // the largest representable input instead of declaring the strand dry,
+        // otherwise a deliverable partial payment fails with tecPATH_DRY.
+        //
+        // Same defect as the case above, reached without an offer: holder ->
+        // issuer -> holder, one case per branch of the pre-fix revImp.
+        testcase("MPT Endpoint rippling input overflow");
+
+        using namespace jtx;
+
+        Account const gw("gateway");
+        Account const alice("alice");
+        Account const bob("bob");
+
+        // The maximum transfer fee gives a transfer rate of 1.5, so an input of
+        // kMaxMpTokenAmount covers at most kMaxMpTokenAmount * 2 / 3 of output.
+        std::int64_t constexpr maxRepresentable = 6'148'914'691'236'517'204LL;
+        std::int64_t constexpr aliceBalance = 1'000;
+        // The forward pass rounds the delivered amount down: 1000 / 1.5
+        std::int64_t constexpr bobBalance = 666;
+
+        auto const test =
+            [&](std::uint64_t maxAmt, std::int64_t deliver, std::string const& label) {
+                Env env(*this, features);
+                env.fund(XRP(10'000), gw, alice, bob);
+                env.close();
+
+                auto mpt = MPTTester(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, bob},
+                     .transferFee = kMaxTransferFee,
+                     .maxAmt = maxAmt});
+
+                env(pay(gw, alice, mpt(aliceBalance)));
+                env.close();
+
+                // alice asks to deliver more than the transfer rate can scale,
+                // so the issuing step caps the flow and her balance limits it
+                // further
+                env(pay(alice, bob, mpt(deliver)),
+                    Sendmax(mpt(kMaxMpTokenAmount)),
+                    Txflags(tfPartialPayment));
+                BEAST_EXPECTS(env.ter() == tesSUCCESS, label);
+                BEAST_EXPECTS(env.balance(alice, mpt) == mpt(0), label);
+                BEAST_EXPECTS(env.balance(bob, mpt) == mpt(bobBalance), label);
+                BEAST_EXPECTS(mpt.checkMPTokenOutstandingAmount(bobBalance), label);
+            };
+
+        // The requested amount is below MaximumAmount, so the reverse pass
+        // takes the non-limiting branch and overflows on the requested amount
+        test(kMaxMpTokenAmount, maxRepresentable + 1, "non-limiting");
+
+        // MaximumAmount is below the requested amount but still large enough
+        // that scaling it by the transfer rate is not representable, so the
+        // reverse pass takes the limiting branch and overflows on the maximum
+        test(maxRepresentable + 1, kMaxMpTokenAmount, "limiting");
+    }
+
     void
     testFalseDry(FeatureBitset features)
     {
@@ -742,6 +887,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)
     {
@@ -2112,6 +2415,103 @@ struct FlowMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testLockedMidPathHolder(FeatureBitset features)
+    {
+        // Regression: a cross-currency strand whose second book step
+        // consumes the offer of a holder that is locked on the step's
+        // in-asset (an MPT). The strand is XRP -> [book1: XRP/USD] ->
+        // USD -> [book2: USD/EUR] -> EUR, so book2 has book_.in == USD
+        // (an MPT) and its previous step is another BookStep. That is
+        // exactly the checkMPTDEX() branch that trusts the preceding
+        // BookStep and no longer re-checks isFrozen(owner, book_.in).
+        //
+        // The bypass the branch might appear to open does not exist:
+        // for MPT, isDeepFrozen() == isFrozen() (frozen MPTs can neither
+        // send nor receive), and OfferStream gates every offer through
+        // isDeepFrozen(owner, assetIn) before it can reach checkMPTDEX().
+        // So a locked mid-path holder's offer is removed by the liquidity
+        // source and the strand simply finds no liquidity at book2.
+        testcase("Locked mid-path holder behind a BookStep");
+
+        using namespace jtx;
+
+        Account const gw("gw");
+        Account const alice("alice");  // book1 (XRP/USD) offer owner
+        Account const mid("mid");      // book2 (USD/EUR) offer owner
+        Account const sam("sam");      // source
+        Account const bill("bill");    // destination
+
+        auto const test = [&](bool lock) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), gw, alice, mid, sam, bill);
+            env.close();
+
+            auto usd = MPTTester(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, mid},
+                 .flags = kMptDexFlags | tfMPTCanLock,
+                 .maxAmt = 1'000});
+            auto const eur = gw["EUR"];
+
+            // alice funds book1 (sells USD for XRP); mid funds book2
+            // (sells EUR for USD, i.e. receives the mid-path USD).
+            env(pay(gw, alice, usd(100)));
+            env(trust(mid, eur(100)));
+            env(pay(gw, mid, eur(100)));
+            env(trust(bill, eur(100)));
+            env.close();
+
+            env(offer(alice, XRP(100), usd(100)));  // XRP/USD, sells USD
+            env.close();
+            env(offer(mid, usd(100), eur(100)));  // USD/EUR, sells EUR
+            env.close();
+            BEAST_EXPECT(expectOffers(env, alice, 1));
+            BEAST_EXPECT(expectOffers(env, mid, 1));
+
+            // Lock mid on USD *after* its offer is already on the book:
+            // the reviewer's "frozen holder's offer sits behind a
+            // BookStep" scenario.
+            if (lock)
+            {
+                usd.set({.holder = mid, .flags = tfMPTLock});
+                env.close();
+            }
+
+            env(pay(sam, bill, eur(100)),
+                Sendmax(XRP(100)),
+                Path(~usd, ~eur),
+                Txflags(tfNoRippleDirect),
+                // book1 (XRP/USD) still has liquidity, so the strand is
+                // not fully dry; it just cannot cross book2 once mid's
+                // offer is removed, hence PARTIAL rather than DRY.
+                Ter(lock ? TER(tecPATH_PARTIAL) : TER(tesSUCCESS)));
+            env.close();
+
+            if (lock)
+            {
+                // No liquidity reached book2: mid neither received USD
+                // nor delivered EUR, so bill received nothing.
+                BEAST_EXPECT(env.balance(bill, eur) == eur(0));
+                BEAST_EXPECT(env.balance(mid, usd) == usd(0));
+            }
+            else
+            {
+                // The strand crosses both books: mid receives the
+                // mid-path USD and bill receives EUR.
+                BEAST_EXPECT(env.balance(bill, eur) == eur(100));
+                BEAST_EXPECT(env.balance(mid, usd) == usd(100));
+                BEAST_EXPECT(env.balance(alice, usd) == usd(0));
+                BEAST_EXPECT(expectOffers(env, alice, 0));
+                BEAST_EXPECT(expectOffers(env, mid, 0));
+            }
+        };
+
+        test(false);  // baseline: unlocked strand succeeds
+        test(true);   // locked mid-path holder: strand finds no liquidity
+    }
+
     void
     testWithFeats(FeatureBitset features)
     {
@@ -2121,7 +2521,10 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testFalseDry(features);
         testDirectStep(features);
         testBookStep(features);
+        testOfferOwnerMPTCreation(features);
         testTransferRate(features);
+        testMPTEndpointTransferRateOverflow(features);
+        testMPTEndpointRipplingInputOverflow(features);
         testSelfPayment1(features);
         testSelfPayment2(features);
         testSelfFundedXRPEndpoint(false, features);
@@ -2129,6 +2532,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testUnfundedOffer(features);
         testReExecuteDirectStep(features);
         testSelfPayLowQualityOffer(features);
+        testLockedMidPathHolder(features);
     }
 
     void
diff --git a/src/test/app/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
deleted file mode 100644
index ffdfe6bc83..0000000000
--- a/src/test/app/Invariants_test.cpp
+++ /dev/null
@@ -1,6260 +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 
-
-namespace xrpl {
-
-// Test-only factory — not part of the public API.
-// The returned Transactor holds a raw reference to ctx; the caller must ensure
-// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp
-std::unique_ptr
-makeTransactor(ApplyContext& ctx);
-
-}  // namespace xrpl
-
-namespace xrpl::test {
-
-class Invariants_test : public beast::unit_test::Suite
-{
-    // The optional Preclose function is used to process additional transactions
-    // on the ledger after creating two accounts, but before closing it, and
-    // before the Precheck function. These should only be valid functions, and
-    // not direct manipulations. Preclose is not commonly used.
-    using Preclose = std::function<
-        bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>;
-
-    // this is common setup/method for running a failing invariant check. The
-    // precheck function is used to manipulate the ApplyContext with view
-    // changes that will cause the check to fail.
-    using Precheck = std::function<
-        bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>;
-
-    static FeatureBitset
-    defaultAmendments()
-    {
-        return xrpl::test::jtx::testableAmendments() | fixCleanup3_1_3 | fixCleanup3_2_0;
-    }
-
-    test::jtx::Env
-    makeEnv(FeatureBitset features)
-    {
-        return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled};
-    }
-
-    /**
-     * Run a specific test case to put the ledger into a state that will be
-     * detected by an invariant. Simulates the actions of a transaction that
-     * would violate an invariant.
-     *
-     * @param expect_logs One or more messages related to the failing invariant
-     *  that should be in the log output
-     * @precheck See "Precheck" above
-     * @fee If provided, the fee amount paid by the simulated transaction.
-     * @tx A mock transaction that took the actions to trigger the invariant. In
-     *  most cases, only the type matters.
-     * @ters The TER results expected on the two passes of the invariant
-     *  checker.
-     * @preclose See "Preclose" above. Note that @preclose runs *before*
-     * @precheck, but is the last parameter for historical reasons
-     * @setTxAccount optionally set to add sfAccount to tx (either A1 or A2)
-     */
-    enum class TxAccount : int { None = 0, A1, A2 };
-    void
-    doInvariantCheck(
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-        Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
-    {
-        doInvariantCheck(
-            makeEnv(defaultAmendments()),
-            expectLogs,
-            precheck,
-            fee,
-            tx,
-            ters,
-            preclose,
-            setTxAccount);
-    }
-
-    void
-    doInvariantCheck(
-        test::jtx::Env&& env,
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-        Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
-    {
-        using namespace test::jtx;
-
-        Account const a1{"A1"};
-        Account const a2{"A2"};
-        env.fund(XRP(1000), a1, a2);
-        if (preclose)
-            BEAST_EXPECT(preclose(a1, a2, env));
-        env.close();
-
-        if (setTxAccount != TxAccount::None)
-            tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
-
-        doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters);
-    }
-
-    void
-    doInvariantCheck(
-        // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
-        test::jtx::Env&& env,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED})
-    {
-        using namespace test::jtx;
-
-        OpenView ov{*env.current()};
-        test::StreamSink sink{beast::Severity::Warning};
-        beast::Journal const jlog{sink};
-        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-
-        // Invariants normally run in the Transaction's "apply" (operator()) context, and can always
-        // access global Rules.
-        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-        BEAST_EXPECT(precheck(a1, a2, ac));
-
-        auto transactor = makeTransactor(ac);
-        if (!BEAST_EXPECT(transactor))
-            return;
-
-        // invoke check twice to cover tec and tef cases
-        if (!BEAST_EXPECT(ters.size() == 2))
-            return;
-
-        TER terActual = tesSUCCESS;
-        for (TER const& terExpect : ters)
-        {
-            terActual = transactor->checkInvariants(terActual, fee);
-            BEAST_EXPECTS(
-                terExpect == terActual,
-                "expected: " + transToken(terExpect) + " got: " + transToken(terActual));
-            auto const messages = sink.messages().str();
-
-            if (!isTesSuccess(terActual))
-            {
-                BEAST_EXPECTS(
-                    messages.starts_with("Invariant failed:") ||
-                        messages.starts_with("Transaction caused an exception"),
-                    messages);
-            }
-
-            // std::cerr << messages << '\n';
-            for (auto const& m : expectLogs)
-            {
-                BEAST_EXPECTS(messages.contains(m), m);
-            }
-        }
-    }
-
-    void
-    testXRPNotCreated()
-    {
-        using namespace test::jtx;
-        testcase << "XRP created";
-        doInvariantCheck(
-            {{"XRP net change was positive: 500"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // put a single account in the view and "manufacture" some XRP
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto amt = sle->getFieldAmount(sfBalance);
-                sle->setFieldAmount(sfBalance, amt + STAmount{500});
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    void
-    testAccountRootsNotRemoved()
-    {
-        using namespace test::jtx;
-        testcase << "account root removed";
-
-        // An account was deleted, but not by an AccountDelete transaction.
-        doInvariantCheck(
-            {{"an account root was deleted"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // remove an account from the view
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                ac.view().erase(sle);
-                return true;
-            });
-
-        // Successful AccountDelete transaction that didn't delete an account.
-        //
-        // Note that this is a case where a second invocation of the invariant
-        // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED.
-        // After a discussion with the team, we believe that's okay.
-        doInvariantCheck(
-            {{"account deletion succeeded without deleting an account"}},
-            [](Account const&, Account const&, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // Successful AccountDelete that deleted more than one account.
-        doInvariantCheck(
-            {{"account deletion succeeded but deleted multiple accounts"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // remove two accounts from the view
-                auto sleA1 = ac.view().peek(keylet::account(a1.id()));
-                auto sleA2 = ac.view().peek(keylet::account(a2.id()));
-                if (!sleA1 || !sleA2)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA2->at(sfBalance) = beast::kZero;
-                ac.view().erase(sleA1);
-                ac.view().erase(sleA2);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-    }
-
-    void
-    testAccountRootsDeletedClean()
-    {
-        using namespace test::jtx;
-        testcase << "account root deletion left artifact";
-
-        doInvariantCheck(
-            {{"account deletion left behind a non-zero balance"}},
-            // NOLINTNEXTLINE(readability-identifier-naming)
-            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                // A1 has a balance. Delete A1
-                auto const a1 = A1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1));
-                if (!sleA1)
-                    return false;
-                if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero))
-                    return false;
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a non-zero owner count"}},
-            // NOLINTNEXTLINE(readability-identifier-naming)
-            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                // Increment A1's owner count, then delete A1
-                auto const a1 = A1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1));
-                if (!sleA1)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sleA1->at(sfBalance) = beast::kZero;
-                BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0);
-                increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoredOwnerCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoringOwnerCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const a1Id = a1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1Id));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoringAccountCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setAccountID(sfSponsor, a2.id());
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            Env{*this, FeatureBitset{featureSponsor}},
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setAccountID(sfSponsor, a2.id());
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        for (auto const& keyletInfo : 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)
-                continue;
-            auto const& keyletfunc = keyletInfo.function;
-            auto const& type = keyletInfo.expectedLEName;
-
-            using namespace std::string_literals;
-
-            doInvariantCheck(
-                {{"account deletion left behind a "s + type.cStr() + " object"}},
-                // NOLINTNEXTLINE(readability-identifier-naming)
-                [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                    // Add an object to the ledger for account A1, then delete
-                    // A1
-                    auto const a1 = A1.id();
-                    auto sleA1 = ac.view().peek(keylet::account(a1));
-                    if (!sleA1)
-                        return false;
-
-                    auto const key = std::invoke(keyletfunc, a1);
-                    auto const newSLE = std::make_shared(key);
-                    ac.view().insert(newSLE);
-                    // Clear the balance so the "account deletion left behind a
-                    // non-zero balance" check doesn't trip earlier than the
-                    // desired check.
-                    sleA1->at(sfBalance) = beast::kZero;
-                    ac.view().erase(sleA1);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-        }
-
-        // NFT special case
-        doInvariantCheck(
-            {{"account deletion left behind a NFTokenPage object"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                // remove an account from the view
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const&, Env& env) {
-                // Preclose callback to mint the NFT which will be deleted in
-                // the Precheck callback above.
-                env(token::mint(a1));
-
-                return true;
-            });
-
-        // AMM special cases
-        AccountID ammAcctID;
-        uint256 ammKey;
-        Issue ammIssue;
-        doInvariantCheck(
-            {{"account deletion left behind a DirectoryNode object"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // Delete the AMM account without cleaning up the directory or
-                // deleting the AMM object
-                auto sle = ac.view().peek(keylet::account(ammAcctID));
-                if (!sle)
-                    return false;
-
-                BEAST_EXPECT(sle->at(~sfAMMID));
-                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
-
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                // Preclose callback to create the AMM which will be partially
-                // deleted in the Precheck callback above.
-                AMM const amm(env, a1, XRP(100), a1["USD"](50));
-                ammAcctID = amm.ammAccount();
-                ammKey = amm.ammID();
-                ammIssue = amm.lptIssue();
-                return true;
-            });
-        doInvariantCheck(
-            {{"account deletion left behind a AMM object"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // Delete all the AMM's trust lines, remove the AMM from the AMM
-                // account's directory (this deletes the directory), and delete
-                // the AMM account. Do not delete the AMM object.
-                auto sle = ac.view().peek(keylet::account(ammAcctID));
-                if (!sle)
-                    return false;
-
-                BEAST_EXPECT(sle->at(~sfAMMID));
-                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
-
-                for (auto const& trustKeylet :
-                     {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)})
-                {
-                    auto const line = ac.view().peek(trustKeylet);
-                    if (!line)
-                    {
-                        return false;
-                    }
-
-                    STAmount const lowLimit = line->at(sfLowLimit);
-                    STAmount const highLimit = line->at(sfHighLimit);
-                    BEAST_EXPECT(
-                        trustDelete(
-                            ac.view(),
-                            line,
-                            lowLimit.getIssuer(),
-                            highLimit.getIssuer(),
-                            ac.journal) == tesSUCCESS);
-                }
-
-                auto const ammSle = ac.view().peek(keylet::amm(ammKey));
-                if (!BEAST_EXPECT(ammSle))
-                    return false;
-                auto const ownerDirKeylet = keylet::ownerDir(ammAcctID);
-
-                BEAST_EXPECT(
-                    ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false));
-                BEAST_EXPECT(
-                    !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet));
-
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                // Preclose callback to create the AMM which will be partially
-                // deleted in the Precheck callback above.
-                AMM const amm(env, a1, XRP(100), a1["USD"](50));
-                ammAcctID = amm.ammAccount();
-                ammKey = amm.ammID();
-                ammIssue = amm.lptIssue();
-                return true;
-            });
-    }
-
-    void
-    testTypesMatch()
-    {
-        using namespace test::jtx;
-        testcase << "ledger entry types don't match";
-        doInvariantCheck(
-            {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // replace an entry in the table with an SLE of a different type
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto const sleNew = std::make_shared(ltTICKET, sle->key());
-                ac.rawView().rawReplace(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"invalid ledger entry type added"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // add an entry in the table with an SLE of an invalid type
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                // make a dummy escrow ledger entry, then change the type to an
-                // unsupported value so that the valid type invariant check
-                // will fail.
-                auto const sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                // We don't use ltNICKNAME directly since it's marked deprecated
-                // to prevent accidental use elsewhere.
-                sleNew->type_ = static_cast('n');
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoXRPTrustLine()
-    {
-        using namespace test::jtx;
-        testcase << "trust lines with XRP not allowed";
-        doInvariantCheck(
-            {{"an XRP trust line was created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create simple trust SLE with xrp currency
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency));
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoDeepFreezeTrustLinesWithoutFreeze()
-    {
-        using namespace test::jtx;
-        testcase << "trust lines with deep freeze flag without freeze "
-                    "not allowed";
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze | lsfHighFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowFreeze | lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testTransfersNotFrozen()
-    {
-        using namespace test::jtx;
-        testcase << "transfers when frozen";
-
-        Account const g1{"G1"};
-        // Helper function to establish the trustlines
-        auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) {
-            // Preclose callback to establish trust lines with gateway
-            env.fund(XRP(1000), g1);
-
-            env.trust(g1["USD"](10000), a1);
-            env.trust(g1["USD"](10000), a2);
-            env.close();
-
-            env(pay(g1, a1, g1["USD"](1000)));
-            env(pay(g1, a2, g1["USD"](1000)));
-            env.close();
-
-            return true;
-        };
-
-        auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
-            createTrustlines(a1, a2, env);
-            env(trust(g1, a1["USD"](10000), tfSetFreeze));
-            env.close();
-
-            return true;
-        };
-
-        auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
-            a1FrozenByIssuer(a1, a2, env);
-            env(trust(g1, a1["USD"](10000), tfSetDeepFreeze));
-            env.close();
-
-            return true;
-        };
-
-        auto const changeBalances = [&](Account const& a1,
-                                        Account const& a2,
-                                        ApplyContext& ac,
-                                        int a1Balance,
-                                        int a2Balance) {
-            auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"]));
-            auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"]));
-
-            sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance));
-            sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance));
-
-            ac.view().update(sleA1);
-            ac.view().update(sleA2);
-        };
-
-        // test: imitating frozen A1 making a payment to A2.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -900, -1100);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1FrozenByIssuer);
-
-        // test: imitating deep frozen A1 making a payment to A2.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -900, -1100);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1DeepFrozenByIssuer);
-
-        // test: imitating A2 making a payment to deep frozen A1.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -1100, -900);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1DeepFrozenByIssuer);
-    }
-
-    void
-    testXRPBalanceCheck()
-    {
-        using namespace test::jtx;
-        testcase << "XRP balance checks";
-
-        doInvariantCheck(
-            {{"Cannot return non-native STAmount as XRPAmount"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // non-native balance
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                STAmount const nonNative(a2["USD"](51));
-                sle->setFieldAmount(sfBalance, nonNative);
-                ac.view().update(sle);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}},
-            [this](Account const& a1, Account const&, ApplyContext& ac) {
-                // balance exceeds genesis amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Use `drops(1)` to bypass a call to STAmount::canonicalize
-                // with an invalid value
-                sle->setFieldAmount(sfBalance, kInitialXrp + drops(1));
-                BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative());
-                ac.view().update(sle);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"incorrect account XRP balance"},
-             {"XRP net change of -1000000001 doesn't match fee 0"}},
-            [this](Account const& a1, Account const&, ApplyContext& ac) {
-                // balance is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                sle->setFieldAmount(sfBalance, STAmount{1, true});
-                BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative());
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    void
-    testTransactionFeeCheck()
-    {
-        using namespace test::jtx;
-        using namespace std::string_literals;
-        testcase << "Transaction fee checks";
-
-        doInvariantCheck(
-            {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{-1});
-
-        doInvariantCheck(
-            {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)},
-             {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{kInitialXrp});
-
-        doInvariantCheck(
-            {{"fee paid is 20 exceeds fee specified in transaction."},
-             {"XRP net change of 0 doesn't match fee 20"}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{20},
-            STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }});
-    }
-
-    void
-    testNoBadOffers()
-    {
-        using namespace test::jtx;
-        testcase << "no bad offers";
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer with negative takerpays
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer with negative takergets
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleNew->setFieldAmount(sfTakerGets, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer XRP to XRP
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, XRP(10));
-                sleNew->setFieldAmount(sfTakerGets, XRP(11));
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoZeroEscrow()
-    {
-        using namespace test::jtx;
-        testcase << "no zero escrow";
-
-        doInvariantCheck(
-            {{"XRP net change of -1000000 doesn't match fee 0"},
-             {"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with negative amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-                sleNew->setFieldAmount(sfAmount, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"XRP net change was positive: 100000000000000001"},
-             {"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-large amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-                // Use `drops(1)` to bypass a call to STAmount::canonicalize
-                // with an invalid value
-                sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // IOU < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-little iou
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
-                STAmount const amt(usd, -1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // IOU bad currency
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with bad iou currency
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                Issue const bad{badCurrency(), AccountID(0x4985601)};
-                STAmount const amt(bad, 1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-little mpt
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                STAmount const amt(mpt, -1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT LockedAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance locked is less than locked
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfLockedAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount < LockedAmount
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is less than locked
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 1);
-                sleNew->setFieldU64(sfLockedAmount, 10);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT MPTAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptoken amount is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
-                sleNew->setFieldU64(sfMPTAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT LockedAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptoken locked amount is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
-                sleNew->setFieldU64(sfLockedAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testValidNewAccountRoot()
-    {
-        using namespace test::jtx;
-        testcase << "valid new account root";
-
-        doInvariantCheck(
-            {{"account root created illegally"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert a new account root created by a non-payment into
-                // the view.
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"multiple accounts created in a single transaction"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert two new account roots into the view.
-                {
-                    Account const a3{"A3"};
-                    Keylet const acctKeylet = keylet::account(a3);
-                    auto const sleA3 = std::make_shared(acctKeylet);
-                    ac.view().insert(sleA3);
-                }
-                {
-                    Account const a4{"A4"};
-                    Keylet const acctKeylet = keylet::account(a4);
-                    auto const sleA4 = std::make_shared(acctKeylet);
-                    ac.view().insert(sleA4);
-                }
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"account created with wrong starting sequence number"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert a new account root with the wrong starting sequence.
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, ac.view().seq() + 1);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created by a wrong transaction type"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account created with wrong starting sequence number"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, ac.view().seq());
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_CREATE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created with wrong flags"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created with wrong flags"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(
-                    sfFlags,
-                    lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_CREATE, [](STObject& tx) {}});
-    }
-
-    void
-    testNFTokenPageInvariants()
-    {
-        using namespace test::jtx;
-        testcase << "NFTokenPage";
-
-        // lambda that returns an STArray of NFTokenIDs.
-        uint256 const firstNFTID(
-            "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000");
-        auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) {
-            SOTemplate const* nfTokenTemplate =
-                InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken);
-
-            uint256 nftID(firstNFTID);
-            STArray ret;
-            for (int i = 0; i < nftCount; ++i)
-            {
-                STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) {
-                    object.setFieldH256(sfNFTokenID, nftID);
-                });
-                ret.pushBack(std::move(newNFToken));
-                ++nftID;
-            }
-            return ret;
-        };
-
-        doInvariantCheck(
-            {{"NFT page has invalid size"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0));
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page has invalid size"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33));
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFTs on page are not sorted"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(2);
-                std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1);
-
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT contains empty URI"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(1);
-                nfTokens[0].setFieldVL(sfURI, Blob{});
-
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfNextPageMin, nftPage->key());
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(1);
-                auto nftPage = std::make_shared(keylet::nftokenPage(
-                    keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-                nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT found in incorrect page"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(2);
-                auto nftPage = std::make_shared(keylet::nftokenPage(
-                    keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-    }
-
-    void
-    testAMMDeleteInvariants(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const enforceAMMDelete = features[fixCleanup3_3_0];
-        testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : "");
-
-        Env env(*this, features);
-        Account const issuer{"issuer"};
-        Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()};
-        STAmount const zeroLP{lptIssue, 0};
-        STAmount const nonZeroLP{lptIssue, 1};
-
-        auto const makeAMM = [](STAmount const& lptBalance) {
-            auto sleAMM = std::make_shared(keylet::amm(uint256(1)));
-            sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance);
-            return sleAMM;
-        };
-
-        auto const checkInvariant = [&](TxType txType,
-                                        TER result,
-                                        std::optional const& deletedLPBalance,
-                                        bool expected,
-                                        std::string const& expectedLog) {
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            ValidAMM invariant;
-
-            if (deletedLPBalance)
-                invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr);
-
-            bool const actual = invariant.finalize(
-                STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog);
-
-            BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result");
-            auto const messages = sink.messages().str();
-            auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : "";
-            if (!expectedLogWhenEnforced.empty())
-            {
-                BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced);
-            }
-            else
-            {
-                BEAST_EXPECTS(messages.empty(), messages);
-            }
-        };
-
-        checkInvariant(
-            ttPAYMENT,
-            tesSUCCESS,
-            nonZeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMM failed, unexpected AMM deletion by");
-        checkInvariant(
-            ttAMM_DELETE,
-            tesSUCCESS,
-            std::nullopt,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS");
-        checkInvariant(
-            ttAMM_DELETE,
-            tesSUCCESS,
-            nonZeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance");
-        checkInvariant(
-            ttAMM_DELETE,
-            tecINCOMPLETE,
-            zeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS");
-
-        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, "");
-        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, "");
-
-        checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, "");
-        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, "");
-        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, "");
-    }
-
-    static SLE::pointer
-    createPermissionedDomain(
-        ApplyContext& ac,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::uint32_t numCreds = 2,
-        std::uint32_t seq = 10)
-    {
-        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq));
-        auto sle = std::make_shared(pdKeylet);
-
-        sle->setAccountID(sfOwner, a1);
-        sle->setFieldU32(sfSequence, seq);
-
-        if (numCreds != 0u)
-        {
-            // This array is sorted naturally, but if you are going to change
-            // this behavior, don't forget to use credentials::makeSorted
-            STArray credentials(sfAcceptedCredentials, numCreds);
-            for (std::size_t n = 0; n < numCreds; ++n)
-            {
-                auto cred = STObject::makeInnerObject(sfCredential);
-                cred.setAccountID(sfIssuer, a2);
-                auto credType = "cred_type" + std::to_string(n);
-                cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                credentials.pushBack(std::move(cred));
-            }
-            sle->setFieldArray(sfAcceptedCredentials, credentials);
-        }
-
-        ac.view().insert(sle);
-        return sle;
-    };
-
-    void
-    testPermissionedDomainInvariants(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const fixEnabled = features[fixCleanup3_1_3];
-        std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED};
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-
-        testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : "");
-
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain with no rules."}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                return createPermissionedDomain(ac, a1, a2, 0).get();
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 2";
-
-        static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1;
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                return !!createPermissionedDomain(ac, a1, a2, kTooBig);
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 3";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't sorted"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
-
-                STArray credentials(sfAcceptedCredentials, 2);
-                for (std::size_t n = 0; n < 2; ++n)
-                {
-                    auto cred = STObject::makeInnerObject(sfCredential);
-                    cred.setAccountID(sfIssuer, a2);
-                    auto credType = std::string("cred_type") + std::to_string(9 - n);
-                    cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                    credentials.pushBack(std::move(cred));
-                }
-                slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                ac.view().update(slePd);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 4";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't unique"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
-
-                STArray credentials(sfAcceptedCredentials, 2);
-                for (std::size_t n = 0; n < 2; ++n)
-                {
-                    auto cred = STObject::makeInnerObject(sfCredential);
-                    cred.setAccountID(sfIssuer, a2);
-                    cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
-                    credentials.pushBack(std::move(cred));
-                }
-                slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                ac.view().update(slePd);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 1";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain with no rules."}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD with empty rules
-                {
-                    STArray const credentials(sfAcceptedCredentials, 2);
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 2";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, kTooBig);
-
-                    for (std::size_t n = 0; n < kTooBig; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        auto credType = "cred_type2" + std::to_string(n);
-                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                        credentials.pushBack(std::move(cred));
-                    }
-
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 3";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't sorted"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, 2);
-                    for (std::size_t n = 0; n < 2; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        auto credType = std::string("cred_type2") + std::to_string(9 - n);
-                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                        credentials.pushBack(std::move(cred));
-                    }
-
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 4";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't unique"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, 2);
-                    for (std::size_t n = 0; n < 2; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
-                        credentials.pushBack(std::move(cred));
-                    }
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS};
-
-        std::vector const badMoreThan1{
-            {"transaction affected more than 1 permissioned domain entry."}};
-        std::vector const emptyV;
-        std::vector const badNoDomains{{"no domain objects affected by"}};
-        std::vector const badNotDeleted{
-            {"domain object modified, but not deleted by "}};
-        std::vector const badDeleted{{"domain object deleted by"}};
-        std::vector const badTx{
-            {"domain object(s) affected by an unauthorized transaction."}};
-
-        {
-            testcase << "PermissionedDomain set 2 domains ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badMoreThan1 : emptyV,
-                [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    createPermissionedDomain(ac, a1, a2, 2, 11);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del 2 domains";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? badMoreThan1 : emptyV,
-                [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
-                    auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2});
-                    ac.view().erase(sle1);
-                    ac.view().erase(sle2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain set 0 domains ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badNoDomains : emptyV,
-                [](Account const&, Account const&, ApplyContext&) { return true; },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? badTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del 0 domains";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                makeEnv(features),
-                a1,
-                a2,
-                fixEnabled ? badNoDomains : emptyV,
-                [](Account const&, Account const&, ApplyContext&) { return true; },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? badTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain set, delete domain";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? badDeleted : emptyV,
-                [&pd1](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
-                    ac.view().erase(sle1);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del, create domain ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badNotDeleted : emptyV,
-                [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain invalid tx";
-
-            doInvariantCheck(
-                fixEnabled ? badTx : emptyV,
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPAYMENT, [](STObject&) {}},
-                failTers);
-        }
-    }
-
-    void
-    testValidPseudoAccounts()
-    {
-        testcase << "valid pseudo accounts";
-
-        using namespace jtx;
-
-        AccountID pseudoAccountID;
-        Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) {
-            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-            // Create vault
-            Vault const vault{env};
-            auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset});
-            env(tx);
-            env.close();
-            if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle))
-            {
-                pseudoAccountID = vSle->at(sfAccount);
-            }
-
-            return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID)));
-        };
-
-        /* Cases to check
-            "pseudo-account has 0 pseudo-account fields set"
-            "pseudo-account has 2 pseudo-account fields set"
-            "pseudo-account sequence changed"
-            "pseudo-account flags are not set"
-            "pseudo-account has a regular key"
-            "pseudo-account has a sponsorship field"
-        */
-        struct Mod
-        {
-            std::string expectedFailure;
-            std::function func;
-        };
-        auto const mods = std::to_array({
-            {
-                .expectedFailure = "pseudo-account has 0 pseudo-account fields set",
-                .func =
-                    [this](SLE::pointer& sle) {
-                        BEAST_EXPECT(sle->at(~sfVaultID));
-                        sle->at(~sfVaultID) = std::nullopt;
-                    },
-            },
-            {
-                .expectedFailure = "pseudo-account sequence changed",
-                .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; },
-            },
-            {
-                .expectedFailure = "pseudo-account flags are not set",
-                .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a regular key",
-                .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); },
-            },
-        });
-
-        for (auto const& mod : mods)
-        {
-            doInvariantCheck(
-                {{mod.expectedFailure}},
-                [&](Account const& a1, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
-                    if (!sle)
-                        return false;
-                    mod.func(sle);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createPseudo);
-        }
-        for (auto const pField : getPseudoAccountFields())
-        {
-            // createPseudo creates a vault, so sfVaultID will be set, and
-            // setting it again will not cause an error
-            if (pField == &sfVaultID)
-                continue;
-            doInvariantCheck(
-                {{"pseudo-account has 2 pseudo-account fields set"}},
-                [&](Account const& a1, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
-                    if (!sle)
-                        return false;
-
-                    auto const vaultID = ~sle->at(~sfVaultID);
-                    BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField));
-                    sle->setFieldH256(*pField, *vaultID);
-
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createPseudo);
-        }
-
-        // Take one of the regular accounts and set the sequence to 0, which
-        // will make it look like a pseudo-account
-        doInvariantCheck(
-            {{"pseudo-account has 0 pseudo-account fields set"},
-             {"pseudo-account sequence changed"},
-             {"pseudo-account flags are not set"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                sle->at(sfSequence) = 0;
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    static std::pair
-    createPermissionedDomainEnv(
-        test::jtx::Env& env,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::uint32_t numCreds = 2)
-    {
-        using namespace test::jtx;
-
-        pdomain::Credentials credentials;
-
-        for (std::size_t n = 0; n < numCreds; ++n)
-        {
-            auto credType = "cred_type" + std::to_string(n);
-            credentials.push_back({.issuer = a2, .credType = credType});
-        }
-
-        std::uint32_t const seq = env.seq(a1);
-        env(pdomain::setTx(a1, credentials));
-        uint256 const key = pdomain::getNewDomain(env.meta());
-
-        // std::cout << "PD, acc: " << A1.id() << ", seq: " << seq << ", k: " <<
-        // key << std::endl;
-        return {seq, key};
-    }
-
-    void
-    testPermissionedDEX(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const fixEnabled = features[fixCleanup3_1_3];
-
-        testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : "");
-
-        doInvariantCheck(
-            makeEnv(features),
-            {{"domain doesn't exist"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10));
-                auto sleOffer = std::make_shared(offerKey);
-                sleOffer->setAccountID(sfAccount, a1);
-                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                ac.view().insert(sleOffer);
-                return true;
-            },
-            XRPAmount{},
-            STTx{
-                ttOFFER_CREATE,
-                [](STObject& tx) {
-                    tx.setFieldH256(
-                        sfDomainID,
-                        uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33"
-                                "70F3649CE134E5"});
-                    Account const a1{"A1"};
-                    tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                    tx.setFieldAmount(sfTakerGets, XRP(1));
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // missing domain ID in offer object
-        doInvariantCheck(
-            makeEnv(features),
-            {{"hybrid offer is malformed"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                auto sleOffer = std::make_shared(offerKey);
-                sleOffer->setAccountID(sfAccount, a2);
-                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                sleOffer->setFlag(lsfHybrid);
-
-                STArray bookArr;
-                bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                ac.view().insert(sleOffer);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttOFFER_CREATE, [&](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // more than one entry in sfAdditionalBooks
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"hybrid offer is malformed"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-
-                    STArray bookArr;
-                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        // empty sfAdditionalBooks (size 0)
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? std::vector{{"hybrid offer is malformed"}}
-                           : std::vector{},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-
-                    STArray const bookArr;  // empty array, size 0
-                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED}
-                           : std::initializer_list{tesSUCCESS, tesSUCCESS});
-        }
-
-        // hybrid offer missing sfAdditionalBooks
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"hybrid offer is malformed"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"transaction consumed wrong domains"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttOFFER_CREATE,
-                    [&pd2, &a1](STObject& tx) {
-                        tx.setFieldH256(sfDomainID, pd2);
-                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                        tx.setFieldAmount(sfTakerGets, XRP(1));
-                    }},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"domain transaction affected regular offers"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttOFFER_CREATE,
-                    [&](STObject& tx) {
-                        Account const a1{"A1"};
-                        tx.setFieldH256(sfDomainID, pd1);
-                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                        tx.setFieldAmount(sfTakerGets, XRP(1));
-                    }},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-    }
-
-    void
-    testBookDirectoryExchangeRate()
-    {
-        using namespace test::jtx;
-        testcase << "book directory exchange rate";
-
-        auto const getBookRootKey = [](Account const& account, std::uint64_t quality) {
-            Book const book{xrpIssue(), account["USD"], std::nullopt};
-            return keylet::quality(keylet::book(book), quality);
-        };
-
-        // Root book-directory pages carry exchange-rate metadata that must
-        // match the quality encoded in the directory key.
-        auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) {
-            auto sleDir = std::make_shared(dir);
-            sleDir->setFieldH256(sfRootIndex, dir.key);
-            STVector256 indexes;
-            indexes.pushBack(uint256{1});
-            sleDir->setFieldV256(sfIndexes, indexes);
-            sleDir->setFieldU64(sfExchangeRate, exchangeRate);
-            return sleDir;
-        };
-
-        // Child pages do not carry quality metadata; they only point back to
-        // the root directory.
-        auto const makeChildPage = [](Keylet const& rootDir) {
-            auto sleDir = std::make_shared(keylet::page(rootDir, 1));
-            sleDir->setFieldH256(sfRootIndex, rootDir.key);
-            STVector256 indexes;
-            indexes.pushBack(uint256{2});
-            sleDir->setFieldV256(sfIndexes, indexes);
-            return sleDir;
-        };
-
-        auto const makeOfferCreateTx = [] {
-            return STTx{ttOFFER_CREATE, [](STObject& tx) {
-                            Account const account{"A1"};
-                            tx.setFieldAmount(sfTakerPays, XRP(1));
-                            tx.setFieldAmount(sfTakerGets, account["USD"](1));
-                        }};
-        };
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-
-        // Creating a root book directory with mismatched exchange-rate
-        // metadata violates the invariant.
-        doInvariantCheck(
-            {{"book directory exchange rate does not match directory quality"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const directoryQuality = STAmount::kURateOne;
-                auto const dir = getBookRootKey(a1, directoryQuality);
-                ac.view().insert(makeRootPage(dir, directoryQuality + 1));
-                return true;
-            },
-            XRPAmount{},
-            makeOfferCreateTx(),
-            failTers);
-
-        // A new child page must point to an existing root page.
-        doInvariantCheck(
-            {{"book directory root missing"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const directoryQuality = STAmount::kURateOne;
-                auto const rootDir = getBookRootKey(a1, directoryQuality);
-                // Insert only the child page.  It points at rootDir, but the
-                // corresponding root page is intentionally missing.
-                ac.view().insert(makeChildPage(rootDir));
-                return true;
-            },
-            XRPAmount{},
-            makeOfferCreateTx(),
-            failTers);
-
-        // Legacy bad-root tolerance:
-        // - The view contains a pre-existing root page with bad sfExchangeRate
-        //   metadata.
-        // - The simulated transaction only creates a child page pointing to
-        //   that root.
-        // - The invariant must pass because this transaction did not create
-        //   the bad root, only adding a child page.
-        {
-            Env env{*this, defaultAmendments()};
-            Account const a1{"A1"};
-            env.fund(XRP(1000), a1);
-            env.close();
-
-            OpenView view{*env.current()};
-            auto const directoryQuality = STAmount::kURateOne;
-            auto const rootDir = getBookRootKey(a1, directoryQuality);
-            view.rawInsert(makeRootPage(rootDir, directoryQuality + 1));
-
-            ValidBookDirectory invariant;
-            invariant.visitEntry(false, nullptr, makeChildPage(rootDir));
-
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            BEAST_EXPECT(
-                invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-        }
-
-        // A bad root is rejected when added, ignored when a legacy bad root is
-        // modified without changing sfRootIndex or deleted, and checked when a
-        // modified directory changes sfRootIndex.
-        {
-            Env env{*this, defaultAmendments()};
-            Account const a1{"A1"};
-            env.fund(XRP(1000), a1);
-            env.close();
-
-            OpenView view{*env.current()};
-            auto const directoryQuality = STAmount::kURateOne;
-            auto const rootDir = getBookRootKey(a1, directoryQuality);
-            auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1);
-            auto const badRoot = makeRootPage(rootDir, directoryQuality + 1);
-            view.rawInsert(badRoot);
-
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-
-            {
-                // add
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, nullptr, badRoot);
-
-                BEAST_EXPECT(
-                    !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-            {
-                // modify (without changing the sfRootIndex)
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, badRoot, badRoot);
-
-                BEAST_EXPECT(
-                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-            {
-                // modify (changing sfRootIndex to a missing root)
-                auto const childBefore = makeChildPage(rootDir);
-                auto const childAfter = std::make_shared(*childBefore, childBefore->key());
-                childAfter->setFieldH256(sfRootIndex, missingRootDir.key);
-
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, childBefore, childAfter);
-
-                test::StreamSink missingRootSink{beast::Severity::Warning};
-                beast::Journal const missingRootJlog{missingRootSink};
-                BEAST_EXPECT(!invariant.finalize(
-                    makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog));
-                BEAST_EXPECT(
-                    missingRootSink.messages().str().contains("book directory root missing"));
-            }
-            {
-                // delete
-                view.rawErase(badRoot);
-                BEAST_EXPECT(!view.exists(rootDir));
-
-                ValidBookDirectory invariant;
-                invariant.visitEntry(true, badRoot, badRoot);
-                BEAST_EXPECT(
-                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-        }
-    }
-
-    Keylet
-    createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset)
-    {
-        using namespace jtx;
-
-        // Create vault
-        uint256 vaultID;
-        Vault const vault{env};
-        auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset});
-        env(tx);
-        BEAST_EXPECT(env.le(vKeylet));
-
-        vaultID = vKeylet.key;
-
-        // Create Loan Broker
-        using namespace loan_broker;
-
-        auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a)));
-        // Create a Loan Broker with all default values.
-        env(set(a, vaultID), Fee(kIncrement));
-
-        return loanBrokerKeylet;
-    };
-
-    void
-    testNoModifiedUnmodifiableFields()
-    {
-        testcase("no modified unmodifiable fields");
-        using namespace jtx;
-
-        // Initialize with a placeholder value because there's no default ctor
-        Keylet loanBrokerKeylet = keylet::amendments();
-        Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) {
-            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-            loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset);
-            return BEAST_EXPECT(env.le(loanBrokerKeylet));
-        };
-
-        {
-            auto const mods = std::to_array>({
-                [](SLE::pointer& sle) { sle->at(sfSequence) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); },
-                [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); },
-                [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); },
-                [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); },
-            });
-
-            for (auto const& mod : mods)
-            {
-                doInvariantCheck(
-                    {{"changed an unchangeable field"}},
-                    [&](Account const& a1, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(loanBrokerKeylet);
-                        if (!sle)
-                            return false;
-                        mod(sle);
-                        ac.view().update(sle);
-                        return true;
-                    },
-                    XRPAmount{},
-                    STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    createLoanBroker);
-            }
-        }
-
-        // TODO: Loan Object
-
-        {
-            auto const mods = std::to_array>({
-                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); },
-            });
-
-            for (auto const& mod : mods)
-            {
-                doInvariantCheck(
-                    {{"changed an unchangeable field"}},
-                    [&](Account const& a1, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(keylet::account(a1.id()));
-                        if (!sle)
-                            return false;
-                        mod(sle);
-                        ac.view().update(sle);
-                        return true;
-                    });
-            }
-        }
-    }
-
-    void
-    testValidLoanBroker()
-    {
-        testcase << "valid loan broker";
-
-        using namespace jtx;
-
-        enum class Asset { XRP, IOU, MPT };
-        auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT});
-
-        for (auto const assetType : assetTypes)
-        {
-            // Initialize with a placeholder value because there's no default
-            // ctor
-            auto const setupAsset =
-                [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset {
-                switch (assetType)
-                {
-                    case Asset::IOU: {
-                        PrettyAsset const iouAsset = issuer["IOU"];
-                        env(trust(alice, iouAsset(1000)));
-                        env(pay(issuer, alice, iouAsset(1000)));
-                        env.close();
-                        return iouAsset;
-                    }
-                    case Asset::MPT: {
-                        MPTTester mptt{env, issuer, kMptInitNoFund};
-                        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-                        PrettyAsset const mptAsset = mptt.issuanceID();
-                        mptt.authorize({.account = alice});
-                        env(pay(issuer, alice, mptAsset(1000)));
-                        env.close();
-                        return mptAsset;
-                    }
-                    case Asset::XRP:
-                    default:
-                        return PrettyAsset{xrpIssue(), 1'000'000};
-                }
-            };
-
-            Keylet loanBrokerKeylet = keylet::amendments();
-            Preclose const createLoanBroker =
-                [&, this](Account const& alice, Account const& issuer, Env& env) {
-                    auto const asset = setupAsset(alice, issuer, env);
-                    loanBrokerKeylet = this->createLoanBroker(alice, env, asset);
-                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
-                };
-
-            // Ensure the test scenarios are set up completely. The test cases
-            // will need to recompute any of these values it needs for itself
-            // rather than trying to return a bunch of items
-            auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac)
-                -> std::optional> {
-                if (loanBrokerKeylet.type != ltLOAN_BROKER)
-                    return {};
-                auto sleBroker = ac.view().peek(loanBrokerKeylet);
-                if (!sleBroker)
-                    return {};
-                if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0))
-                    return {};
-                // Need to touch sleBroker so that it is included in the
-                // modified entries for the invariant to find
-                ac.view().update(sleBroker);
-
-                // The pseudo-account holds the directory, so get it
-                auto const pseudoAccountID = sleBroker->at(sfAccount);
-                auto const pseudoAccountKeylet = keylet::account(pseudoAccountID);
-                // Strictly speaking, we don't need to load the
-                // ACCOUNT_ROOT, but check anyway
-                auto slePseudo = ac.view().peek(pseudoAccountKeylet);
-                if (!BEAST_EXPECT(slePseudo))
-                    return {};
-                // Make sure the directory doesn't already exist
-                auto const dirKeylet = keylet::ownerDir(pseudoAccountID);
-                auto sleDir = ac.view().peek(dirKeylet);
-                auto const describe = describeOwnerDir(pseudoAccountID);
-                if (!sleDir)
-                {
-                    // Create the directory
-                    BEAST_EXPECT(
-                        ::xrpl::directory::createRoot(
-                            ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0);
-
-                    sleDir = ac.view().peek(dirKeylet);
-                }
-
-                return std::make_pair(slePseudo, sleDir);
-            };
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has multiple directory "
-                  "pages"}},
-                [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
-
-                    BEAST_EXPECT(
-                        ::xrpl::directory::insertPage(
-                            ac.view(),
-                            0,
-                            sleDir,
-                            0,
-                            sleDir,
-                            slePseudo->key(),
-                            keylet::page(sleDir->key(), 0),
-                            describe) == 1);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has multiple indexes in "
-                  "the Directory root"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto indexes = sleDir->getFieldV256(sfIndexes);
-
-                    // Put some extra garbage into the directory
-                    for (auto const& key : {slePseudo->key(), sleDir->key()})
-                    {
-                        ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
-                    }
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker directory corrupt"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
-                    // Empty vector will overwrite the existing entry for the
-                    // holding, if any, avoiding the "has multiple indexes"
-                    // failure.
-                    STVector256 indexes;
-
-                    // Put one meaningless key into the directory
-                    auto const key = keylet::account(Account("random").id()).key;
-                    ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has an unexpected entry in "
-                  "the directory"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    // Empty vector will overwrite the existing entry for the
-                    // holding, if any, avoiding the "has multiple indexes"
-                    // failure.
-                    STVector256 indexes;
-
-                    ::xrpl::directory::insertKey(
-                        ac.view(), sleDir, 0, false, indexes, slePseudo->key());
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker sequence number decreased"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    if (loanBrokerKeylet.type != ltLOAN_BROKER)
-                        return false;
-                    auto sleBroker = ac.view().peek(loanBrokerKeylet);
-                    if (!sleBroker)
-                        return false;
-                    if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0))
-                        return false;
-                    // Need to touch sleBroker so that it is included in the
-                    // modified entries for the invariant to find
-                    ac.view().update(sleBroker);
-
-                    sleBroker->at(sfLoanSequence) -= 1;
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            // Test: cover available less than pseudo-account asset balance
-            {
-                Keylet brokerKeylet = keylet::amendments();
-                Preclose const createBrokerWithCover =
-                    [&, this](Account const& alice, Account const& issuer, Env& env) {
-                        auto const asset = setupAsset(alice, issuer, env);
-                        brokerKeylet = this->createLoanBroker(alice, env, asset);
-                        if (!BEAST_EXPECT(env.le(brokerKeylet)))
-                            return false;
-                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
-                        env.close();
-                        return BEAST_EXPECT(env.le(brokerKeylet));
-                    };
-
-                doInvariantCheck(
-                    {{"Loan Broker cover available is less than pseudo-account asset balance"}},
-                    [&](Account const&, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(brokerKeylet);
-                        if (!BEAST_EXPECT(sle))
-                            return false;
-                        // Pseudo-account holds 10 units, set cover to 5
-                        sle->at(sfCoverAvailable) = Number(5);
-                        ac.view().update(sle);
-                        return true;
-                    },
-                    XRPAmount{},
-                    STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    createBrokerWithCover);
-            }
-
-            // Test: cover available greater than pseudo-account asset balance
-            // (requires fixCleanup3_1_3)
-            doInvariantCheck(
-                {{"Loan Broker cover available is greater than pseudo-account asset balance"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(loanBrokerKeylet);
-                    if (!BEAST_EXPECT(sle))
-                        return false;
-                    // Pseudo-account has no cover deposited; set cover
-                    // higher than any incidental balance
-                    sle->at(sfCoverAvailable) = Number(1'000'000);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-        }
-    }
-
-    void
-    testVault()  // NOLINT(readability-function-size)
-    {
-        using namespace test::jtx;
-
-        struct AccountAmount
-        {
-            AccountID account;
-            int amount;
-        };
-        struct Adjustments
-        {
-            // NOLINTBEGIN(readability-redundant-member-init)
-            std::optional assetsTotal = std::nullopt;
-            std::optional assetsAvailable = std::nullopt;
-            std::optional lossUnrealized = std::nullopt;
-            std::optional assetsMaximum = std::nullopt;
-            std::optional sharesTotal = std::nullopt;
-            std::optional vaultAssets = std::nullopt;
-            std::optional accountAssets = std::nullopt;
-            std::optional accountShares = std::nullopt;
-            // NOLINTEND(readability-redundant-member-init)
-        };
-        constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
-            auto sleVault = ac.peek(keylet);
-            if (!sleVault)
-                return false;
-
-            auto const mptIssuanceID = (*sleVault)[sfShareMPTID];
-            auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID));
-            if (!sleShares)
-                return false;
-
-            // These two fields are adjusted in absolute terms
-            if (args.lossUnrealized)
-                (*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
-            if (args.assetsMaximum)
-                (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
-
-            // Remaining fields are adjusted in terms of difference
-            if (args.assetsTotal)
-                (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal;
-            if (args.assetsAvailable)
-            {
-                (*sleVault)[sfAssetsAvailable] =
-                    *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable;
-            }
-            ac.update(sleVault);
-
-            if (args.sharesTotal)
-            {
-                (*sleShares)[sfOutstandingAmount] =
-                    *(*sleShares)[sfOutstandingAmount] + *args.sharesTotal;
-                ac.update(sleShares);
-            }
-
-            auto const assets = *(*sleVault)[sfAsset];
-            auto const pseudoId = *(*sleVault)[sfAccount];
-            if (args.vaultAssets)
-            {
-                if (assets.native())
-                {
-                    auto slePseudoAccount = ac.peek(keylet::account(pseudoId));
-                    if (!slePseudoAccount)
-                        return false;
-                    (*slePseudoAccount)[sfBalance] =
-                        *(*slePseudoAccount)[sfBalance] + *args.vaultAssets;
-                    ac.update(slePseudoAccount);
-                }
-                else if (assets.holds())
-                {
-                    auto const mptId = assets.get().getMptID();
-                    auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId));
-                    if (!sleMPToken)
-                        return false;
-                    (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + *args.vaultAssets;
-                    ac.update(sleMPToken);
-                }
-                else
-                {
-                    return false;  // Not supporting testing with IOU
-                }
-            }
-
-            if (args.accountAssets)
-            {
-                auto const& pair = *args.accountAssets;
-                if (assets.native())
-                {
-                    auto sleAccount = ac.peek(keylet::account(pair.account));
-                    if (!sleAccount)
-                        return false;
-                    (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount;
-                    ac.update(sleAccount);
-                }
-                else if (assets.holds())
-                {
-                    auto const mptID = assets.get().getMptID();
-                    auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account));
-                    if (!sleMPToken)
-                        return false;
-                    (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount;
-                    ac.update(sleMPToken);
-                }
-                else
-                {
-                    return false;  // Not supporting testing with IOU
-                }
-            }
-
-            if (args.accountShares)
-            {
-                auto const& pair = *args.accountShares;
-                auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account));
-                if (!sleMPToken)
-                    return false;
-                (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount;
-                ac.update(sleMPToken);
-            }
-            return true;
-        };
-
-        static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments {
-            Adjustments sample = {
-                .assetsTotal = adjustment,
-                .assetsAvailable = adjustment,
-                .lossUnrealized = 0,
-                .sharesTotal = adjustment,
-                .vaultAssets = adjustment,
-                .accountAssets =  //
-                AccountAmount{.account = id, .amount = -adjustment},
-                .accountShares =  //
-                AccountAmount{.account = id, .amount = adjustment}};
-            fn(sample);
-            return sample;
-        };
-
-        Account const a3{"A3"};
-        Account const a4{"A4"};
-        auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-            env.fund(XRP(1000), a3, a4);
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-            env(tx);
-            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
-            env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
-            return true;
-        };
-
-        testcase << "Vault general checks";
-        doInvariantCheck(
-            {"vault deletion succeeded without deleting a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-                sleVault->setAccountID(sfAccount, a1.id());
-                ac.view().insert(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"vault deleted by a wrong transaction type",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation updated more than single vault",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                {
-                    auto const keylet =
-                        keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                    auto sleVault = ac.view().peek(keylet);
-                    if (!sleVault)
-                        return false;
-                    ac.view().erase(sleVault);
-                }
-                {
-                    auto const keylet =
-                        keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq()));
-                    auto sleVault = ac.view().peek(keylet);
-                    if (!sleVault)
-                        return false;
-                    ac.view().erase(sleVault);
-                }
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                {
-                    auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                    env(tx);
-                }
-                {
-                    auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()});
-                    env(tx);
-                }
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation updated more than single vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const insertVault = [&](Account const a) {
-                    auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence));
-                    auto sleVault = std::make_shared(vaultKeylet);
-                    auto const vaultPage = ac.view().dirInsert(
-                        keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id()));
-                    sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-                    sleVault->setAccountID(sfAccount, a.id());
-                    ac.view().insert(sleVault);
-                };
-                insertVault(a1);
-                insertVault(a2);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"deleted vault must also delete shares",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"deleted vault must have no shares outstanding",
-             "deleted vault must have no assets outstanding",
-             "deleted vault must have no assets available"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().erase(sleVault);
-                ac.view().erase(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                // Note, such an "orphaned" update of MPT issuance attached to a
-                // vault is invalid; ttVAULT_SET must also update Vault object.
-                sleShares->setFieldH256(sfDomainID, uint256(13));
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"updated vault must have shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsMaximum] = 200;
-                ac.view().update(sleVault);
-
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().erase(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without updating shares",
-             "assets available must not be greater than assets outstanding"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsTotal] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-                return true;
-            });
-
-        doInvariantCheck(
-            {"set must not change assets outstanding",
-             "set must not change assets available",
-             "set must not change shares outstanding",
-             "set must not change vault balance",
-             "assets available must not be negative",
-             "assets available must not be greater than assets outstanding",
-             "assets outstanding must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount]));
-                if (!slePseudoAccount)
-                    return false;
-                (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10;
-                ac.view().update(slePseudoAccount);
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsAvailable = (kDropsPerXrp * -100).value();
-                                   sample.assetsTotal = (kDropsPerXrp * -200).value();
-                                   sample.sharesTotal = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                sleVault->setAccountID(sfAccount, a2.id());
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfShareMPTID] = MPTID(42);
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"vault transaction must not change loss unrealized",
-             "set must not change assets outstanding"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = 13;
-                                   sample.assetsTotal = 20;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"loss unrealized must not exceed the difference "
-             "between assets outstanding and available",
-             "vault transaction must not change loss unrealized"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = 13;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is
-        // allowed to change loss unrealized, so it isolates this check from the
-        // "must not change loss unrealized" invariant. Gated behind
-        // fixCleanup3_4_0 (see below).
-        doInvariantCheck(
-            {"loss unrealized must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // Without fixCleanup3_4_0 the same state must NOT trip the invariant,
-        // preserving pre-amendment behavior (no fork risk).
-        doInvariantCheck(
-            makeEnv(defaultAmendments() - fixCleanup3_4_0),
-            {},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
-            {tesSUCCESS, tesSUCCESS},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"set assets outstanding must not exceed assets maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = 1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"assets maximum must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"set must not change shares outstanding",
-             "updated zero sized vault must have no assets outstanding",
-             "updated zero sized vault must have no assets available"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfOutstandingAmount] = 0;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"updated shares must not exceed maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfMaximumAmount] = 10;
-                ac.view().update(sleShares);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"updated shares must not exceed maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
-
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        testcase << "Vault create";
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "updated zero sized vault must have no assets outstanding",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsTotal] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "updated zero sized vault must have no assets available",
-                "assets available must not be greater than assets outstanding",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsAvailable] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "loss unrealized must not exceed the difference between assets "
-                "outstanding and available",
-                "vault transaction must not change loss unrealized",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfLossUnrealized] = 1;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().update(sleVault);
-                (*sleShares)[sfOutstandingAmount] = 9;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "assets maximum must not be negative",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsMaximum] = Number(-1);
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"create operation must not have updated a vault",
-             "shares issuer and vault pseudo-account must be the same",
-             "shares issuer must be a pseudo-account",
-             "shares issuer pseudo-account must point back to the vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().update(sleVault);
-                (*sleShares)[sfIssuer] = a1.id();
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault created by a wrong transaction type", "account root created illegally"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // The code below will create a valid vault with (almost) all
-                // the invariants holding. Except one: it is created by the
-                // wrong transaction type.
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
-                // Create pseudo-account.
-                auto sleAccount = std::make_shared(keylet::account(pseudoId));
-                sleAccount->setAccountID(sfAccount, pseudoId);
-                sleAccount->setFieldAmount(sfBalance, STAmount{});
-                std::uint32_t const seqno =                             //
-                    ac.view().rules().enabled(featureSingleAssetVault)  //
-                    ? 0                                                 //
-                    : sequence;
-                sleAccount->setFieldU32(sfSequence, seqno);
-                sleAccount->setFieldU32(
-                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
-                ac.view().insert(sleAccount);
-
-                auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                sleShares->at(sfIssuer) = pseudoId;
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                sleVault->at(sfAccount) = pseudoId;
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"shares issuer and vault pseudo-account must be the same",
-             "shares issuer pseudo-account must point back to the vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
-                // Create pseudo-account.
-                auto sleAccount = std::make_shared(keylet::account(pseudoId));
-                sleAccount->setAccountID(sfAccount, pseudoId);
-                sleAccount->setFieldAmount(sfBalance, STAmount{});
-                std::uint32_t const seqno =                             //
-                    ac.view().rules().enabled(featureSingleAssetVault)  //
-                    ? 0                                                 //
-                    : sequence;
-                sleAccount->setFieldU32(sfSequence, seqno);
-                sleAccount->setFieldU32(
-                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
-                // Setting wrong vault key
-                sleAccount->setFieldH256(sfVaultID, uint256(42));
-                ac.view().insert(sleAccount);
-
-                auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                sleShares->at(sfIssuer) = pseudoId;
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                // sleVault->at(sfAccount) = pseudoId;
-                // Setting wrong pseudo account ID
-                sleVault->at(sfAccount) = a2.id();
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto const sharesMptId = makeMptID(sequence, a2.id());
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id()));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                // Setting wrong pseudo account ID
-                sleShares->at(sfIssuer) = AccountID(42);
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                sleVault->at(sfAccount) = a2.id();
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        testcase << "Vault deposit";
-        doInvariantCheck(
-            {"deposit must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"deposit assets outstanding must not exceed assets maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = 1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // This really convoluted unit tests makes the zero balance on the
-        // depositor, by sending them the same amount as the transaction fee.
-        // The operation makes no sense, but the defensive check in
-        // ValidVault::finalize is otherwise impossible to trigger.
-        doInvariantCheck(
-            {"deposit must increase vault balance", "deposit must change depositor balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = -100;
-                               }));
-            },
-            XRPAmount{100},
-            STTx{
-                ttVAULT_DEPOSIT,
-                [&](STObject& tx) {
-                    tx[sfFee] = XRPAmount(100);
-                    tx[sfAccount] = a3.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"deposit must increase vault balance",
-             "deposit must decrease depositor balance",
-             "deposit must change vault and depositor balance by equal amount",
-             "deposit and assets outstanding must add up",
-             "deposit and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A2 to A3 to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.vaultAssets = -20;
-                                   sample.accountAssets->amount = 10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change depositor balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A3 to vault to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change depositor shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must increase depositor shares",
-             "deposit must change depositor and vault shares by equal amount",
-             "deposit must not change vault balance by more than deposited "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = -5;
-                                   sample.sharesTotal = -10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit and assets outstanding must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
-                ac.view().update(sleA3);
-
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = 11;
-                               }));
-            },
-            XRPAmount{2000},
-            STTx{
-                ttVAULT_DEPOSIT,
-                [&](STObject& tx) {
-                    tx[sfAmount] = XRPAmount(10);
-                    tx[sfDelegate] = a3.id();
-                    tx[sfFee] = XRPAmount(2000);
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit and assets outstanding must add up",
-             "deposit and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = 7;
-                                   sample.assetsAvailable = 7;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        testcase << "Vault withdrawal";
-        doInvariantCheck(
-            {"withdrawal must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        // Almost identical to the really convoluted test for deposit, where the
-        // depositor spends only the transaction fee. In case of withdrawal,
-        // this test is almost the same as normal withdrawal where the
-        // sfDestination would have been A4, but has been omitted.
-        doInvariantCheck(
-            {"withdrawal must change one destination balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = -100;
-                               }));
-            },
-            XRPAmount{100},
-            STTx{
-                ttVAULT_WITHDRAW,
-                [&](STObject& tx) {
-                    tx[sfFee] = XRPAmount(100);
-                    tx[sfAccount] = a3.id();
-                    // This commented out line causes the invariant violation.
-                    // tx[sfDestination] = A4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {
-                "withdrawal must change vault and destination balance by equal amount",
-                "withdrawal must decrease vault balance",
-                "withdrawal must increase destination balance",
-                "withdrawal and assets outstanding must add up",
-                "withdrawal and assets available must add up",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A2 to A3 to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.vaultAssets = 10;
-                                   sample.accountAssets->amount = -20;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change one destination balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                 *sample.vaultAssets -= 5;
-                             })))
-                    return false;
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5;
-                ac.view().update(sleA3);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change depositor shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must decrease depositor shares",
-             "withdrawal must change depositor and vault shares by equal "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = 5;
-                                   sample.sharesTotal = 10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal and assets outstanding must add up",
-             "withdrawal and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = -15;
-                                   sample.assetsAvailable = -15;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal and assets outstanding must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
-                ac.view().update(sleA3);
-
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = -7;
-                               }));
-            },
-            XRPAmount{2000},
-            STTx{
-                ttVAULT_WITHDRAW,
-                [&](STObject& tx) {
-                    tx[sfAmount] = XRPAmount(10);
-                    tx[sfDelegate] = a3.id();
-                    tx[sfFee] = XRPAmount(2000);
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-            env.fund(XRP(1000), a3, a4);
-
-            // Create MPT asset
-            {
-                json::Value jv;
-                jv[sfAccount] = a3.human();
-                jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
-                jv[sfFlags] = tfMPTCanTransfer;
-                env(jv);
-                env.close();
-            }
-
-            auto const mptID = makeMptID(env.seq(a3) - 1, a3);
-            Asset const asset = MPTIssue(mptID);
-            // Authorize A1 A2 A4
-            {
-                json::Value jv;
-                jv[sfAccount] = a1.human();
-                jv[sfTransactionType] = jss::MPTokenAuthorize;
-                jv[sfMPTokenIssuanceID] = to_string(mptID);
-                env(jv);
-                jv[sfAccount] = a2.human();
-                env(jv);
-                jv[sfAccount] = a4.human();
-                env(jv);
-
-                env.close();
-            }
-            // Send tokens to A1 A2 A4
-            {
-                env(pay(a3, a1, asset(1000)));
-                env(pay(a3, a2, asset(1000)));
-                env(pay(a3, a4, asset(1000)));
-                env.close();
-            }
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-            env(tx);
-            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)}));
-            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)}));
-            env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)}));
-            return true;
-        };
-
-        doInvariantCheck(
-            {"withdrawal must decrease depositor shares",
-             "withdrawal must change depositor and vault shares by equal "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = 5;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt,
-            TxAccount::A2);
-
-        testcase << "Vault clawback";
-        doInvariantCheck(
-            {"clawback must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        // Not the same as below check: attempt to clawback XRP
-        doInvariantCheck(
-            {"clawback may only be performed by the asset issuer"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        // Not the same as above check: attempt to clawback MPT by bad account
-        doInvariantCheck(
-            {"clawback may only be performed by the asset issuer"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must decrease vault balance",
-             "clawback must decrease holder shares",
-             "clawback must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must change holder shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must change holder and vault shares by equal amount",
-             "clawback and assets outstanding must add up",
-             "clawback and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = -8;
-                                   sample.assetsTotal = -7;
-                                   sample.assetsAvailable = -7;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-    }
-
-    void
-    testMPT()
-    {
-        using namespace test::jtx;
-        testcase << "MPT";
-
-        MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))};
-        auto const nonCanonicalMPTAmount = [&](SField const& field) {
-            return STAmount{
-                field,
-                nonCanonicalMPTIssue,
-                kMaxMpTokenAmount + std::uint64_t{1},
-                0,
-                false,
-                STAmount::Unchecked{}};
-        };
-        auto const negativeMPTAmount = [&](SField const& field) {
-            return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}};
-        };
-        auto const nonCanonicalMPTPayment = [&]() {
-            return STTx{ttPAYMENT, [&](STObject& tx) {
-                            tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount));
-                        }};
-        };
-
-        doInvariantCheck(
-            makeEnv(defaultAmendments() - fixCleanup3_2_0),
-            {},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{},
-            nonCanonicalMPTPayment(),
-            {tesSUCCESS, tesSUCCESS});
-
-        doInvariantCheck(
-            {{"ledger entry contains non-canonical MPT or XRP amount"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                auto sleNew = std::make_shared(
-                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setAccountID(sfDestination, a2.id());
-                sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"ledger entry contains non-canonical MPT or XRP amount"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                auto sleNew = std::make_shared(
-                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setAccountID(sfDestination, a2.id());
-                sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount > MaximumAmount
-        doInvariantCheck(
-            {{"OutstandingAmount overflow"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 110);
-                sleNew->setFieldU64(sfMaximumAmount, 100);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPTToken amount doesn't add up to OutstandingAmount
-        doInvariantCheck(
-            {{"invalid OutstandingAmount balance"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 100);
-                sleNew->setFieldU64(sfMaximumAmount, 100);
-                ac.view().insert(sleNew);
-
-                sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
-                sleNew->setFieldU64(sfMPTAmount, 90);
-                ac.view().insert(sleNew);
-
-                return true;
-            });
-
-        // Overflow/Invalid balance on payment
-        auto testPayment = [&](std::string const& log, auto&& update) {
-            MPTID id;
-            doInvariantCheck(
-                {{log}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    return update(id, ac, a1);
-                },
-                XRPAmount{},
-                STTx{ttPAYMENT, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-                [&](Account const& a1, Account const& a2, Env& env) {
-                    Account const gw("gw");
-                    env.fund(XRP(1'000), gw);
-                    MPTTester const mpt(
-                        {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100});
-                    id = mpt.issuanceID();
-                    return true;
-                });
-        };
-        testPayment(
-            "invalid OutstandingAmount balance",
-            [&](MPTID const& id, ApplyContext& ac, Account const& a1) {
-                auto sle = ac.view().peek(keylet::mptoken(id, a1));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfMPTAmount, 101);
-                ac.view().update(sle);
-                return true;
-            });
-        testPayment(
-            "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
-                auto sle = ac.view().peek(keylet::mptokenIssuance(id));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfOutstandingAmount, 101);
-                ac.view().update(sle);
-                return true;
-            });
-
-        // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback balance change is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100};
-                    }},
-                {tesSUCCESS, tesSUCCESS});
-        }
-
-        // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing.
-        {
-            Env env(*this, defaultAmendments() - featureMPTokensV2);
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback balance change is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tesSUCCESS, tesSUCCESS});
-        }
-
-        // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback balance change is invalid"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-
-                    sleToken->setFieldU64(sfMPTAmount, 80);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 80);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline and MPToken both changed"}},
-                [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleLine =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id()));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleLine || !sleToken || !sleIssuance)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sleLine->setFieldAmount(sfBalance, balance);
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleLine);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Clawback that modifies a trustline other than the one implied by the
-        // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for
-        // the mismatched line.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            auto const eur = issuer["EUR"];
-            env.trust(eur(100), holder);
-            env(pay(issuer, holder, eur(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback changed the wrong line"}},
-                [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency));
-                    if (!sle)
-                        return false;
-                    STAmount balance{Issue{eur.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Clawback leaving the holder's balance negative.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline or MPT balance is negative"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-                    // Make the holder's balance negative from their perspective.
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() < issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // IOU-amount clawback while only an MPToken changed: no trustline was
-        // recorded, so iou_.before is empty.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback changed the wrong line"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Valid trustline change but a zero clawback amount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback amount is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback tx missing the Holder field.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback missing holder"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback where the holder's MPToken was deleted (after is empty).
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback token is missing"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    // Keep the issuance consistent after removing the token.
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 0);
-                    ac.view().update(sleIssuance);
-                    ac.view().erase(sleToken);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback that changed a different holder's MPToken.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env,
-                 .issuer = issuer,
-                 .holders = {holder, other},
-                 .pay = 100,
-                 .maxAmt = 200});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback changed the wrong token"}},
-                [id](Account const&, Account const& other, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, other));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 190);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Valid MPToken change but a zero MPT clawback amount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback amount is invalid"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 0};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // More MPTokens created than expected
-        std::array, 4> const tests = {
-            std::make_pair(ttAMM_WITHDRAW, 2),
-            std::make_pair(ttAMM_CLAWBACK, 2),
-            std::make_pair(ttAMM_CREATE, 3),
-            std::make_pair(ttCHECK_CASH, 2)};
-        for (auto const& [tx, nTokens] : tests)
-        {
-            doInvariantCheck(
-                {{std::string("MPToken created for the MPT issuer")}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-
-                    auto seq = sle->getFieldU32(sfSequence);
-                    for (int i = 0; i < nTokens; ++i)
-                    {
-                        MPTIssue const mpt{makeMptID(seq + i, a1)};
-                        auto sleNew =
-                            std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                        ac.view().insert(sleNew);
-
-                        sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
-                        ac.view().insert(sleNew);
-                    }
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{tx, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // More MPTokens deleted than expected
-        for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK})
-        {
-            MPTID id;
-            Account const a3("A3");
-            doInvariantCheck(
-                {{"MPT authorize  succeeded but created/deleted bad number of mptokens"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    for (auto const& a : {a1, a2, a3})
-                    {
-                        auto sle = ac.view().peek(keylet::mptoken(id, a));
-                        if (!sle)
-                            return false;
-                        ac.view().erase(sle);
-                    }
-                    return true;
-                },
-                XRPAmount{},
-                STTx{tx, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const& a2, Env& env) {
-                    Account const gw("gw");
-                    env.fund(XRP(1'000), gw, a3);
-                    MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}});
-                    id = mpt.issuanceID();
-                    return true;
-                });
-        }
-
-        // sfReferenceHolding can only be set on creation by VaultCreate. A
-        // non-VaultCreate transaction that creates an MPTokenIssuance with
-        // sfReferenceHolding present must trip the invariant.
-        doInvariantCheck(
-            {{"sfReferenceHolding set on a new MPTokenIssuance by a "
-              "non-VaultCreate transaction"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const sleAcct = ac.view().peek(keylet::account(a1.id()));
-                if (!sleAcct)
-                    return false;
-                MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldH256(sfReferenceHolding, uint256{1});
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}});
-
-        // sfReferenceHolding is immutable: changing the field on an
-        // existing MPTokenIssuance must trip the invariant. Set up a real
-        // vault via preclose (so the share issuance carries
-        // sfReferenceHolding), then mutate it in precheck to produce a
-        // before/after pair.
-        {
-            uint256 vaultKey;
-            doInvariantCheck(
-                {{"sfReferenceHolding was modified on an existing "
-                  "MPTokenIssuance"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
-                    if (!sleVault)
-                        return false;
-                    auto sleIssuance =
-                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-                    if (!sleIssuance)
-                        return false;
-                    sleIssuance->setFieldH256(sfReferenceHolding, uint256{2});
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const&, Env& env) {
-                    Account const issuer{"issuer"};
-                    env.fund(XRP(10'000), issuer);
-                    env.close();
-                    MPTTester mptt{env, issuer, kMptInitNoFund};
-                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-                    PrettyAsset const asset = mptt.issuanceID();
-                    mptt.authorize({.account = a1});
-                    env.close();
-
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-                    env(tx);
-                    env.close();
-                    vaultKey = keylet.key;
-                    return true;
-                });
-        }
-
-        // A vault pseudo-account's MPToken cannot be deleted by anything
-        // other than a VaultDelete transaction. Set up a vault, then have
-        // an arbitrary tx erase the pseudo's MPToken in precheck.
-        {
-            uint256 vaultKey;
-            doInvariantCheck(
-                {{"vault pseudo-account holding deleted by a "
-                  "non-VaultDelete transaction"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
-                    if (!sleVault)
-                        return false;
-                    auto const sleIssuance =
-                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-                    if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
-                        return false;
-                    auto sleHolding = ac.view().peek(
-                        keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
-                    if (!sleHolding)
-                        return false;
-                    ac.view().erase(sleHolding);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const&, Env& env) {
-                    Account const issuer{"issuer"};
-                    env.fund(XRP(10'000), issuer);
-                    env.close();
-                    MPTTester mptt{env, issuer, kMptInitNoFund};
-                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-                    PrettyAsset const asset = mptt.issuanceID();
-                    mptt.authorize({.account = a1});
-                    env.close();
-
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-                    env(tx);
-                    env.close();
-                    vaultKey = keylet.key;
-                    return true;
-                });
-        }
-
-        // Invalid transfer
-        std::array, 3> const invalidTransferTests = {
-            std::make_pair(ttAMM_WITHDRAW, false),
-            std::make_pair(ttPAYMENT, false),
-            std::make_pair(ttPAYMENT, true)};
-        for (auto const enabled : {true, false})
-        {
-            for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
-            {
-                for (auto const flag :
-                     {static_cast(lsfMPTLocked),
-                      ~lsfMPTCanTransfer,
-                      ~lsfMPTCanTrade,
-                      0u})
-                {
-                    MPTID id{};
-                    auto const isSuccess = !enabled || flag == 0 ||
-                        (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
-                        (tx == ttAMM_WITHDRAW &&
-                         (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
-                    std::pair const error = isSuccess
-                        ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS))
-                        : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED));
-                    doInvariantCheck(
-                        {{isSuccess ? "" : "invalid MPToken transfer between holders"}},
-                        [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                            auto update = [&](AccountID const& a, std::uint64_t v) {
-                                auto sle = ac.view().peek(keylet::mptoken(id, a));
-                                if (!sle)
-                                    return false;
-                                sle->at(sfMPTAmount) = v;
-                                ac.view().update(sle);
-                                return true;
-                            };
-                            auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id));
-                            if (!issuanceSle)
-                                return false;
-                            auto const flags = issuanceSle->at(sfFlags);
-                            if (flag == lsfMPTLocked)
-                            {
-                                issuanceSle->at(sfFlags) = flags | lsfMPTLocked;
-                            }
-                            else if (flag != 0u)
-                            {
-                                issuanceSle->at(sfFlags) = flags & flag;
-                            }
-                            issuanceSle->at(sfOutstandingAmount) = 200;
-                            ac.view().update(issuanceSle);
-                            return update(a1, 101) && update(a2, 99);
-                        },
-                        XRPAmount{},
-                        STTx{
-                            tx,
-                            [&](STObject& tx) {
-                                if (crossCurrencyPayment)
-                                {
-                                    tx.setFieldAmount(
-                                        sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id}));
-                                }
-                            }},
-                        {error.first, error.second},
-                        [&](Account const& a1, Account const& a2, Env& env) {
-                            Account const gw("gw");
-                            env.fund(XRP(1'000), gw);
-                            MPTTester const usd(
-                                {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100});
-                            id = usd.issuanceID();
-                            if (!enabled)
-                            {
-                                env.disableFeature(featureMPTokensV2);
-                            }
-                            return true;
-                        });
-                }
-            }
-        }
-
-        // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
-        // through sfReferenceHolding to test the vault's underlying asset for
-        // each changed holder.
-        {
-            Account const gw{"gw"};
-            MPTID shareID{};
-
-            // Vault setup: a1 and a2 both deposit IOU and hold vault shares.
-            auto const setupVault = [&](Account const& a1,
-                                        Account const& a2,
-                                        Env& env) -> std::tuple {
-                env.fund(XRP(1'000), gw);
-                env.trust(gw["IOU"](10'000), a1);
-                env.trust(gw["IOU"](10'000), a2);
-                env.close();
-                env(pay(gw, a1, gw["IOU"](500)));
-                env(pay(gw, a2, gw["IOU"](500)));
-                env.close();
-
-                Vault const vault{env};
-                auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]});
-                env(createTx);
-                env.close();
-                env(vault.deposit(
-                    {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
-                env(vault.deposit(
-                    {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
-                env.close();
-
-                return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)};
-            };
-
-            // Simulate a vault-share transfer: a1 sends 10 shares to a2.
-            auto const precheck =
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool {
-                auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id()));
-                auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
-                if (!sle1 || !sle2)
-                    return false;
-                (*sle1)[sfMPTAmount] -= 10;
-                (*sle2)[sfMPTAmount] += 10;
-                ac.view().update(sle1);
-                ac.view().update(sle2);
-                return true;
-            };
-
-            // Case: vault pseudo-account's IOU trustline is frozen.
-            {
-                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-                    auto [sid, vid] = setupVault(a1, a2, env);
-                    shareID = sid;
-                    env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze));
-                    env.close();
-                    return true;
-                };
-
-                doInvariantCheck(
-                    Env{*this, defaultAmendments()},
-                    {{"invalid MPToken transfer between holders"}},
-                    precheck,
-                    XRPAmount{},
-                    STTx{ttPAYMENT, [](STObject&) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    preclose);
-            }
-
-            // Case: receiver's (a2's) IOU trustline is frozen.
-            {
-                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-                    auto [sid, vid] = setupVault(a1, a2, env);
-                    shareID = sid;
-                    env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
-                    env.close();
-                    return true;
-                };
-
-                doInvariantCheck(
-                    Env{*this, defaultAmendments()},
-                    {{"invalid MPToken transfer between holders"}},
-                    precheck,
-                    XRPAmount{},
-                    STTx{ttPAYMENT, [](STObject&) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    preclose);
-            }
-        }
-    }
-
-    void
-    testAMM()
-    {
-        testcase << "AMM";
-        using namespace jtx;
-
-        MPTID mptID{};
-        uint256 ammID{};
-        AccountID ammAccountID{};
-        Account const gw{"gw"};
-        Issue lptIssue{};
-        PrettyAsset poolAsset{xrpIssue()};
-
-        auto deleteAMMAccount = [&](ApplyContext& ac, bool) {
-            auto sle = ac.view().peek(keylet::account(ammAccountID));
-            if (!sle)
-                return false;
-            ac.view().erase(sle);
-            return true;
-        };
-
-        auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) {
-            auto sle = ac.view().peek(keylet::amm(ammID));
-            if (!sle)
-                return false;
-            sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount});
-            ac.view().update(sle);
-            return true;
-        };
-        auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) {
-            return updateLPTokensBalance(ac, -1);
-        };
-        auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) {
-            return updateLPTokensBalance(ac, 200'000'000);
-        };
-        auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); };
-
-        auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) {
-            if (isMPT)
-            {
-                auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfMPTAmount, 1);
-                ac.view().update(sle);
-                return true;
-            }
-            auto sle = ac.view().peek(keylet::account(ammAccountID));
-            if (!sle)
-                return false;
-            sle->setFieldAmount(sfBalance, XRP(1));
-            ac.view().update(sle);
-            return true;
-        };
-
-        auto test = [&](auto const txType,
-                        auto&& update,
-                        bool isMPT,
-                        TER error = tecINVARIANT_FAILED) {
-            doInvariantCheck(
-                {{"AMM"}},
-                [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); },
-                XRPAmount{},
-                STTx{txType, [&](STObject& tx) {}},
-                {tecINVARIANT_FAILED, error},
-                [&](Account const&, Account const&, Env& env) {
-                    env.fund(XRP(1'000), gw);
-                    poolAsset = [&]() -> PrettyAsset {
-                        if (isMPT)
-                        {
-                            MPT const mpt = MPTTester({.env = env, .issuer = gw});
-                            mptID = mpt.issuanceID;
-                            return mpt;
-                        }
-                        return gw["USD"];
-                    }();
-                    AMM const amm(env, gw, XRP(100), poolAsset(100));
-                    ammAccountID = amm.ammAccount();
-                    ammID = amm.ammID();
-                    lptIssue = amm.lptIssue();
-                    return true;
-                });
-        };
-
-        for (bool const isMPT : {false, true})
-        {
-            auto const error = isMPT ? TER(tecINVARIANT_FAILED) : TER(tefINVARIANT_FAILED);
-            for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW})
-            {
-                test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED);
-                test(txType, updateLPTokensBadAmount, isMPT);
-                test(txType, updateLPTokensBadBalance, isMPT);
-            }
-            for (auto txType : {ttAMM_BID, ttAMM_VOTE})
-            {
-                test(txType, updateAMMPool, isMPT, error);
-                test(txType, updateLPTokensBadAmount, isMPT);
-                test(txType, updateLPTokensBadBalance, isMPT);
-            }
-            for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT})
-            {
-                test(txType, updateAMM, isMPT);
-            }
-        }
-    }
-
-    // Test the invariant overwrite fix for both pre- and post-amendment
-    // behavior. With the fix enabled, |= accumulates violations across
-    // entries so a later valid entry cannot clear an earlier violation.
-    // Without the fix, = assignment means the last-visited entry wins.
-    void
-    testInvariantOverwrite(FeatureBitset features)
-    {
-        using namespace test::jtx;
-        bool const fixEnabled = features[fixCleanup3_1_3];
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-        std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS};
-
-        // Insert two trust line SLEs in hash-sorted order, with the "bad"
-        // entry at the lower-sorting key so it is visited first by
-        // ApplyStateTable::visit(). The configurer callables receive the
-        // SLE and the Issue corresponding to that side's keylet currency.
-        auto const insertOrderedTrustLinePair = [](ApplyContext& ac,
-                                                   Account const& a1,
-                                                   Account const& a2,
-                                                   Account const& a3,
-                                                   auto const& badConfig,
-                                                   auto const& goodConfig) {
-            char const* const c1 = "USD";
-            char const* const c2 = "EUR";
-            auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency);
-            auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency);
-
-            bool const k1First = k1.key < k2.key;
-            auto const& badKey = k1First ? k1 : k2;
-            auto const& goodKey = k1First ? k2 : k1;
-            Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()};
-            Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()};
-
-            auto const sleBad = std::make_shared(badKey);
-            badConfig(*sleBad, badIss);
-            ac.view().insert(sleBad);
-
-            auto const sleGood = std::make_shared(goodKey);
-            goodConfig(*sleGood, goodIss);
-            ac.view().insert(sleGood);
-        };
-
-        // Regression: bad XRP trust line followed by a valid trust line.
-        // With the fix, the invariant catches the violation. Without it,
-        // the valid entry overwrites the flag to false. The keylet
-        // currencies are non-XRP (the invariant inspects sfLowLimit /
-        // sfHighLimit issue, not the keylet currency).
-        testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"an XRP trust line was created"}}
-                       : std::vector{},
-            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Account const a3{"A3"};
-                insertOrderedTrustLinePair(
-                    ac,
-                    a1,
-                    a2,
-                    a3,
-                    [](SLE& sle, Issue const& iss) {
-                        // sfLowLimit has xrpIssue, making isXrp = true
-                        sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                    },
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                    });
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            fixEnabled ? failTers : passTers);
-
-        // Regression: bad deep-freeze trust line followed by a valid one.
-        testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"a trust line with deep freeze flag without "
-                                                   "normal freeze was created"}}
-                       : std::vector{},
-            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Account const a3{"A3"};
-                insertOrderedTrustLinePair(
-                    ac,
-                    a1,
-                    a2,
-                    a3,
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                        sle.setFieldU32(sfFlags, lsfLowDeepFreeze);
-                    },
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                        sle.setFieldU32(sfFlags, 0u);
-                    });
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            fixEnabled ? failTers : passTers);
-
-        // Regression: MPT OutstandingAmount exceeds max, but locked <=
-        // outstanding. Plain assignment would overwrite bad_ = true.
-        // With the fix, NoZeroEscrow catches it.
-        // Without the fix, NoZeroEscrow passes but ValidMPTIssuance
-        // still fires ("a MPT issuance was created").
-        testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"escrow specifies invalid amount"}}
-                       : std::vector{{"a MPT issuance was created"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_
-                sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1);
-                // locked is valid and <= outstanding -> must NOT clear bad_
-                sleNew->setFieldU64(sfLockedAmount, 10);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            failTers);
-    }
-
-    void
-    testVaultComputeCoarsestScale()
-    {
-        using namespace jtx;
-
-        Account const issuer{"issuer"};
-        PrettyAsset const vaultAsset = issuer["IOU"];
-
-        struct TestCase
-        {
-            std::string name;
-            std::int32_t expectedMinScale;
-            std::vector values;
-        };
-
-        for (auto const mantissaScale : MantissaRange::getAllScales())
-        {
-            if (mantissaScale == MantissaRange::MantissaScale::Small)
-                continue;
-            NumberMantissaScaleGuard const g{mantissaScale};
-
-            auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
-                return {.delta = n, .scale = scale(n, vaultAsset.raw())};
-            };
-
-            auto const testCases = std::vector{
-                {
-                    .name = "No values",
-                    .expectedMinScale = 0,
-                    .values = {},
-                },
-                {
-                    .name = "Mixed integer and Number values",
-                    .expectedMinScale = -15,
-                    .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})},
-                },
-                {
-                    .name = "Mixed scales",
-                    .expectedMinScale = -17,
-                    .values =
-                        {makeDelta(Number{1, -2}),
-                         makeDelta(Number{5, -3}),
-                         makeDelta(Number{3, -2})},
-                },
-                {
-                    .name = "Equal scales",
-                    .expectedMinScale = -16,
-                    .values =
-                        {makeDelta(Number{1, -1}),
-                         makeDelta(Number{5, -1}),
-                         makeDelta(Number{1, -1})},
-                },
-                {
-                    .name = "Mixed mantissa sizes",
-                    .expectedMinScale = -12,
-                    .values =
-                        {makeDelta(Number{1}),
-                         makeDelta(Number{1234, -3}),
-                         makeDelta(Number{12345, -6}),
-                         makeDelta(Number{123, 1})},
-                },
-            };
-
-            for (auto const& tc : testCases)
-            {
-                testcase("vault computeCoarsestScale: " + tc.name);
-
-                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
-
-                BEAST_EXPECTS(
-                    actualScale == tc.expectedMinScale,
-                    "expected: " + std::to_string(tc.expectedMinScale) +
-                        ", actual: " + std::to_string(actualScale));
-                for (auto const& num : tc.values)
-                {
-                    // None of these scales are far enough apart that rounding the
-                    // values would lose information, so check that the rounded
-                    // value matches the original.
-                    auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                    BEAST_EXPECTS(
-                        actualRounded == num.delta,
-                        "number " + to_string(num.delta) + " rounded to scale " +
-                            std::to_string(actualScale) + " is " + to_string(actualRounded));
-                }
-            }
-
-            auto const testCases2 = std::vector{
-                {
-                    .name = "False equivalence",
-                    .expectedMinScale = -15,
-                    .values =
-                        {
-                            makeDelta(Number{1234567890123456789, -18}),
-                            makeDelta(Number{12345, -4}),
-                            makeDelta(Number{1}),
-                        },
-                },
-            };
-
-            // Unlike the first set of test cases, the values in these test could
-            // look equivalent if using the wrong scale.
-            for (auto const& tc : testCases2)
-            {
-                testcase("vault computeCoarsestScale: " + tc.name);
-
-                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
-
-                BEAST_EXPECTS(
-                    actualScale == tc.expectedMinScale,
-                    "expected: " + std::to_string(tc.expectedMinScale) +
-                        ", actual: " + std::to_string(actualScale));
-                std::optional first;
-                Number firstRounded;
-                for (auto const& num : tc.values)
-                {
-                    if (!first)
-                    {
-                        first = num.delta;
-                        firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                        continue;
-                    }
-                    auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                    BEAST_EXPECTS(
-                        numRounded != firstRounded,
-                        "at a scale of " + std::to_string(actualScale) + " " +
-                            to_string(num.delta) + " == " + to_string(*first));
-                }
-            }
-        }
-    }
-
-    void
-    testSponsorship()
-    {
-        using namespace test::jtx;
-        using namespace std::string_literals;
-        testcase("Sponsorship");
-        {
-            auto const expectMessage =
-                "SponsoredOwnerCount does not equal SponsoringOwnerCount delta.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoringOwnerCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "OwnerCount must be greater than or equal to SponsoredOwnerCount.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfOwnerCount, 0);
-                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
-                    ac.view().update(sle);
-
-                    auto const sle2 = ac.view().peek(keylet::account(a2.id()));
-                    if (!sle2)
-                        return false;
-                    sle2->setFieldU32(sfSponsoringOwnerCount, 1);
-                    ac.view().update(sle2);
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta.";
-            uint256 checkID;
-
-            doInvariantCheck(
-                {{expectMessage}},
-                [&](Account const&, Account const& a2, ApplyContext& ac) {
-                    auto const check = ac.view().peek(keylet::check(checkID));
-                    if (!check)
-                        return false;
-                    check->setAccountID(sfSponsor, a2.id());
-                    ac.view().update(check);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&checkID](Account const& a1, Account const& a2, Env& env) {
-                    checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key;
-                    env(check::create(a1, a2, XRP(1)));
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "Invariant failed: Net delta of SponsoringAccountCount does "
-                "not match net delta of sfSponsor presence.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoringAccountCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setAccountID(sfSponsor, a2.id());
-                    ac.view().update(sle);
-                    return true;
-                });
-        }
-    }
-
-    void
-    testObjectHasPseudoAccount()
-    {
-        testcase << "object has pseudo-account";
-        using namespace jtx;
-
-        auto const amendments = defaultAmendments() | fixCleanup3_3_0;
-
-        // Vault: object deleted without its pseudo-account
-        {
-            Keylet vaultKeylet = keylet::amendments();
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted Vault without deleting its pseudo-account"}},
-                [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(vaultKeylet);
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttVAULT_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&vaultKeylet](Account const& a1, Account const&, Env& env) {
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                    env(tx);
-                    vaultKeylet = keylet;
-                    return true;
-                });
-        }
-
-        // AMM: object deleted without its pseudo-account
-        {
-            uint256 ammID{};
-            Account const gw{"gw"};
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted AMM without deleting its pseudo-account"}},
-                [&ammID](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::amm(ammID));
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttAMM_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&ammID, &gw](Account const&, Account const&, Env& env) {
-                    env.fund(XRP(1'000), gw);
-                    AMM const amm(env, gw, XRP(100), gw["USD"](100));
-                    ammID = amm.ammID();
-                    return true;
-                });
-        }
-
-        // LoanBroker: object deleted without its pseudo-account
-        {
-            Keylet loanBrokerKeylet = keylet::amendments();
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted LoanBroker without deleting its pseudo-account"}},
-                [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(loanBrokerKeylet);
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) {
-                    PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-                    loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
-                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
-                });
-        }
-
-        // Deleted object missing sfAccount field (defensive check).
-        // Manually construct the view to place a vault SLE without
-        // sfAccount into the base ledger, then erase it.
-        {
-            Env env{*this, amendments};
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env.fund(XRP(1000), a1, a2);
-            env.close();
-
-            OpenView ov{*env.current()};
-
-            auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq()));
-            auto sleVault = std::make_shared(vaultKeylet);
-            sleVault->makeFieldAbsent(sfAccount);
-            ov.rawInsert(sleVault);
-
-            STTx const tx{ttVAULT_DELETE, [](STObject&) {}};
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            ApplyContext ac{
-                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-            auto sle = ac.view().peek(vaultKeylet);
-            if (!BEAST_EXPECT(sle))
-                return;
-            ac.view().erase(sle);
-
-            auto transactor = makeTransactor(ac);
-            if (!BEAST_EXPECT(transactor))
-                return;
-            TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{});
-            BEAST_EXPECT(result == tecINVARIANT_FAILED);
-            BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
-        }
-    }
-
-    void
-    testConfidentialMPTTransfer()
-    {
-        using namespace test::jtx;
-        testcase << "ValidConfidentialMPToken";
-
-        MPTID mptID;
-
-        // Generate an MPT with privacy, issue 100 tokens to A2.
-        // Perform a confidential conversion to populate encrypted state.
-        auto const precloseConfidential =
-            [&mptID](Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
-            mptID = mpt.issuanceID();
-
-            mpt.authorize({.account = a2});
-            mpt.pay(a1, a2, 100);
-
-            mpt.generateKeyPair(a1);
-            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
-
-            mpt.generateKeyPair(a2);
-            mpt.convert({
-                .account = a2,
-                .amt = 100,
-                .holderPubKey = mpt.getPubKey(a2),
-            });
-            return true;
-        };
-
-        // badDelete
-        doInvariantCheck(
-            {"MPToken deleted with encrypted fields while COA > 0"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Force an erase of the object while the COA remains 100
-                ac.view().erase(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            precloseConfidential);
-
-        // badConsistency
-        doInvariantCheck(
-            {"MPToken encrypted field existence inconsistency"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Remove one of the required encrypted fields to create a mismatch
-                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        doInvariantCheck(
-            {"MPToken encrypted field existence inconsistency"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
-                sleToken->makeFieldAbsent(sfConfidentialBalanceInbox);
-                sleToken->makeFieldAbsent(sfConfidentialBalanceSpending);
-                sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00});
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // requiresPrivacyFlag
-        auto const precloseNoPrivacy = [&mptID](
-                                           Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            // completely omitted the tfMPTCanHoldConfidentialBalance flag here.
-            mpt.create({.flags = tfMPTCanTransfer});
-            mptID = mpt.issuanceID();
-            mpt.authorize({.account = a2});
-            mpt.pay(a1, a2, 100);
-            return true;
-        };
-
-        doInvariantCheck(
-            {"MPToken has encrypted fields but Issuance does not have "
-             "lsfMPTCanHoldConfidentialBalance "
-             "set"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Inject all three encrypted fields consistently (inbox+spending+issuer must be
-                // in sync or badConsistency fires first and masks requiresPrivacyFlag).
-                sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00});
-                sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00});
-                sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00});
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseNoPrivacy);
-
-        // badCOA
-        doInvariantCheck(
-            {"Confidential outstanding amount exceeds total outstanding amount"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-                // Total outstanding is natively 100; bloat the COA over 100
-                sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200);
-                ac.view().update(sleIssuance);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Conservation Violation
-        doInvariantCheck(
-            {"Token conservation violation for MPT"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-
-                sleIssuance->setFieldU64(
-                    sfConfidentialOutstandingAmount,
-                    sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10);
-                ac.view().update(sleIssuance);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0)
-        doInvariantCheck(
-            {"Invariant failed: OutstandingAmount changed "
-             "by confidential transaction that should not "
-             "modify it for MPT"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-                sleIssuance->setFieldU64(
-                    sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1);
-                ac.view().update(sleIssuance);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Send/MergeInbox and zero-COA-delta confidential transactions must not
-        // change public holder MPTAmount.
-        doInvariantCheck(
-            {"Invariant failed: MPTAmount changed by confidential "
-             "transaction that should not modify this field."},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1);
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // badVersion
-        doInvariantCheck(
-            {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending "
-             "changed"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Blob const kChangedConfidentialSpending = {0xBA, 0xDD};
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending);
-
-                // DO NOT update sfConfidentialBalanceVersion
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Skipping Deleted MPTs (Issuance deleted)
-        auto const precloseOrphan = [&mptID](
-                                        Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
-            mptID = mpt.issuanceID();
-            mpt.authorize({.account = a2});
-
-            // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
-            mpt.generateKeyPair(a1);
-            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
-            mpt.generateKeyPair(a2);
-            mpt.convert({
-                .account = a2,
-                .amt = 0,
-                .holderPubKey = mpt.getPubKey(a2),
-            });
-
-            // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
-            mpt.destroy();
-            return true;
-        };
-
-        doInvariantCheck(
-            {},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Safely able to erase the deleted token.
-                ac.view().erase(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tesSUCCESS, tesSUCCESS},
-            precloseOrphan);
-    }
-
-public:
-    void
-    run() override
-    {
-        testXRPNotCreated();
-        testAccountRootsNotRemoved();
-        testAccountRootsDeletedClean();
-        testTypesMatch();
-        testNoXRPTrustLine();
-        testNoDeepFreezeTrustLinesWithoutFreeze();
-        testTransfersNotFrozen();
-        testXRPBalanceCheck();
-        testTransactionFeeCheck();
-        testNoBadOffers();
-        testNoZeroEscrow();
-        testValidNewAccountRoot();
-        testNFTokenPageInvariants();
-        testAMMDeleteInvariants(defaultAmendments());
-        testAMMDeleteInvariants(defaultAmendments() - fixCleanup3_3_0);
-        testPermissionedDomainInvariants(defaultAmendments() | fixCleanup3_1_3);
-        testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
-        testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
-        testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
-        testBookDirectoryExchangeRate();
-        testNoModifiedUnmodifiableFields();
-        testValidPseudoAccounts();
-        testValidLoanBroker();
-        testVault();
-        testConfidentialMPTTransfer();
-        testMPT();
-        testInvariantOverwrite(defaultAmendments());
-        testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3);
-        testVaultComputeCoarsestScale();
-        testAMM();
-        testObjectHasPseudoAccount();
-        testSponsorship();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Invariants, app, xrpl);
-
-}  // namespace xrpl::test
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/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp
index 3cf9b3a9d9..2f2f81bb8a 100644
--- a/src/test/app/LedgerMaster_test.cpp
+++ b/src/test/app/LedgerMaster_test.cpp
@@ -5,17 +5,25 @@
 #include 
 
 #include 
+#include 
 #include 
 
+#include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -111,6 +119,200 @@ class LedgerMaster_test : public beast::unit_test::Suite
         }
     }
 
+    // Wait until the SHAMapStore has finished processing the ledger that the
+    // preceding env.close() produced.
+    //
+    // env.close() returns as soon as the ledger_accept RPC returns, but the
+    // validated ledger path -- LedgerMaster::setValidLedger() ->
+    // SHAMapStore::onLedgerClosed() -- runs on a job queue thread. Without
+    // draining the job queue first, the store may not have been handed the
+    // ledger at all, in which case rendezvous() observes working_ == false and
+    // returns immediately, before any work has been done.
+    [[nodiscard]] static bool
+    syncStore(jtx::Env& env)
+    {
+        // Drain the job queue first, so that onLedgerClosed() has run and
+        // working_ is set. Then use the store's timeout overload, so a store
+        // that never finishes fails this test instead of blocking on it.
+        //
+        // Only the second wait is bounded: JobQueue::rendezvous() has no
+        // timeout overload, so a job that never completes hangs here. That is
+        // pre-existing -- ~AppBundle waits on it the same way for every jtx
+        // test -- but it does mean this helper is not hang-proof end to end.
+        env.app().getJobQueue().rendezvous();
+        return env.app().getSHAMapStore().rendezvous(std::chrono::seconds{60});
+    }
+
+    // Bring the SHAMapStore to the point where it has been handed a validated
+    // ledger and initialized lastRotated, and report how many extra ledgers had
+    // to be closed to get it there (normally none). Returns std::nullopt if
+    // syncStore() itself failed.
+    //
+    // syncStore() alone does not guarantee that, because
+    // SHAMapStoreImp::run()'s loop does not use the notification and the
+    // working_ flag safely:
+    //
+    //   * onLedgerClosed() notifies cond_ whether or not run()'s thread is
+    //     parked on it, and run() waits on cond_ without a predicate, so a
+    //     notification that lands while the thread is still starting up --
+    //     before it first reaches that wait -- is lost.
+    //   * run() clears working_ at the top of its loop without checking
+    //     whether newLedger_ is still set, so rendezvous() can report the
+    //     store idle with a validated ledger queued.
+    //
+    // Either way the store ends up parked with work pending, and only another
+    // notification gets it moving again. In a standalone test nothing else
+    // closes ledgers, so that has to come from here: this closes a ledger
+    // rather than polling getLastRotated(), because polling would just time
+    // out. onLedgerClosed() keeps only the most recent ledger in newLedger_,
+    // so the ledger the store picks up -- and therefore lastRotated -- is a
+    // timing detail, which is why the caller derives its expectations from the
+    // value it observes instead of assuming one.
+    //
+    // run() is deliberately left as it is. In production the only effect is
+    // latency: the trigger is validatedSeq >= lastRotated + deleteInterval, so
+    // a lost notification delays rotation to the next validated ledger and
+    // nothing is skipped or accumulated -- starting at 513 instead of 512 does
+    // not matter. Two consequences do follow from leaving it in place, and both
+    // hold today: nothing in production decides anything from working_ or
+    // rendezvous() (rendezvous() has no production callers at all), and a node
+    // whose ledgers only advance on demand -- standalone, driven by
+    // ledger_accept -- can sit on a queued ledger until something closes the
+    // next one, which is exactly the situation this helper is working around.
+    //
+    // So this helper is permanent rather than a stopgap. Working around the
+    // race must not make it invisible, so every extra close is logged. That
+    // keeps how often it is actually hit observable in the unit test output --
+    // which is the only signal left once this testcase stops flaking on it.
+    [[nodiscard]] std::optional
+    initializeStore(jtx::Env& env, int const maxExtraCloses = 3)
+    {
+        auto& store = env.app().getSHAMapStore();
+
+        for (int extraCloses = 0;; ++extraCloses)
+        {
+            if (!syncStore(env))
+                return std::nullopt;
+            if (store.getLastRotated() != 0 || extraCloses == maxExtraCloses)
+            {
+                if (extraCloses != 0)
+                {
+                    log << "initializeStore: the store needed " << extraCloses
+                        << " extra ledger close(s) to pick up a validated ledger. "
+                           "SHAMapStoreImp::run() dropped the notification for the "
+                           "first one; see the comment on initializeStore()."
+                        << std::endl;
+                }
+                return extraCloses;
+            }
+            env.close();
+        }
+    }
+
+    void
+    testCompleteLedgerRange(FeatureBitset features)
+    {
+        // Note that this test is intentionally very similar to
+        // SHAMapStore_test::testLedgerGaps, but has a different
+        // focus.
+
+        testcase("Complete Ledger operations");
+
+        using namespace test::jtx;
+
+        auto const deleteInterval = 8;
+
+        Env env{*this, envconfig(onlineDelete, deleteInterval)};
+
+        auto const alice = Account("alice");
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        auto& lm = env.app().getLedgerMaster();
+        LedgerIndex minSeq = 2;
+        auto& store = env.app().getSHAMapStore();
+        // Which of the existing complete ledgers the store initializes
+        // lastRotated from is a timing detail; all this test needs is that it is
+        // one of them. Everything below derives from the observed value rather
+        // than assuming a particular one.
+        //
+        // The range check and the initializeStore() one both end the testcase
+        // rather than merely reporting, because lastRotated is the only value
+        // from the store that enters minSeq. A lastRotated of 0 -- the value
+        // getLastRotated() reports until the store has been handed a
+        // validated ledger -- makes minSeq 0 below, and the minSeq - 1 and
+        // minSeq - 2 ranges then underflow to first > last, which aborts a
+        // Debug build inside missingFromCompleteLedgerRange().
+        auto const extraCloses = initializeStore(env);
+        if (!BEAST_EXPECT(extraCloses.has_value()))
+            return;
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        LedgerIndex lastRotated = store.getLastRotated();
+        if (!BEAST_EXPECTS(lastRotated >= minSeq && lastRotated <= maxSeq, to_string(lastRotated)))
+            return;
+        // The BEAST_EXPECT above already returned if this is nullopt, but that
+        // is invisible to clang-tidy's optional model.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        BEAST_EXPECTS(maxSeq == 3 + *extraCloses, to_string(maxSeq));
+        std::stringstream initialRange;
+        initialRange << minSeq << "-" << maxSeq;
+        BEAST_EXPECTS(lm.getCompleteLedgers() == initialRange.str(), lm.getCompleteLedgers());
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+        // The inner range is empty unless initializeStore() had to close extra
+        // ledgers, and missingFromCompleteLedgerRange() treats first > last as a
+        // precondition violation that aborts a Debug build via UNREACHABLE, so
+        // only check it when it is well formed.
+        if (minSeq + 1 <= maxSeq - 1)
+        {
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0);
+        }
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+
+        // Close enough ledgers to rotate a few times
+        for (int i = 0; i < 24; ++i)
+        {
+            for (int t = 0; t < 3; ++t)
+            {
+                env(noop(alice));
+            }
+            env.close();
+            BEAST_EXPECT(syncStore(env));
+
+            ++maxSeq;
+
+            if (maxSeq == lastRotated + deleteInterval)
+            {
+                minSeq = lastRotated;
+                lastRotated = maxSeq;
+            }
+            BEAST_EXPECTS(
+                env.closed()->header().seq == maxSeq, to_string(env.closed()->header().seq));
+            BEAST_EXPECTS(store.getLastRotated() == lastRotated, to_string(store.getLastRotated()));
+            std::stringstream expectedRange;
+            expectedRange << minSeq << "-" << maxSeq;
+            BEAST_EXPECTS(lm.getCompleteLedgers() == expectedRange.str(), lm.getCompleteLedgers());
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+            // missingFromCompleteLedgerRange() treats first > last as a
+            // precondition violation and aborts a Debug build via UNREACHABLE.
+            // The range can only collapse if this test's model of minSeq /
+            // maxSeq has desynced from the store, so report that as a failure
+            // instead of taking down the whole unit test job.
+            if (minSeq + 1 <= maxSeq - 1)
+            {
+                BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0);
+            }
+            else
+            {
+                BEAST_EXPECTS(false, to_string(minSeq) + "-" + to_string(maxSeq));
+            }
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+        }
+    }
+
 public:
     void
     run() override
@@ -124,6 +326,7 @@ public:
     testWithFeats(FeatureBitset features)
     {
         testTxnIdFromIndex(features);
+        testCompleteLedgerRange(features);
     }
 };
 
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 16c4e6e000..f17054e2d0 100644
--- a/src/test/app/NFTokenBurn_test.cpp
+++ b/src/test/app/NFTokenBurn_test.cpp
@@ -33,6 +33,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -117,33 +118,30 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 std::cout << "Ledger state is not array!" << std::endl;
                 return;
             }
-            for (json::UInt i = 0; i < state.size(); ++i)
+            for (auto& i : state)
             {
-                if (state[i].isMember(sfNFTokens.jsonName) &&
-                    state[i][sfNFTokens.jsonName].isArray())
+                if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                 {
-                    std::uint32_t const tokenCount = state[i][sfNFTokens.jsonName].size();
-                    std::cout << tokenCount << " NFtokens in page "
-                              << state[i][jss::index].asString() << std::endl;
+                    std::uint32_t const tokenCount = i[sfNFTokens.jsonName].size();
+                    std::cout << tokenCount << " NFtokens in page " << i[jss::index].asString()
+                              << std::endl;
 
                     if (vol == Volume::Noisy)
                     {
-                        std::cout << state[i].toStyledString() << std::endl;
+                        std::cout << i.toStyledString() << std::endl;
                     }
                     else
                     {
                         if (tokenCount > 0)
                         {
-                            std::cout
-                                << "first: " << state[i][sfNFTokens.jsonName][0u].toStyledString()
-                                << std::endl;
+                            std::cout << "first: " << i[sfNFTokens.jsonName][0u].toStyledString()
+                                      << std::endl;
                         }
                         if (tokenCount > 1)
                         {
-                            std::cout
-                                << "last: "
-                                << state[i][sfNFTokens.jsonName][tokenCount - 1].toStyledString()
-                                << std::endl;
+                            std::cout << "last: "
+                                      << i[sfNFTokens.jsonName][tokenCount - 1].toStyledString()
+                                      << std::endl;
                         }
                     }
                 }
@@ -419,12 +417,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
@@ -459,11 +456,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 json::Value jrr = env.rpc("json", "ledger_data", to_string(jvParams));
 
-                json::Value& state = jrr[jss::result][jss::state];
+                json::Value const& state = jrr[jss::result][jss::state];
 
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto const& i : state)
                 {
-                    BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName));
+                    BEAST_EXPECT(!i.isMember(sfNFTokens.jsonName));
                 }
             }
         };
@@ -757,8 +754,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // We're going to fire an Invariant failure that is difficult to
             // cause.  We do it here because the tools are here.
             //
-            // See Invariants_test.cpp for examples of other invariant tests
-            // that this one is modeled after.
+            // See InvariantsMisc_test.cpp for examples of other invariant
+            // tests that this one is modeled after.
 
             // Generate three closely packed NFTokenPages.
             std::vector nfts = genPackedTokens();
@@ -795,7 +792,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
@@ -831,7 +828,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
@@ -1076,12 +1073,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp
index c6d2283775..d7caa2d514 100644
--- a/src/test/app/NFToken_test.cpp
+++ b/src/test/app/NFToken_test.cpp
@@ -30,6 +30,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -37,6 +38,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -4791,6 +4793,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)
     {
@@ -6245,84 +6328,162 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.fund(XRP(10000), alice, bob, broker);
         env.close();
 
-        // Verify `nftoken_id` value equals to the NFTokenID that was
-        // changed in the most recent NFTokenMint or NFTokenAcceptOffer
-        // transaction
-        auto verifyNFTokenID = [&](uint256 const& actualNftID) {
+        // Transaction metadata is not always reported under the same field
+        // name: the `ledger` RPC uses `metaData`, the others use `meta`.
+        auto const getMeta = [](json::Value const& tx) -> json::Value const* {
+            if (tx.isMember(jss::meta))
+                return &tx[jss::meta];
+            if (tx.isMember(jss::metaData))
+                return &tx[jss::metaData];
+            return nullptr;
+        };
+
+        // Neither is the transaction hash: api_version 1 nests the
+        // transaction under `tx`, later versions use `tx_json`, and some
+        // responses put the hash on the entry itself.
+        auto const getHash = [](json::Value const& entry) -> std::string {
+            if (entry.isMember(jss::tx) && entry[jss::tx].isMember(jss::hash))
+                return entry[jss::tx][jss::hash].asString();
+            if (entry.isMember(jss::tx_json) && entry[jss::tx_json].isMember(jss::hash))
+                return entry[jss::tx_json][jss::hash].asString();
+            return entry[jss::hash].asString();
+        };
+
+        // Run `verifyMeta` against the metadata of the most recent
+        // transaction as reported by the `tx`, `ledger` and `account_tx`
+        // RPCs, so that the synthetic fields are checked in every response
+        // that carries them. Runs under both api_version 1 (`tx`/`meta`)
+        // and the latest api_version (`tx_json`/synthetic fields alongside
+        // it), since the two versions place fields differently.
+        auto verifyMetaInAllResponses = [&](auto verifyMeta) {
             // Get the hash for the most recent transaction.
             std::string const txHash{
                 env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
 
             env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
 
-            // Expect nftokens_id field
-            if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id)))
-                return;
+            for (unsigned const apiVersion :
+                 {unsigned{rpc::kApiMinimumSupportedVersion},
+                  unsigned{rpc::kApiMaximumSupportedVersion}})
+            {
+                // Test 1: Check tx RPC response
+                json::Value const txResult = env.rpc(apiVersion, "tx", txHash)[jss::result];
+                verifyMeta(txResult[jss::meta]);
 
-            // Check the value of NFT ID in the meta with the
-            // actual value
-            uint256 nftID;
-            BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString()));
-            BEAST_EXPECT(nftID == actualNftID);
+                // Test 2: Check ledger RPC response with expanded
+                // transactions
+                json::Value ledgerParams;
+                ledgerParams[jss::ledger_index] = txResult[jss::ledger_index].asUInt();
+                ledgerParams[jss::transactions] = true;
+                ledgerParams[jss::expand] = true;
+
+                auto const ledgerResult =
+                    env.rpc(apiVersion, "json", "ledger", to_string(ledgerParams));
+                auto const& ledgerTx =
+                    ledgerResult[jss::result][jss::ledger][jss::transactions][0u];
+
+                // Verify transaction hash matches
+                BEAST_EXPECT(getHash(ledgerTx) == txHash);
+
+                if (auto const* meta = getMeta(ledgerTx); BEAST_EXPECT(meta != nullptr))
+                    verifyMeta(*meta);
+
+                // Test 3: Check account_tx RPC response
+                // The transaction is not necessarily alice's, so query
+                // account_tx for the account that actually submitted it.
+                json::Value accountTxParams;
+                accountTxParams[jss::account] = txResult.isMember(jss::tx_json)
+                    ? txResult[jss::tx_json][jss::Account].asString()
+                    : txResult[jss::Account].asString();
+
+                auto const accountTxResult =
+                    env.rpc(apiVersion, "json", "account_tx", to_string(accountTxParams));
+
+                // account_tx ordering is not guaranteed, so find our
+                // transaction by hash rather than assuming it is the most
+                // recent one.
+                json::Value const* accountTx = nullptr;
+                for (auto const& entry : accountTxResult[jss::result][jss::transactions])
+                {
+                    if (getHash(entry) == txHash)
+                    {
+                        accountTx = &entry;
+                        break;
+                    }
+                }
+
+                if (!BEAST_EXPECT(accountTx != nullptr))
+                    continue;
+
+                if (auto const* meta = getMeta(*accountTx); BEAST_EXPECT(meta != nullptr))
+                    verifyMeta(*meta);
+            }
+        };
+
+        // Verify `nftoken_id` value equals to the NFTokenID that was
+        // changed in the most recent NFTokenMint or NFTokenAcceptOffer
+        // transaction
+        auto verifyNFTokenID = [&](uint256 const& actualNftID) {
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect nftoken_id field
+                if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id)))
+                    return;
+
+                // Check the value of NFT ID matches
+                uint256 nftID;
+                BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString()));
+                BEAST_EXPECT(nftID == actualNftID);
+            });
         };
 
         // Verify `nftoken_ids` value equals to the NFTokenIDs that were
         // changed in the most recent NFTokenCancelOffer transaction
         auto verifyNFTokenIDsInCancelOffer = [&](std::vector actualNftIDs) {
-            // Get the hash for the most recent transaction.
-            std::string const txHash{
-                env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
-
-            env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
-
-            // Expect nftokens_ids field and verify the values
-            if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids)))
-                return;
-
-            // Convert NFT IDs from json::Value to uint256
-            std::vector metaIDs;
-            std::transform(
-                meta[jss::nftoken_ids].begin(),
-                meta[jss::nftoken_ids].end(),
-                std::back_inserter(metaIDs),
-                [this](json::Value id) {
-                    uint256 nftID;
-                    BEAST_EXPECT(nftID.parseHex(id.asString()));
-                    return nftID;
-                });
-
-            // Sort both array to prepare for comparison
-            std::ranges::sort(metaIDs);
+            // Sort to prepare for comparison
             std::ranges::sort(actualNftIDs);
 
-            // Make sure the expect number of NFTs is correct
-            BEAST_EXPECT(metaIDs.size() == actualNftIDs.size());
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect nftoken_ids field and verify the values
+                if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids)))
+                    return;
 
-            // Check the value of NFT ID in the meta with the
-            // actual values
-            for (size_t i = 0; i < metaIDs.size(); ++i)
-                BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]);
+                // Convert NFT IDs from json::Value to uint256
+                std::vector metaIDs;
+                std::transform(
+                    meta[jss::nftoken_ids].begin(),
+                    meta[jss::nftoken_ids].end(),
+                    std::back_inserter(metaIDs),
+                    [this](json::Value id) {
+                        uint256 nftID;
+                        BEAST_EXPECT(nftID.parseHex(id.asString()));
+                        return nftID;
+                    });
+
+                std::ranges::sort(metaIDs);
+
+                // Make sure the expect number of NFTs is correct
+                if (!BEAST_EXPECT(metaIDs.size() == actualNftIDs.size()))
+                    return;
+
+                // Check the value of NFT ID in the meta with the
+                // actual values
+                for (size_t i = 0; i < metaIDs.size(); ++i)
+                    BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]);
+            });
         };
 
         // Verify `offer_id` value equals to the offerID that was
         // changed in the most recent NFTokenCreateOffer tx
         auto verifyNFTokenOfferID = [&](uint256 const& offerID) {
-            // Get the hash for the most recent transaction.
-            std::string const txHash{
-                env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect offer_id field and verify the value
+                if (!BEAST_EXPECT(meta.isMember(jss::offer_id)))
+                    return;
 
-            env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
-
-            // Expect offer_id field and verify the value
-            if (!BEAST_EXPECT(meta.isMember(jss::offer_id)))
-                return;
-
-            uint256 metaOfferID;
-            BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString()));
-            BEAST_EXPECT(metaOfferID == offerID);
+                uint256 metaOfferID;
+                BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString()));
+                BEAST_EXPECT(metaOfferID == offerID);
+            });
         };
 
         // Check new fields in tx meta when for all NFTtransactions
@@ -7275,6 +7436,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()};
 
@@ -7306,6 +7588,7 @@ protected:
         testNFTokenWithTickets(features);
         testNFTokenDeleteAccount(features);
         testNftXxxOffers(features);
+        testNftXxxOffersMarkerWrongSide(features);
         testNFTokenNegOffer(features);
         testIOUWithTransferFee(features);
         testBrokeredSaleToSelf(features);
@@ -7316,6 +7599,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..80541480e8 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 
@@ -21,11 +24,14 @@
 #include 
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -35,6 +41,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +53,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -609,6 +617,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 +1216,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 +3151,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 +3570,260 @@ 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);
+        }
+
+        {
+            // Companion to the transfer-rate overflow cases above. The taker
+            // sells TakerPays=MPT(~1.84e18) for TakerGets=XRP(1) against a
+            // same-magnitude poison offer, forcing BookStep::revImp()'s
+            // limitStepOut() to strictly reduce and overflow.
+            //
+            // The taker's own quality is also unrepresentable here
+            // (getRate(TakerGets, TakerPays) == 0: a large MPT numerator over
+            // a small XRP denominator overflows the rate mantissa), but that
+            // no longer short-circuits the transaction -- crossing is
+            // attempted, and only a residual that would REST is stopped. So
+            // this exercises the deeper safety net:
+            // BookStep::forEachOffer's catch(std::overflow_error), which under
+            // featureMPTokensV2 removes the offending offer rather than
+            // propagating.
+            //
+            // Net effect: the poison offer is consumed off the book instead of
+            // being left to poison the next taker, nothing crosses, and the
+            // taker's own offer is not placed because its rate is
+            // unrepresentable -- so tecKILLED, which charges a fee and
+            // advances the sequence.
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}};
+
+            env(pay(issuer, taker, token(1)));
+            env.close();
+
+            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 takerSeqBefore = env.seq(taker);
+
+            auto const takerSeq = takerSeqBefore;
+            auto const fee = env.current()->fees().base;
+            env(offer(taker, token(funded), XRP(1)), Ter(tecKILLED));
+            env.close();
+
+            // The overflowing poison offer is removed by BookStep. Nothing
+            // crossed, so no asset changes hands and the taker's offer is not
+            // placed; the fee is burned and the sequence advances.
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) == nullptr);
+            BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore);
+            BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee);
+            BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore);
+            BEAST_EXPECT(env.seq(taker) == takerSeqBefore + 1);
+        }
+
+        {
+            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)
     {
@@ -4787,6 +5509,215 @@ public:
         }
     }
 
+    void
+    testMPTOfferZeroRate(FeatureBitset features)
+    {
+        // An MPT offer whose quality is not representable must not REST -- on
+        // both the buy and sell sides, with or without a TickSize on the IOU
+        // issuer. Here nothing crosses it, so the whole offer is the remainder
+        // and the result is tecKILLED with nothing placed.
+        //
+        // getRate(TakerGets, TakerPays) returns 0 when the rate overflows: a
+        // large MPT TakerPays (XLS-0082 allows up to 2^63-1) over a small IOU
+        // TakerGets. Such an offer would otherwise (a) rest in the quality-0
+        // book directory, whose index equals getBookBase(), which BookTip's
+        // strict successor scan never returns -- so it can never be crossed yet
+        // still consumes the owner's reserve; and (b) on a TickSize market,
+        // drive the tick-rounding path in applyGuts to divide by a zero rate,
+        // throwing and surfacing as tefEXCEPTION. A normally-priced offer in the
+        // same market is unaffected.
+        //
+        // See testMPTOfferZeroRateCrossable for the other half of the
+        // behavior: an unrepresentable quality that CROSSES is not rejected.
+        testcase("MPT Offer Zero Rate");
+
+        using namespace jtx;
+
+        // Mantissa well above the ~1.84e17 overflow threshold (with an IOU
+        // denominator mantissa of 1e15); still within the XLS-0082 range.
+        auto const kBigMpt = 5'000'000'000'000'000'000LL;
+
+        auto runScenario = [&](bool withTickSize) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            env.fund(XRP(10'000), gw, alice);
+            env.close();
+
+            auto const usd = gw["USD"];
+            env(trust(alice, usd(1'000)));
+            env(pay(gw, alice, usd(100)));
+            env.close();
+
+            if (withTickSize)
+            {
+                auto txn = noop(gw);
+                txn[sfTickSize.fieldName] = 5;
+                env(txn);
+                env.close();
+                BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+            }
+
+            // gw issues a DEX-tradable MPT (CanTrade | CanTransfer by default)
+            // and authorizes alice to hold it.
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice}, .maxAmt = kMaxMpTokenAmount});
+
+            // Buy side: TakerPays = large MPT, TakerGets = small IOU.
+            // getRate() overflows to 0 and nothing crosses -> killed, no
+            // offer placed and no reserve consumed.
+            BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+            env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+
+            // Sell side (tfSell): killed regardless of the flag, since the
+            // rate is computed from the raw amounts either way.
+            BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+            env(offer(alice, mpt(kBigMpt), usd(1), tfSell), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+
+            // Control: a normally-priced offer in the same market still
+            // places (and the tick-size rounding path still works when
+            // withTickSize is set).
+            env(offer(alice, mpt(10'000'000), usd(30)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).size() == 1);
+        };
+
+        // Without a TickSize: previously placed as a dead, never-crossable
+        // quality-0 entry that still consumed reserve.
+        runScenario(/*withTickSize=*/false);
+        // With a TickSize: previously threw and surfaced as tefEXCEPTION. The
+        // rounding is now skipped when the rate is unrepresentable.
+        runScenario(/*withTickSize=*/true);
+    }
+
+    void
+    testZeroRateXrpIouOffer(FeatureBitset features)
+    {
+        // A rate-0 offer is reachable without MPT: for XRP/IOU the "too
+        // good" underflow path makes getRate() return 0 when a tiny IOU
+        // TakerPays is divided by an XRP TakerGets.
+        //
+        // Without featureMPTokensV2 the offer is accepted and placed, but
+        // rests in the quality-0 book directory (whose index == getBookBase),
+        // which BookTip's strict successor scan never returns -- so it can
+        // never be crossed, even by a willing, better-priced counterparty.
+        // With featureMPTokensV2 the same offer crosses nothing and is not
+        // placed, so it is killed.
+        testcase("Zero Rate XRP/IOU Offer");
+
+        using namespace jtx;
+
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        auto const usd = gw["USD"];
+
+        // Smallest-magnitude IOU: mantissa kMinValue, exponent kMinOffset
+        // (= 1e-81). divide(tinyUsd, XRP(1000)) underflows below kMinOffset
+        // and canonicalizes to 0, so getRate() returns 0.
+        auto const tinyUsd = STAmount{usd, UINT64_C(1'000'000'000'000'000), -96};
+
+        auto setup = [&](Env& env) {
+            env.fund(XRP(100'000), gw, alice, bob);
+            env.close();
+            env(trust(alice, usd(1'000)));
+            env(trust(bob, usd(1'000)));
+            env(pay(gw, bob, usd(100)));
+            env.close();
+        };
+
+        // featureMPTokensV2 disabled: legacy behavior -- placed but inert.
+        {
+            Env env{*this, features - featureMPTokensV2};
+            setup(env);
+
+            // TakerPays = tiny IOU, TakerGets = XRP -> rate 0.
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS));
+            env.close();
+
+            auto const aliceOffers = offersOnAccount(env, alice);
+            BEAST_EXPECT(aliceOffers.size() == 1);
+            // Placed in the quality-0 book directory.
+            BEAST_EXPECT(getQuality((*aliceOffers.front())[sfBookDirectory]) == 0);
+
+            // A complementary offer that would cross a usable offer at this
+            // (astronomically good) price does NOT cross it, because the
+            // quality-0 directory is never visited: both offers rest.
+            env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).size() == 1);
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+        }
+
+        // featureMPTokensV2 disabled, with a TickSize on the IOU issuer: the
+        // tick-rounding path divides by the zero rate and throws, surfacing as
+        // tefEXCEPTION. Legacy behavior, and it must stay that way -- the
+        // guard that skips the rounding is gated on the amendment, since
+        // changing this without a gate would fork a pre-amendment ledger.
+        {
+            Env env{*this, features - featureMPTokensV2};
+            setup(env);
+
+            auto txn = noop(gw);
+            txn[sfTickSize.fieldName] = 5;
+            env(txn);
+            env.close();
+            BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tefEXCEPTION));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+
+        // featureMPTokensV2 enabled: nothing crosses, so the remainder is the
+        // whole offer and it is killed rather than placed.
+        {
+            Env env{*this, features};
+            setup(env);
+
+            BEAST_EXPECT(getRate(XRP(1000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1000)), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+
+        // featureMPTokensV2 enabled, with a counterparty already on the book:
+        // the same unrepresentable quality now CROSSES. This is the reviewer's
+        // objection with no MPT anywhere in it -- the old preflight check
+        // rejected this outright even though it fills completely and rests
+        // nothing.
+        {
+            Env env{*this, features};
+            setup(env);
+
+            // Bob rests first: he gives usd(10) to receive XRP(1'000).
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+            // Alice offers up to XRP(1'000) for a dust amount of USD -- rate
+            // 0, at a price bob's offer improves on enormously.
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS));
+            env.close();
+
+            // Alice asked for dust and got exactly that, so her offer is
+            // fully satisfied and never reaches the book. Bob's offer is
+            // barely touched and stays. The old preflight check rejected this
+            // transaction outright, with no MPT involved anywhere.
+            BEAST_EXPECT(env.balance(alice, usd).value() == tinyUsd);
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+    }
+
     void
     testAutoCreateReserve(FeatureBitset features)
     {
@@ -4882,6 +5813,642 @@ public:
         }
     }
 
+    void
+    testBookOffersMPTFunding(FeatureBitset features)
+    {
+        testcase("book_offers uses MPT issuer capacity, transfer fees, and locks");
+
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const maker{"maker"};
+        Account const buyer{"buyer"};
+
+        // Issuer-owned MPT offers are funded only by remaining issuance
+        // capacity. Once ordinary issuance consumes the cap, book_offers must
+        // report the stale issuer offer as zero-funded.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester musd(
+                {.env = env, .issuer = issuer, .holders = {maker, buyer}, .maxAmt = 100});
+            MPT const usd = musd;
+
+            auto const issuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+
+            musd.pay(issuer, maker, 100);
+
+            auto const issuance = env.le(keylet::mptokenIssuance(usd.mpt()));
+            if (!BEAST_EXPECT(issuance))
+                return;
+            BEAST_EXPECT(issuance->getFieldU64(sfOutstandingAmount) == 100);
+            BEAST_EXPECT(issuance->getFieldU64(sfMaximumAmount) == 100);
+
+            env(offer(maker, XRP(200), usd(100)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() >= 2))
+                return;
+
+            json::Value const& issuerOffer = bookOffers[0u];
+            BEAST_EXPECT(issuerOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(issuerOffer[sfSequence.jsonName] == issuerOfferSeq);
+            BEAST_EXPECT(issuerOffer[jss::owner_funds] == "0");
+            BEAST_EXPECT(issuerOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(issuerOffer[jss::taker_gets_funded][jss::value] == "0");
+            BEAST_EXPECT(issuerOffer.isMember(jss::taker_pays_funded));
+            BEAST_EXPECT(issuerOffer[jss::taker_pays_funded] == "0");
+        }
+
+        // Multiple issuer-owned MPT offers share the same bounded self-issue
+        // capacity. The second offer exercises the cached running balance path
+        // after the first offer has consumed part of the issuer's capacity.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, buyer);
+            env.close();
+
+            MPTTester const musd({.env = env, .issuer = issuer, .holders = {buyer}, .maxAmt = 150});
+            MPT const usd = musd;
+
+            auto const firstIssuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+            auto const secondIssuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() >= 2))
+                return;
+
+            json::Value const& firstOffer = bookOffers[0u];
+            BEAST_EXPECT(firstOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstIssuerOfferSeq);
+            BEAST_EXPECT(firstOffer[jss::owner_funds] == "150");
+            BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded));
+
+            json::Value const& secondOffer = bookOffers[1u];
+            BEAST_EXPECT(secondOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondIssuerOfferSeq);
+            BEAST_EXPECT(!secondOffer.isMember(jss::owner_funds));
+            BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "50");
+            BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
+            BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "50000000");
+        }
+
+        auto checkTransferFeeBookOffers = [&](std::uint16_t transferFee, auto&& checkOffers) {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester const musd(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {maker, buyer},
+                 .transferFee = transferFee,
+                 .pay = 3'000});
+            MPT const usd = musd;
+            if (transferFee != 0)
+                BEAST_EXPECT(musd.checkTransferFee(transferFee));
+
+            auto const firstOfferSeq = env.seq(maker);
+            env(offer(maker, XRP(1'500), usd(1'500)));
+            auto const secondOfferSeq = env.seq(maker);
+            env(offer(maker, XRP(1'500), usd(1'500)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() == 2))
+                return;
+
+            checkOffers(bookOffers, firstOfferSeq, secondOfferSeq);
+        };
+
+        // With no MPT transfer fee, two identical maker offers backed by 3000
+        // owner funds are both fully funded for 1500 MPT.
+        checkTransferFeeBookOffers(
+            0,
+            [&](json::Value const& bookOffers,
+                std::uint32_t firstOfferSeq,
+                std::uint32_t secondOfferSeq) {
+                for (auto const i : {0u, 1u})
+                {
+                    json::Value const& offer = bookOffers[i];
+                    BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                    BEAST_EXPECT(
+                        offer[sfSequence.jsonName] == (i == 0u ? firstOfferSeq : secondOfferSeq));
+                    BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
+                    BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
+                }
+                BEAST_EXPECT(bookOffers[0u][jss::owner_funds] == "3000");
+            });
+
+        // With a 50% MPT transfer fee, the first identical maker offer consumes
+        // 2250 owner funds, so the second offer can deliver only 500 MPT.
+        checkTransferFeeBookOffers(
+            50'000,
+            [&](json::Value const& bookOffers,
+                std::uint32_t firstOfferSeq,
+                std::uint32_t secondOfferSeq) {
+                json::Value const& firstOffer = bookOffers[0u];
+                BEAST_EXPECT(firstOffer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstOfferSeq);
+                BEAST_EXPECT(firstOffer[jss::owner_funds] == "3000");
+                BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded));
+
+                json::Value const& secondOffer = bookOffers[1u];
+                BEAST_EXPECT(secondOffer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondOfferSeq);
+                // A 50% MPT transfer fee leaves only 750 owner funds after
+                // the first offer. That can fund 500 MPT delivered to the
+                // taker on the same second offer that was fully funded without
+                // the transfer fee.
+                BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "500");
+                BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
+                BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "500000000");
+            });
+
+        // A large MPT balance used to overflow the fee adjustment. divide()
+        // assumes an IOU mantissa, always normalized into [1e15, 1e16), and
+        // scales the numerator by 1e17. An MPT mantissa is the raw int64
+        // balance, so past ~1.8e17 the scaled quotient leaves uint64 range and
+        // throws -- failing the whole RPC with "internal", so one offer owner
+        // blanked the entire book for every caller.
+        //
+        // The quotient itself always fits, because the branch only runs when
+        // the rate exceeds parity. The cases below pin that at the edges of
+        // the domain rather than leaving it to inspection.
+        auto checkLargeOwnerFunds =
+            [&](std::uint16_t transferFee, std::int64_t funds, char const* expectedFunded) {
+                Env env{*this, features};
+                env.fund(XRP(10'000), issuer, maker, buyer);
+                env.close();
+
+                MPT const usd = MPTTester(
+                    {.env = env,
+                     .issuer = issuer,
+                     .holders = {maker, buyer},
+                     .transferFee = transferFee,
+                     .maxAmt = kMaxMpTokenAmount});
+                env(pay(issuer, maker, usd(funds)));
+                env.close();
+
+                auto const offerSeq = env.seq(maker);
+                env(offer(maker, XRP(100), usd(funds)));
+                env.close();
+
+                json::Value const jrr = getBookOffers(env, XRP, usd);
+                BEAST_EXPECT(!jrr.isMember(jss::error));
+                json::Value const& bookOffers = jrr[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray());
+                if (!BEAST_EXPECT(bookOffers.size() == 1))
+                    return;
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == std::to_string(funds));
+                BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == expectedFunded);
+            };
+
+        // Above the ~2.77e17 boundary at the maximum transfer rate of 1.5:
+        // 3e17 of owner funds covers 2e17 delivered.
+        checkLargeOwnerFunds(kMaxTransferFee, 300'000'000'000'000'000LL, "200000000000000000");
+        // Large balance at the maximum rate. Kept at 6e18 so that 6e18 * 1.5
+        // stays representable: offer crossing's rate-preservation path
+        // overflows above that, which is a separate defect from this one.
+        checkLargeOwnerFunds(kMaxTransferFee, 6'000'000'000'000'000'000LL, "4000000000000000000");
+        // Near-maximum balance at the smallest rate above parity. This is the
+        // largest quotient the branch can produce, and the case the old code
+        // failed earliest on -- its overflow boundary is lowest, ~1.8e17, when
+        // the rate is closest to parity.
+        checkLargeOwnerFunds(1, 9'000'000'000'000'000'000LL, "8999910000899991000");
+
+        // An MPT global lock makes book_offers report the locked MPT book
+        // liquidity as zero-funded instead of funded.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester musd(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {maker, buyer},
+                 .pay = 100,
+                 .flags = kMptDexFlags | tfMPTCanLock});
+            MPT const usd = musd;
+
+            auto const offerSeq = env.seq(maker);
+            env(offer(maker, XRP(100), usd(100)));
+            env.close();
+
+            {
+                json::Value const jrr = getBookOffers(env, XRP, usd);
+                json::Value const& bookOffers = jrr[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray());
+                if (!BEAST_EXPECT(bookOffers.size() == 1))
+                    return;
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == "100");
+                BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
+            }
+
+            musd.set({.flags = tfMPTLock});
+
+            {
+                // The lock does not remove the offer from the ledger;
+                // book_offers must report it as zero-funded liquidity.
+                auto const bookOffers = getBookOffers(env, XRP, usd)[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray() && bookOffers.size() == 1);
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount] == maker.human());
+                BEAST_EXPECT(offer[sfSequence] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == "0");
+                BEAST_EXPECT(offer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == "0");
+                BEAST_EXPECT(offer.isMember(jss::taker_pays_funded));
+                BEAST_EXPECT(offer[jss::taker_pays_funded] == "0");
+            }
+        }
+    }
+
+    // getBookBase hashes raw concatenations of fixed-width fields, so the
+    // (Issue,MPT) preimage `currency(20)||mptID(24)||account(20)` and the
+    // (MPT,Issue) preimage `mptID(24)||currency(20)||account(20)` are both
+    // 64 bytes and collide when the bytes align. An attacker picks the IOU
+    // currency, reuses an IOU issuer, and grinds an MPT issuer / sequence;
+    // the per-branch discriminator in getBookBase blocks this.
+    void
+    testBookBaseMixedAssetCollision(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: (Issue,MPT) vs (MPT,Issue) preimage collision");
+
+        // Construction recipe:
+        //   issuerB last 4 bytes == seq_A; mptID_B = BE(5) || issuerB
+        //   currencyA            == mptID_B[0..19] = BE(5) || issuerB[0..15]
+        //   issuerA              == currencyB (both 20-byte all-0xBB)
+        //   sharedIOUIssuer      == acct_A == acct_B
+        AccountID issuerB;
+        AccountID issuerA;
+        Currency currencyB;
+        Currency currencyA;
+        AccountID sharedIOUIssuer;
+        BEAST_EXPECT(issuerB.parseHex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00000007"));
+        BEAST_EXPECT(issuerA.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"));
+        BEAST_EXPECT(currencyB.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"));
+        BEAST_EXPECT(currencyA.parseHex("00000005AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
+        BEAST_EXPECT(sharedIOUIssuer.parseHex("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"));
+
+        Book const bookA{
+            Asset{Issue{currencyA, sharedIOUIssuer}},
+            Asset{MPTIssue{0x00000007u, issuerA}},
+            std::nullopt};
+        Book const bookB{
+            Asset{MPTIssue{0x00000005u, issuerB}},
+            Asset{Issue{currencyB, sharedIOUIssuer}},
+            std::nullopt};
+
+        BEAST_EXPECT(bookA != bookB);
+        BEAST_EXPECT(getBookBase(bookA) != getBookBase(bookB));
+    }
+
+    // (MPT,MPT) bodies are 48 bytes and can't length-match the 64-byte
+    // mixed branches, but tag them too for symmetry/future-proofing; this
+    // test also pins the directional asymmetry of an (MPT,MPT) book.
+    void
+    testBookBaseMptMptDistinct(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: (MPT,MPT) distinguishes from mixed branches");
+
+        AccountID issuerX;
+        AccountID issuerY;
+        Currency currency;
+        AccountID iouIssuer;
+        BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111"));
+        BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222"));
+        BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333"));
+        BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444"));
+
+        Asset const mptX{MPTIssue{1u, issuerX}};
+        Asset const mptY{MPTIssue{2u, issuerY}};
+        Book const mptBook{mptX, mptY, std::nullopt};
+        Book const mixedBook{mptX, Asset{Issue{currency, iouIssuer}}, std::nullopt};
+        Book const reversedMptBook{mptY, mptX, std::nullopt};
+
+        BEAST_EXPECT(getBookBase(mptBook) != getBookBase(mixedBook));
+        BEAST_EXPECT(getBookBase(mptBook) != getBookBase(reversedMptBook));
+    }
+
+    void
+    testBookBaseDomainMptDistinct(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: domain does not reopen MPT preimage collisions");
+
+        // The type tag is a front prefix and the domain is a 32-byte suffix, so a
+        // domain'd book must (a) stay distinct from its public counterpart and
+        // (b) preserve the mixed-branch tag distinction that the public case has.
+        AccountID issuerX, issuerY, iouIssuer;
+        Currency currency;
+        BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111"));
+        BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222"));
+        BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333"));
+        BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444"));
+
+        uint256 const domainA = uint256::fromVoid(
+            "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD"
+            "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD");
+
+        Asset const mptX{MPTIssue{1u, issuerX}};
+        Asset const iou{Issue{currency, iouIssuer}};
+
+        // (a) same pair, public vs domain'd -> distinct directories.
+        Book const publicBook{mptX, iou, std::nullopt};
+        Book const domainBook{mptX, iou, domainA};
+        BEAST_EXPECT(getBookBase(publicBook) != getBookBase(domainBook));
+
+        // (b) mixed-branch tag distinction still holds *with* a domain set:
+        //     (MPT,Issue) vs (Issue,MPT), both domain'd, must not collide.
+        Book const mi{mptX, iou, domainA};
+        Book const im{iou, mptX, domainA};
+        BEAST_EXPECT(mi != im);
+        BEAST_EXPECT(getBookBase(mi) != getBookBase(im));
+    }
+
+    void
+    testMPTOfferZeroRateCrossable(FeatureBitset features)
+    {
+        // An unrepresentable quality does not imply an offer that cannot
+        // function. "Can never be crossed" describes an offer that RESTS:
+        // crossing happens in applyGuts, before any residual is placed in the
+        // book, so an offer whose rate is unrepresentable can still consume a
+        // resting offer in full and never reach the quality-0 directory.
+        //
+        // The two sides of one trade do not have the same rate
+        // representability: getRate(TakerGets, TakerPays) overflows to 0 for
+        // the side paying a large MPT, but not for the side paying XRP. So a
+        // preflight rejection keyed on getRate() == 0 admits the resting half
+        // of a trade and rejects the crossing half.
+        testcase("MPT Offer Zero Rate - crossable quality");
+
+        using namespace jtx;
+
+        // Above the rate-overflow threshold: divide() scales the XRP
+        // denominator up to a 1e15 mantissa and then evaluates
+        // muldiv(mptMantissa, 1e17, denMantissa), which exceeds 2^64 -- so
+        // getRate() takes its catch-all and returns 0.
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        // Both scenarios are the same trade against the same resting offer,
+        // and both execute identically (at bob's price). They differ only in
+        // the price alice quotes, and therefore only in whether the rate on
+        // HER side of the book is representable.
+        auto runScenario = [&](STAmount const& aliceQuote, bool rateRepresentable) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            auto const bob = Account{"bob"};
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+
+            env(pay(gw, bob, mpt(kBigMpt)));
+            env.close();
+
+            // Bob rests the sell side: TakerPays = XRP(1), TakerGets =
+            // kBigMpt. getRate(TakerGets, TakerPays) is representable in this
+            // direction, so preflight admits it and it rests at a normal
+            // quality.
+            BEAST_EXPECT(getRate(mpt(kBigMpt), XRP(1)) != 0);
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+            // Alice takes it from the other side: TakerPays = kBigMpt,
+            // TakerGets = her quote.
+            BEAST_EXPECT((getRate(aliceQuote, mpt(kBigMpt)) != 0) == rateRepresentable);
+
+            auto const bobXrpBefore = env.balance(bob).value().xrp();
+            env(offer(alice, mpt(kBigMpt), aliceQuote), Ter(tesSUCCESS));
+            env.close();
+
+            // Alice's offer crosses bob's in full, so it never rests: nothing
+            // ends up in the quality-0 directory, no reserve is stranded, and
+            // the tick-rounding divide is never reached with a zero rate.
+            BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+            // And it executes at bob's 1 XRP, whatever alice quoted.
+            BEAST_EXPECT(env.balance(bob).value().xrp() == bobXrpBefore + XRP(1).value().xrp());
+        };
+
+        // Alice quotes bob's exact price. getRate() overflows to 0 on her
+        // side, yet the offer crosses in full and never rests.
+        runScenario(XRP(1), /*rateRepresentable=*/false);
+        // Alice quotes a price worse for herself, which halves the rate into
+        // representable range. Same execution as above: she pays 1 XRP for
+        // kBigMpt. The two cases are therefore numerically, not economically,
+        // different.
+        runScenario(XRP(2), /*rateRepresentable=*/true);
+    }
+
+    void
+    testMPTOfferZeroRatePartialCross(FeatureBitset features)
+    {
+        // The case between the two extremes: an unrepresentable quality that
+        // crosses PARTIALLY. The crossed portion must execute -- it never
+        // touches the book -- while the residual must not be placed, since it
+        // would rest in the quality-0 directory holding a reserve it could
+        // never earn back by being crossed.
+        testcase("MPT Offer Zero Rate - partial cross");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        Env env{*this, features};
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        env.fund(XRP(10'000), gw, alice, bob);
+        env.close();
+
+        MPT const mpt = MPTTester(
+            {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+
+        env(pay(gw, bob, mpt(kBigMpt)));
+        env.close();
+
+        // Bob rests a sell of kBigMpt for XRP(1) -- representable on his side.
+        auto const bobSeq = env.seq(bob);
+        env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+        env.close();
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+        // Alice asks for twice what bob has, at the same price. Her rate is
+        // unrepresentable: mantissa ratio 4e17 / 2e15 = 200 > ~184.47.
+        BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0);
+
+        auto const aliceXrpBefore = env.balance(alice).value().xrp();
+        auto const fee = env.current()->fees().base;
+
+        env(offer(alice, mpt(2 * kBigMpt), XRP(2)), Ter(tesSUCCESS));
+        env.close();
+
+        // The half that crossed executed at bob's price...
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+        BEAST_EXPECT(
+            env.balance(alice).value().xrp() == aliceXrpBefore - XRP(1).value().xrp() - fee);
+        // ...and the half that did not is dropped rather than placed, so no
+        // offer rests and no reserve is consumed.
+        BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        BEAST_EXPECT((*env.le(alice))[sfOwnerCount] == 1);  // the MPToken only
+    }
+
+    void
+    testMPTOfferZeroRateTickSizeCross(FeatureBitset features)
+    {
+        // TickSize plus an unrepresentable quality plus a counterparty on the
+        // book. The tick-rounding path is skipped for a zero rate, since it
+        // would divide by that rate and throw, so the offer crosses at its raw
+        // price. Every other TickSize case here faces an empty book, making
+        // this the only coverage that the skip leaves crossing intact --
+        // without it this transaction is tefEXCEPTION.
+        testcase("MPT Offer Zero Rate - tick size with crossing");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 5'000'000'000'000'000'000LL;
+
+        Env env{*this, features};
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        env.fund(XRP(10'000), gw, alice, bob);
+        env.close();
+
+        auto const usd = gw["USD"];
+        env(trust(alice, usd(1'000)));
+        env(pay(gw, alice, usd(100)));
+        env.close();
+
+        auto txn = noop(gw);
+        txn[sfTickSize.fieldName] = 5;
+        env(txn);
+        env.close();
+        BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+
+        MPT const mpt = MPTTester(
+            {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+        env(pay(gw, bob, mpt(kBigMpt)));
+        env.close();
+
+        // Bob rests the sell side; representable in that direction.
+        BEAST_EXPECT(getRate(mpt(kBigMpt), usd(1)) != 0);
+        auto const bobSeq = env.seq(bob);
+        env(offer(bob, usd(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+        env.close();
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+        // Alice takes it from the unrepresentable side, with the tick size in
+        // force on her TakerGets.
+        BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+        env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tesSUCCESS));
+        env.close();
+
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+        BEAST_EXPECT(offersOnAccount(env, alice).empty());
+    }
+
+    void
+    testMPTOfferZeroRateFlags(FeatureBitset features)
+    {
+        // A zero rate must not change what tfFillOrKill and tfImmediateOrCancel
+        // do. Both are handled above the unrepresentable-quality guard, but the
+        // ordering is not observable and no test can pin it: the guard returns
+        // the same pair either flag would. Immediate-or-cancel matches it by
+        // construction, and fill-or-kill disables partial payment
+        // (OfferCreate.cpp: flowCross is passed !tfFillOrKill), so a
+        // not-fully-fillable offer leaves crossed == false and both paths give
+        // {tecKILLED, false}. What this does cover is flags combined with an
+        // unrepresentable quality, which nothing else exercises.
+        testcase("MPT Offer Zero Rate - IOC and FoK");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        auto const runScenario = [&](std::uint32_t flags, TER expected) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            auto const bob = Account{"bob"};
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+            env(pay(gw, bob, mpt(kBigMpt)));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+            env.close();
+
+            // Asking for twice what bob has forces a partial cross, so the
+            // flag handling -- not the fully-crossed early return -- decides.
+            BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0);
+            env(offer(alice, mpt(2 * kBigMpt), XRP(2), flags), Ter(expected));
+            env.close();
+
+            auto const bobOfferLive =
+                env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr;
+            if (isTesSuccess(expected))
+            {
+                // Immediate-or-cancel: the crossed part is kept, the rest is
+                // cancelled -- the same shape the guard would produce.
+                BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+                BEAST_EXPECT(!bobOfferLive);
+            }
+            else
+            {
+                // Fill-or-kill: the offer is not fully fillable, so nothing
+                // crosses at all and bob's offer survives untouched.
+                BEAST_EXPECT(env.balance(alice, mpt) == mpt(0));
+                BEAST_EXPECT(bobOfferLive);
+            }
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        };
+
+        runScenario(tfImmediateOrCancel, tesSUCCESS);
+        runScenario(tfFillOrKill, tecKILLED);
+    }
+
     void
     testAll(FeatureBitset features)
     {
@@ -4920,6 +6487,7 @@ public:
         testSellOffer(features);
         testSellWithFillOrKill(features);
         testTransferRateOffer(features);
+        testTransferRateOverflowOffer(features);
         testSelfCrossOffer(features);
         testSelfIssueOffer(features);
         testDirectToDirectPath(features);
@@ -4934,11 +6502,24 @@ public:
         testDeletedOfferIssuer(features);
         testTicketOffer(features);
         testTicketCancelOffer(features);
+        testMPTAMMLimitQualityRounding(features);
         testRmSmallIncreasedQOffersXRP(features);
         testRmSmallIncreasedQOffersMPT(features);
+        testMPTIssuerOfferUsesRemainingCapacity(features);
+        testPartiallyFundedMPTInputOfferZeroInput(features);
         testFillOrKill(features);
         testTickSize(features);
+        testMPTOfferZeroRate(features);
+        testMPTOfferZeroRateCrossable(features);
+        testMPTOfferZeroRatePartialCross(features);
+        testMPTOfferZeroRateTickSizeCross(features);
+        testMPTOfferZeroRateFlags(features);
+        testZeroRateXrpIouOffer(features);
+        testBookOffersMPTFunding(features);
         testAutoCreateReserve(features);
+        testBookBaseMixedAssetCollision(features);
+        testBookBaseMptMptDistinct(features);
+        testBookBaseDomainMptDistinct(features);
     }
 
     void
diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp
index ff4a024cb8..87da13087f 100644
--- a/src/test/app/PathMPT_test.cpp
+++ b/src/test/app/PathMPT_test.cpp
@@ -14,6 +14,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 
@@ -25,6 +27,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +37,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -231,6 +236,102 @@ public:
         env.require(Balance("bob", usd(24)));
     }
 
+    void
+    sourceCurrencyWithSendMax()
+    {
+        testcase("source currency with send_max");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gateway");
+        env.fund(XRP(10'000), alice, bob, gw);
+
+        MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}});
+        env(pay(gw, alice, usd(25)));
+        env.close();
+
+        // MPT source_currencies entries do not carry an issuer. A matching
+        // send_max identifies the same issuance, so the request should not run
+        // the IOU issuer reconciliation path.
+        auto const result = findPathsRequest(
+            env,
+            alice,
+            bob,
+            usd(-1),
+            std::optional(usd(10).value()),
+            std::optional(usd.mpt()));
+        BEAST_EXPECTS(!result.isMember(jss::error), result.toStyledString());
+
+        auto const& alternatives = result[jss::alternatives];
+        if (BEAST_EXPECT(alternatives.size() == 1))
+        {
+            auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]);
+            auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]);
+            BEAST_EXPECTS(equal(sa, usd(10)), sa.getFullText());
+            BEAST_EXPECTS(equal(da, usd(10)), da.getFullText());
+        }
+    }
+
+    void
+    maxedOutMPTPathfinding()
+    {
+        testcase("maxed-out MPT pathfinding");
+        using namespace jtx;
+
+        auto hasMPT = [](auto const& assets, MPT const& mpt) {
+            return std::ranges::any_of(assets, [&](auto const& asset) {
+                return asset.template holds() && asset.template get() == mpt.mpt();
+            });
+        };
+
+        Env env = pathTestEnv();
+        auto const gw = Account("gateway");
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const carol = Account("carol");
+
+        env.fund(XRP(10'000), gw, alice, bob, carol);
+        env.close();
+
+        MPT const usd =
+            MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}, .maxAmt = 100});
+        env(pay(gw, alice, usd(90)));
+        env(pay(gw, bob, usd(10)));
+        env.close();
+
+        auto const cache =
+            std::make_shared(env.current(), env.app().getJournal("AssetCache"));
+
+        BEAST_EXPECT(hasMPT(accountSourceAssets(alice.id(), cache, false), usd));
+        BEAST_EXPECT(hasMPT(accountDestAssets(bob.id(), cache, false), usd));
+        BEAST_EXPECT(hasMPT(accountDestAssets(carol.id(), cache, false), usd));
+
+        // A fully minted issuance should not be advertised as issuer-side
+        // mintable source liquidity.
+        BEAST_EXPECT(!hasMPT(accountSourceAssets(gw.id(), cache, false), usd));
+
+        auto [st, sa, da] = findPaths(env, alice, bob, usd(5));
+        BEAST_EXPECT(st.empty());
+        BEAST_EXPECT(equal(sa, usd(5)));
+        BEAST_EXPECT(equal(da, usd(5)));
+
+        env(offer(carol, usd(5), XRP(5)));
+        env.close();
+
+        std::tie(st, sa, da) = findPaths(env, alice, bob, drops(-1), usd(100).value());
+        BEAST_EXPECT(sa == usd(5));
+        BEAST_EXPECT(equal(da, XRP(5)));
+        if (BEAST_EXPECT(st.size() == 1 && st[0].size() == 1))
+        {
+            auto const& pathElem = st[0][0];
+            BEAST_EXPECT(
+                pathElem.isOffer() && pathElem.getIssuerID() == xrpAccount() &&
+                pathElem.getCurrency() == xrpCurrency());
+        }
+    }
+
     void
     pathFind(bool const domainEnabled)
     {
@@ -441,6 +542,124 @@ public:
         }
     }
 
+    // Regression test: the Pathfinder constructor must honor the
+    // caller-supplied srcAmount (= the user's send_max from PathRequest)
+    // when ranking candidate paths in convert_all mode.
+    //
+    // Background. The MPT-DEX refactor of `Pathfinder::Pathfinder`
+    // (src/xrpld/rpc/detail/Pathfinder.cpp) replaced the original
+    // `mSrcAmount(srcAmount.value_or(...))` initializer with an
+    // unconditional `amountFromPathAsset(...)` call. The latter always
+    // returns the negative "no limit" STAmount sentinel, so the
+    // `srcAmount` constructor parameter became dead code:
+    // `getPathLiquidity` and `computePathRanks` ran `rippleCalculate`
+    // with `saMaxAmountReq` = sentinel and recorded each path's
+    // saturated capacity instead of the capacity reachable inside
+    // send_max.
+    //
+    // In convert_all_ mode (the only mode that allows send_max),
+    // `Pathfinder::rankPaths` ignores quality and orders purely by
+    // liquidity, then `Pathfinder::getBestPaths` only fills the last
+    // (kMaxPaths-th = 4th) slot when `pathRank.liquidity >= remaining`.
+    // For convert_all_ `remaining = largestAmount(dstAmount_)`, so the
+    // last slot effectively never fills and the cut keeps the top 3
+    // ranked paths. With the wrong (unbounded-budget) ranking, a
+    // low-capacity / high-rate path that would actually deliver the
+    // most under the user's send_max can be excluded entirely.
+    //
+    // Topology. Four candidate paths from alice's XRP to bob's USD-MPT,
+    // each via a distinct IOU intermediary issued by a different market
+    // maker:
+    //
+    //   charlie: XRP(1000) -> AUD(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   dave:    XRP(1000) -> EUR(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   eve:     XRP(1000) -> GBP(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   frank:   XRP(50)   -> JPY(50)   -> USD(75)     cap   50 XRP, rate 1.5
+    //
+    // Alice queries findPaths with destination = USD-MPT(-1) (convert_all)
+    // and send_max = XRP(100).
+    //
+    // Bug-free ranking (post-fix), with srcAmount = XRP(100):
+    //   charlie/dave/eve liquidity = min(100, 1000) * 0.5 = 50 USD each
+    //   frank   liquidity         = min(100, 50)   * 1.5 = 75 USD
+    // -> frank ranks first; the flow uses frank's 50 XRP at 1.5 (=75 USD)
+    //    plus 50 XRP via a 0.5-rate path (=25 USD), delivering USD(100).
+    //
+    // Pre-fix ranking, with srcAmount silently replaced by the sentinel:
+    //   charlie/dave/eve liquidity = 1000 * 0.5 = 500 USD each
+    //   frank   liquidity         =   50 * 1.5 = 75 USD
+    // -> frank ranks 4th; the last-slot rule excludes it from the
+    //    surviving path set, the cut keeps the three 0.5-rate paths,
+    //    and the flow delivers only 100 * 0.5 = USD(50).
+    //
+    // This test asserts the post-fix outcome (USD(100)). On the pre-fix
+    // tree the assertion fails with USD(50).
+    void
+    convertAllSendMaxRanking()
+    {
+        testcase("convert_all + send_max: srcAmount governs path ranking");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gateway");
+        auto const charlie = Account("charlie");
+        auto const dave = Account("dave");
+        auto const eve = Account("eve");
+        auto const frank = Account("frank");
+
+        env.fund(XRP(10'000), alice, bob, gw, charlie, dave, eve, frank);
+        env.close();
+
+        // USD MPT issued by gw; the four market makers and bob are holders.
+        // alice is not a holder because she only pays XRP; USD only ever
+        // flows from gw / market-maker offers to bob.
+        MPT const usd =
+            MPTTester({.env = env, .issuer = gw, .holders = {charlie, dave, eve, frank, bob}});
+
+        // Capitalize each market maker with the USD-MPT they will sell.
+        env(pay(gw, charlie, usd(500)));
+        env(pay(gw, dave, usd(500)));
+        env(pay(gw, eve, usd(500)));
+        env(pay(gw, frank, usd(75)));
+        env.close();
+
+        // Each market maker issues their own intermediate IOU.
+        auto const aud = charlie["AUD"];
+        auto const eur = dave["EUR"];
+        auto const gbp = eve["GBP"];
+        auto const jpy = frank["JPY"];
+
+        // Three high-capacity, low-rate paths (1 XRP -> 0.5 USD-MPT,
+        // capacity 1000 XRP each).
+        env(offer(charlie, XRP(1'000), aud(1'000)));
+        env(offer(charlie, aud(1'000), usd(500)));
+        env(offer(dave, XRP(1'000), eur(1'000)));
+        env(offer(dave, eur(1'000), usd(500)));
+        env(offer(eve, XRP(1'000), gbp(1'000)));
+        env(offer(eve, gbp(1'000), usd(500)));
+
+        // One low-capacity, high-rate path (1 XRP -> 1.5 USD-MPT,
+        // capacity 50 XRP).
+        env(offer(frank, XRP(50), jpy(50)));
+        env(offer(frank, jpy(50), usd(75)));
+        env.close();
+
+        // ripple_path_find with convert_all (USD(-1)) and send_max XRP(100).
+        STPathSet st;
+        STAmount sa;
+        STAmount da;
+        std::tie(st, sa, da) =
+            findPaths(env, alice, bob, usd(-1), std::optional(XRP(100).value()));
+
+        // Post-fix: frank's high-rate path is included in the surviving
+        // path set, so the flow uses 50 XRP at 1.5 plus 50 XRP at 0.5,
+        // delivering exactly USD(100) on alice's 100-XRP budget.
+        BEAST_EXPECT(sa == XRP(100));
+        BEAST_EXPECT(equal(da, usd(100)));
+    }
+
     void
     run() override
     {
@@ -448,6 +667,9 @@ public:
         noDirectPathNoIntermediaryNoAlternatives();
         directPathNoIntermediary();
         paymentAutoPathFind();
+        sourceCurrencyWithSendMax();
+        maxedOutMPTPathfinding();
+        convertAllSendMaxRanking();
         for (auto const domainEnabled : {false, true})
         {
             pathFind(domainEnabled);
diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp
index 29b4a5b048..cd61668b03 100644
--- a/src/test/app/Path_test.cpp
+++ b/src/test/app/Path_test.cpp
@@ -147,7 +147,8 @@ public:
         STAmount const& saDstAmount,
         std::optional const& saSendMax = std::nullopt,
         std::optional const& saSrcCurrency = std::nullopt,
-        std::optional const& domain = std::nullopt)
+        std::optional const& domain = std::nullopt,
+        std::optional const& saSrcIssuer = std::nullopt)
     {
         using namespace jtx;
 
@@ -181,6 +182,10 @@ public:
             auto& sc = params[jss::source_currencies] = json::ValueType::Array;
             json::Value j = json::ValueType::Object;
             j[jss::currency] = to_string(saSrcCurrency.value());
+            // Optional issuer for tests that need to exercise
+            // source_currencies entries more precisely than currency alone.
+            if (saSrcIssuer)
+                j[jss::issuer] = toBase58(*saSrcIssuer);
             sc.append(j);
         }
         if (domain)
@@ -209,10 +214,11 @@ public:
         STAmount const& saDstAmount,
         std::optional const& saSendMax = std::nullopt,
         std::optional const& saSrcCurrency = std::nullopt,
-        std::optional const& domain = std::nullopt)
+        std::optional const& domain = std::nullopt,
+        std::optional const& saSrcIssuer = std::nullopt)
     {
-        json::Value result =
-            findPathsRequest(env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain);
+        json::Value result = findPathsRequest(
+            env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain, saSrcIssuer);
         BEAST_EXPECT(!result.isMember(jss::error));
 
         STAmount da;
@@ -325,6 +331,53 @@ public:
         BEAST_EXPECT(result.isMember(jss::error));
     }
 
+    void
+    sourceCurrencyIssuerSelection()
+    {
+        testcase("source currency issuer selection");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gateway = Account("gateway");
+
+        env.fund(XRP(10000), alice, bob, gateway);
+        env.close();
+
+        auto const usd = gateway["USD"];
+        env.trust(usd(600), alice);
+        env.trust(usd(700), bob);
+        env.trust(alice["USD"](700), bob);
+        env(pay(gateway, alice, usd(70)));
+        env(pay(gateway, bob, usd(50)));
+        env.close();
+
+        // Ask for USD from an explicit source issuer while send_max is
+        // Alice-issued USD. The parser should choose gateway-issued USD
+        // because gateway is the issuer in source_currencies.
+        //
+        // The Alice/Bob trust line is intentional: if Alice-issued USD is also
+        // considered as a source asset, pathfinding can produce an additional
+        // alternative. The single expected alternative below verifies that only
+        // the explicit issuer is selected.
+        auto const result = findPathsRequest(
+            env,
+            alice,
+            bob,
+            bob["USD"](-1),
+            alice["USD"](100).value(),
+            usd.currency,
+            std::nullopt,
+            gateway.id());
+        auto const& alternatives = result[jss::alternatives];
+        BEAST_EXPECT(alternatives.size() == 1);
+        auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]);
+        auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]);
+        BEAST_EXPECTS(equal(sa, usd(100)), sa.getFullText());
+        BEAST_EXPECTS(equal(da, bob["USD"](100)), da.getFullText());
+    }
+
     void
     noDirectPathNoIntermediaryNoAlternatives()
     {
@@ -1968,6 +2021,7 @@ public:
     run() override
     {
         sourceCurrenciesLimit();
+        sourceCurrencyIssuerSelection();
         noDirectPathNoIntermediaryNoAlternatives();
         directPathNoIntermediary();
         paymentAutoPathFind();
diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp
index a7e4cd7615..fbe942948d 100644
--- a/src/test/app/PermissionedDEX_test.cpp
+++ b/src/test/app/PermissionedDEX_test.cpp
@@ -21,6 +21,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -179,7 +180,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite
     void
     testOfferCreate(FeatureBitset features)
     {
-        testcase("OfferCreate");
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        testcase << "OfferCreate"
+                 << (fixEnabled ? " (Cleanup3_4_0 enabled)" : " (Cleanup3_4_0 disabled)");
 
         // test preflight
         {
@@ -273,8 +277,10 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             // time advance
             env.close(std::chrono::seconds(20));
 
-            // devin cannot create offer with expired cred
-            env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION));
+            // Devin cannot create offer with expired cred. After fixCleanup3_4_0,
+            // doApply deletes the expired credential SLE and returns tecEXPIRED.
+            TER const expectedExpiredCredTer = fixEnabled ? tecEXPIRED : tecNO_PERMISSION;
+            env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(expectedExpiredCredTer));
             env.close();
         }
 
@@ -1510,7 +1516,9 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         env.close(std::chrono::seconds(100));
 
         // Confirm devin can no longer create domain offers.
-        env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecNO_PERMISSION));
+        // After fixCleanup3_4_0, OfferCreate deletes the expired credential and
+        // returns tecEXPIRED (covered in depth by testExpiredCredentialCleanup).
+        env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecEXPIRED));
         env.close();
 
         // The hybrid offer must still exist in the open book after expiry.
@@ -1635,6 +1643,202 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         BEAST_EXPECT(!offerExists(env, bob, carolOfferSeq));
     }
 
+    void
+    testExpiredCredentialCleanup(FeatureBitset features)
+    {
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        testcase << "Expired credential cleanup"
+                 << (fixEnabled ? " (Cleanup3_4_0 enabled)" : " (Cleanup3_4_0 disabled)");
+
+        TER const expectedExpiredCredTer = fixEnabled ? tecEXPIRED : tecNO_PERMISSION;
+
+        auto const fundAccount =
+            [](Env& env, Account const& account, Account const& gw, IOU const& usd) {
+                env.fund(XRP(1000), account);
+                env.close();
+                env.trust(usd(1000), account);
+                env.close();
+                env(pay(gw, account, usd(100)));
+                env.close();
+            };
+
+        auto const fundDevin = [&](Env& env, Account const& gw, IOU const& usd) {
+            Account const devin("devin");
+            fundAccount(env, devin, gw, usd);
+            return devin;
+        };
+
+        auto const createExpiringCredential = [](Env& env,
+                                                 Account const& subject,
+                                                 Account const& issuer,
+                                                 std::string const& credType) {
+            auto jv = credentials::create(subject, issuer, credType);
+            uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count();
+            jv[sfExpiration.jsonName] = t + 20;
+            env(jv);
+            env(credentials::accept(subject, issuer, credType));
+            env.close();
+
+            return keylet::credential(subject.id(), issuer.id(), makeSlice(credType));
+        };
+
+        auto const expectExpiredCredentialState = [&](Env const& env, Keylet const& credKey) {
+            if (fixEnabled)
+            {
+                BEAST_EXPECT(!env.le(credKey));
+            }
+            else
+            {
+                BEAST_EXPECT(env.le(credKey));
+            }
+        };
+
+        // A payment referencing a non-existent domain is rejected in preclaim.
+        {
+            Env env(*this, features);
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            uint256 const badDomain{
+                "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134"
+                "E5"};
+
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+            env.close();
+
+            env(pay(alice, bob, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(badDomain),
+                Ter(tecNO_PERMISSION));
+            env.close();
+        }
+
+        // OfferCreate with an expired credential.
+        {
+            Env env(*this, features);
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            Account const devin = fundDevin(env, gw, USD);
+            auto const credKey = createExpiringCredential(env, devin, domainOwner, credType);
+            BEAST_EXPECT(env.le(credKey));  // credential exists before expiry
+
+            env.close(std::chrono::seconds(20));
+
+            env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(expectedExpiredCredTer));
+            env.close();
+
+            expectExpiredCredentialState(env, credKey);
+        }
+
+        // Payment where the sender's credential is expired.
+        {
+            Env env(*this, features);
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            Account const devin = fundDevin(env, gw, USD);
+            auto const credKey = createExpiringCredential(env, devin, domainOwner, credType);
+
+            auto const bobOfferSeq{env.seq(bob)};
+            auto const bobCredKey =
+                keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+            env.close();
+
+            BEAST_EXPECT(env.le(credKey));
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+
+            env.close(std::chrono::seconds(20));
+
+            env(pay(devin, alice, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(expectedExpiredCredTer));
+            env.close();
+
+            expectExpiredCredentialState(env, credKey);
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+        }
+
+        // Payment where the destination's credential is expired.
+        {
+            Env env(*this, features);
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            Account const devin = fundDevin(env, gw, USD);
+            auto const credKey = createExpiringCredential(env, devin, domainOwner, credType);
+
+            auto const bobOfferSeq{env.seq(bob)};
+            auto const bobCredKey =
+                keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+            env.close();
+
+            BEAST_EXPECT(env.le(credKey));
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+
+            env.close(std::chrono::seconds(20));
+
+            env(pay(alice, devin, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(expectedExpiredCredTer));
+            env.close();
+
+            expectExpiredCredentialState(env, credKey);
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+        }
+
+        // Payment where both sender and destination credentials are expired.
+        {
+            Env env(*this, features);
+            auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
+                PermissionedDEX(env);
+
+            Account const devin = fundDevin(env, gw, USD);
+            Account const erin("erin");
+            fundAccount(env, erin, gw, USD);
+
+            auto const devinCredKey = createExpiringCredential(env, devin, domainOwner, credType);
+            auto const erinCredKey = createExpiringCredential(env, erin, domainOwner, credType);
+
+            auto const bobOfferSeq{env.seq(bob)};
+            auto const bobCredKey =
+                keylet::credential(bob.id(), domainOwner.id(), makeSlice(credType));
+            env(offer(bob, XRP(10), USD(10)), Domain(domainID));
+            env.close();
+
+            BEAST_EXPECT(env.le(devinCredKey));
+            BEAST_EXPECT(env.le(erinCredKey));
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+
+            env.close(std::chrono::seconds(20));
+
+            env(pay(devin, erin, USD(10)),
+                Path(~USD),
+                Sendmax(XRP(10)),
+                Domain(domainID),
+                Ter(expectedExpiredCredTer));
+            env.close();
+
+            expectExpiredCredentialState(env, devinCredKey);
+            expectExpiredCredentialState(env, erinCredKey);
+            BEAST_EXPECT(env.le(bobCredKey));
+            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
+        }
+    }
+
     void
     testHybridMalformedOffer(FeatureBitset features)
     {
@@ -2008,6 +2212,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)
     {
@@ -2072,6 +2413,7 @@ public:
         // Test domain offer (w/o hybrid)
         testOfferCreate(all);
         testOfferCreate(all - fixCleanup3_2_0);
+        testOfferCreate(all - fixCleanup3_4_0);
         testPayment(all);
         testPayment(all - fixCleanup3_2_0);
         testBookStep(all);
@@ -2082,6 +2424,8 @@ public:
         testAmmQualityNotLeaked(all);
         testAmmQualityNotLeaked(all - fixCleanup3_3_0);
         testAutoBridge(all);
+        testExpiredCredentialCleanup(all);
+        testExpiredCredentialCleanup(all - fixCleanup3_4_0);
 
         // Test hybrid offers
         testHybridOfferCreate(all);
@@ -2100,6 +2444,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..4a69ac17f9 100644
--- a/src/test/app/SHAMapStore_test.cpp
+++ b/src/test/app/SHAMapStore_test.cpp
@@ -1,7 +1,10 @@
+#include 
 #include 
 #include 
 #include 
+#include 
 
+#include 
 #include 
 #include 
 #include 
@@ -9,10 +12,13 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,30 +28,229 @@
 #include 
 #include 
 #include 
+#include 
 
-#include 
-
+#include 
 #include 
+#include 
+#include 
+#include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
 #include 
+#include 
 #include 
+#include 
 
 namespace xrpl::test {
 
 class SHAMapStore_test : public beast::unit_test::Suite
 {
-    static auto const kDeleteInterval = 8;
+    static constexpr int kDeleteInterval = 8;
+
+    // Mirrors SHAMapStoreImp::kMinimumDeletionIntervalSa, the floor that
+    // online_delete is held to in standalone mode. Note that the
+    // max_waiting_ledgers floor is derived from this minimum rather than from
+    // the configured interval -- SHAMapStoreImp.cpp computes it as
+    // minInterval / 4 -- so both constants below stay put if kDeleteInterval is
+    // ever raised.
+    static constexpr int kMinDeleteInterval = 8;
+    static constexpr int kMinWaitingLedgers = kMinDeleteInterval / 4;
+    static_assert(kDeleteInterval >= kMinDeleteInterval);
+
+    // The two wait durations healthWait() can choose from, spelled as they
+    // appear in its log message. onlineDelete() below sets
+    // recovery_wait_seconds to 1, so the full wait is 1000ms and the shortened
+    // wait is a tenth of that. Note that "Waiting 1000ms" does not contain
+    // "Waiting 100ms", so the two are distinguishable by substring.
+    static constexpr char const* kFullWait = "Waiting 1000ms for node to stabilize";
+    static constexpr char const* kShortWait = "Waiting 100ms for node to stabilize";
+
+    // Distinctive fragments of the other messages the tests below key off. Each
+    // is unique among everything SHAMapStoreImp logs, so a substring match
+    // identifies the message unambiguously.
+    //
+    // kRotating is logged once run() has committed to a rotation, immediately
+    // after the health check that gates it, and kFinished once a rotation has
+    // run to completion. kExpired is logged by healthWait() when the circuit
+    // breaker trips.
+    static constexpr char const* kRotating = "rotating";
+    static constexpr char const* kFinished = "finished rotation";
+    static constexpr char const* kExpired = "unable to make progress";
+
+    // A Logs implementation that records every message the store's own
+    // partition emits, keeping each message's severity alongside its text, and
+    // lets a test block until a given message has appeared.
+    //
+    // Severity is recorded because healthWait() picks the severity and the wait
+    // duration together, so the pair identifies which of its three logging
+    // branches ran: warn at the full wait when the server is unhealthy for a
+    // reason that is not expected to resolve on its own, trace at a tenth of
+    // the wait when the only missing ledger is the one currently being built,
+    // and info at the full wait otherwise. Matching on the pair is what lets
+    // the tests below assert which branch was taken instead of merely that some
+    // wait happened.
+    //
+    // waitFor() exists because run() logs on entry to a rotation, which is the
+    // only signal a test has that the store has passed the health check gating
+    // the rotation and is now inside it. Several of the branches under test are
+    // only reachable from there, and no other handle on the store exposes it.
+    class StoreLogs : public Logs
+    {
+        mutable std::mutex mutex_;
+        std::condition_variable cond_;
+        std::vector> messages_;
+
+        class Sink : public beast::Journal::Sink
+        {
+            StoreLogs& owner_;
+
+        public:
+            Sink(beast::Severity threshold, StoreLogs& owner)
+                : beast::Journal::Sink(threshold, false), owner_(owner)
+            {
+            }
+
+            // Env::AppBundle calls Logs::threshold() after the Application is
+            // built, which would otherwise raise this sink above Trace and
+            // discard the messages the buildingIndex branch logs.
+            void
+            threshold(beast::Severity) override
+            {
+            }
+
+            void
+            write(beast::Severity level, std::string const& text) override
+            {
+                {
+                    std::scoped_lock const lock(owner_.mutex_);
+                    owner_.messages_.emplace_back(level, text);
+                }
+                owner_.cond_.notify_all();
+            }
+
+            void
+            writeAlways(beast::Severity level, std::string const& text) override
+            {
+                write(level, text);
+            }
+        };
+
+        // Caller must hold mutex_. A nullopt severity matches any severity.
+        [[nodiscard]] std::size_t
+        countLocked(std::optional severity, std::string const& text) const
+        {
+            return std::count_if(messages_.begin(), messages_.end(), [&](auto const& message) {
+                return (!severity || message.first == *severity) &&
+                    message.second.find(text) != std::string::npos;
+            });
+        }
+
+    public:
+        StoreLogs() : Logs(beast::Severity::Trace)
+        {
+        }
+
+        // Only the store's own partition is logged at Trace; everything else
+        // is silenced, so that enabling trace for this one branch does not pay
+        // for formatting every trace message in the server.
+        std::unique_ptr
+        makeSink(std::string const& partition, beast::Severity) override
+        {
+            return std::make_unique(
+                partition == "SHAMapStore" ? beast::Severity::Trace : beast::Severity::Disabled,
+                *this);
+        }
+
+        // How many recorded messages were logged at `severity` and contain
+        // `text`.
+        [[nodiscard]] std::size_t
+        count(beast::Severity severity, std::string const& text) const
+        {
+            std::scoped_lock const lock(mutex_);
+            return countLocked(severity, text);
+        }
+
+        // How many recorded messages contain `text`, at any severity.
+        [[nodiscard]] std::size_t
+        count(std::string const& text) const
+        {
+            std::scoped_lock const lock(mutex_);
+            return countLocked(std::nullopt, text);
+        }
+
+        // Blocks until `text` has been logged at least `expected` times at
+        // `severity` -- or at any severity, if that is nullopt -- or until the
+        // timeout expires. Returns whether it got there.
+        //
+        // Waiting rather than sleeping-then-counting matters for the branches
+        // that are only reached after a rotation has started: the store gets
+        // there when it gets there, so a fixed sleep has to be sized for the
+        // slowest plausible machine, whereas this returns as soon as the
+        // message appears.
+        [[nodiscard]] bool
+        waitFor(
+            std::optional severity,
+            std::string const& text,
+            std::chrono::milliseconds timeout,
+            std::size_t expected = 1)
+        {
+            std::unique_lock lock(mutex_);
+            return cond_.wait_for(
+                lock, timeout, [&] { return countLocked(severity, text) >= expected; });
+        }
+
+        // As above, at any severity.
+        [[nodiscard]] bool
+        waitFor(
+            std::string const& text,
+            std::chrono::milliseconds timeout,
+            std::size_t expected = 1)
+        {
+            return waitFor(std::nullopt, text, timeout, expected);
+        }
+    };
 
     static auto
     onlineDelete(std::unique_ptr cfg)
     {
-        cfg->ledgerHistory = kDeleteInterval;
+        cfg = jtx::onlineDelete(std::move(cfg), kDeleteInterval);
+        cfg->section(Sections::kNodeDatabase).set(Keys::kRecoveryWaitSeconds, "1");
+        return cfg;
+    }
+
+    // online delete tuned so that a rotation, once it has started, spends a
+    // long time in clearPrior() before reaching the first health check inside
+    // the rotation body.
+    //
+    // clearSql() sleeps back_off_milliseconds at the top of every iteration and
+    // advances by delete_batch rows per iteration, so a delete_batch of 1 costs
+    // one sleep per ledger removed, for each of the three tables it is called
+    // on. That is what gives parkMidRotation() below a window measured in
+    // seconds rather than in microseconds. delete_batch is therefore the actual
+    // lever; back_off_milliseconds is set to the value SHAMapStoreImp already
+    // defaults to, and is spelled out only so the arithmetic above can be
+    // checked against the config rather than against the implementation.
+    //
+    // max_waiting_ledgers is pinned to its floor so that tripping the circuit
+    // breaker takes the fewest possible ledger closes.
+    static auto
+    slowOnlineDelete(std::unique_ptr cfg)
+    {
+        cfg = onlineDelete(std::move(cfg));
         auto& section = cfg->section(Sections::kNodeDatabase);
-        section.set(Keys::kOnlineDelete, std::to_string(kDeleteInterval));
+        section.set(Keys::kDeleteBatch, "1");
+        section.set(Keys::kBackOffMilliseconds, "100");
+        section.set(Keys::kMaxWaitingLedgers, std::to_string(kMinWaitingLedgers));
         return cfg;
     }
 
@@ -136,6 +341,96 @@ class SHAMapStore_test : public beast::unit_test::Suite
         BEAST_EXPECT(env.app().getRelationalDatabase().getAccountTransactionCount() == rows);
     }
 
+    // Wait until the SHAMapStore has finished processing the ledger that the
+    // preceding env.close() produced.
+    //
+    // env.close() returns as soon as the ledger_accept RPC returns, but the
+    // validated ledger path -- LedgerMaster::setValidLedger() ->
+    // SHAMapStore::onLedgerClosed() -- runs on a job queue thread. Without
+    // draining the job queue first, the store may not have been handed the
+    // ledger at all, in which case rendezvous() observes working_ == false and
+    // returns immediately, before any work has been done.
+    [[nodiscard]] static bool
+    syncStore(jtx::Env& env)
+    {
+        // Drain the job queue first, so that onLedgerClosed() has run and
+        // working_ is set. Then use the store's timeout overload, so a store
+        // that never finishes fails this test instead of blocking on it.
+        //
+        // Only the second wait is bounded: JobQueue::rendezvous() has no
+        // timeout overload, so a job that never completes hangs here. That is
+        // pre-existing -- ~AppBundle waits on it the same way for every jtx
+        // test -- but it does mean this helper is not hang-proof end to end.
+        env.app().getJobQueue().rendezvous();
+        return env.app().getSHAMapStore().rendezvous(std::chrono::seconds{60});
+    }
+
+    // Bring the SHAMapStore to the point where it has been handed a validated
+    // ledger and initialized lastRotated, and report how many extra ledgers had
+    // to be closed to get it there (normally none). Returns std::nullopt if
+    // syncStore() itself failed.
+    //
+    // syncStore() alone does not guarantee that, because
+    // SHAMapStoreImp::run()'s loop does not use the notification and the
+    // working_ flag safely:
+    //
+    //   * onLedgerClosed() notifies cond_ whether or not run()'s thread is
+    //     parked on it, and run() waits on cond_ without a predicate, so a
+    //     notification that lands while the thread is still starting up --
+    //     before it first reaches that wait -- is lost.
+    //   * run() clears working_ at the top of its loop without checking
+    //     whether newLedger_ is still set, so rendezvous() can report the
+    //     store idle with a validated ledger queued.
+    //
+    // Either way the store ends up parked with work pending, and only another
+    // notification gets it moving again. In a standalone test nothing else
+    // closes ledgers, so that has to come from here: this closes a ledger
+    // rather than polling getLastRotated(), because polling would just time
+    // out. onLedgerClosed() keeps only the most recent ledger in newLedger_,
+    // so the ledger the store picks up -- and therefore lastRotated -- is a
+    // timing detail, which is why the callers derive their expectations from
+    // the value they observe instead of assuming one.
+    //
+    // run() is deliberately left as it is. In production the only effect is
+    // latency: the trigger is validatedSeq >= lastRotated + deleteInterval, so
+    // a lost notification delays rotation to the next validated ledger and
+    // nothing is skipped or accumulated -- starting at 513 instead of 512 does
+    // not matter. Two consequences do follow from leaving it in place, and both
+    // hold today: nothing in production decides anything from working_ or
+    // rendezvous() (rendezvous() has no production callers at all), and a node
+    // whose ledgers only advance on demand -- standalone, driven by
+    // ledger_accept -- can sit on a queued ledger until something closes the
+    // next one, which is exactly the situation this helper is working around.
+    //
+    // So this helper is permanent rather than a stopgap. Working around the
+    // race must not make it invisible, so every extra close is logged. That
+    // keeps how often it is actually hit observable in the unit test output --
+    // which is the only signal left once these testcases stop flaking on it.
+    [[nodiscard]] std::optional
+    initializeStore(jtx::Env& env, int const maxExtraCloses = 3)
+    {
+        auto& store = env.app().getSHAMapStore();
+
+        for (int extraCloses = 0;; ++extraCloses)
+        {
+            if (!syncStore(env))
+                return std::nullopt;
+            if (store.getLastRotated() != 0 || extraCloses == maxExtraCloses)
+            {
+                if (extraCloses != 0)
+                {
+                    log << "initializeStore: the store needed " << extraCloses
+                        << " extra ledger close(s) to pick up a validated ledger. "
+                           "SHAMapStoreImp::run() dropped the notification for the "
+                           "first one; see the comment on initializeStore()."
+                        << std::endl;
+                }
+                return extraCloses;
+            }
+            env.close();
+        }
+    }
+
     int
     waitForReady(jtx::Env& env)
     {
@@ -144,11 +439,11 @@ class SHAMapStore_test : public beast::unit_test::Suite
         auto& store = env.app().getSHAMapStore();
 
         int ledgerSeq = 3;
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
         BEAST_EXPECT(!store.getLastRotated());
 
         env.close();
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         auto ledger = env.rpc("ledger", "validated");
         BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++)));
@@ -157,7 +452,417 @@ class SHAMapStore_test : public beast::unit_test::Suite
         return ledgerSeq;
     }
 
+    // Construct an Env whose config has online_delete enabled and is then
+    // mutated by `tweak`, and report how SHAMapStoreImp's constructor judged
+    // it: the message of the exception it threw, or std::nullopt if the Env was
+    // constructed successfully.
+    //
+    // SHAMapStoreImp is built from ApplicationImp's member initializer list, so
+    // a configuration it rejects surfaces as an exception thrown out of the Env
+    // constructor rather than as a failure at some later point.
+    //
+    // Note that ~AppBundle does not run when the Env constructor throws, so the
+    // global debug log sink it installed -- which holds a reference to this
+    // suite -- would outlive the suite. The catch below clears it, so callers
+    // are free to end on a configuration that is rejected.
+    std::optional
+    storeConfigResult(std::function const& tweak)
+    {
+        using namespace test::jtx;
+
+        try
+        {
+            Env const env{
+                *this,
+                envconfig([&tweak](std::unique_ptr cfg) {
+                    cfg = onlineDelete(std::move(cfg));
+                    tweak(*cfg);
+                    return cfg;
+                }),
+                nullptr,
+                beast::Severity::Disabled};
+            return std::nullopt;
+        }
+        // Deliberately broader than the std::runtime_error that
+        // SHAMapStoreImp throws: an unexpected exception type then shows up as
+        // a message mismatch naming the actual failure, rather than escaping
+        // this testcase.
+        catch (std::exception const& e)
+        {
+            // ~AppBundle did not run, so drop the sink it installed by hand
+            // rather than leaving a reference to this suite live in a global.
+            setDebugLogSink(nullptr);
+            return std::string{e.what()};
+        }
+    }
+
+    void
+    expectConfigRejected(std::string const& expected, std::function const& tweak)
+    {
+        auto const result = storeConfigResult(tweak);
+        BEAST_EXPECTS(result == expected, result.value_or(""));
+    }
+
+    void
+    expectConfigAccepted(std::function const& tweak)
+    {
+        auto const result = storeConfigResult(tweak);
+        BEAST_EXPECTS(!result, result.value_or(""));
+    }
+
+    // The state parkInHealthWait() leaves behind.
+    struct Parked
+    {
+        // Value of getLastRotated() before the rotation attempt began. The
+        // store must still report this for as long as it stays parked.
+        LedgerIndex lastRotated = 0;
+        // The validated ledger the store is waiting on, and the sequence
+        // getLastRotated() will report once the rotation finally completes.
+        LedgerIndex validated = 0;
+        // Sequence removed from LedgerMaster to create the gap, or 0 if
+        // `createGap` was false.
+        LedgerIndex gap = 0;
+    };
+
+    // Drive the store to the point where it is parked inside healthWait(),
+    // unable to proceed with a rotation: close ledgers until one more close
+    // would make the store due to rotate, optionally remove the newest ledger
+    // from LedgerMaster so that the attempt sees a gap in the range, close the
+    // triggering ledger, and then set the operating mode to `modeAfterClose`.
+    //
+    // Once parked, the store stays parked indefinitely. Its wait loop reruns
+    // every recovery_wait_seconds and exits only when the server looks healthy,
+    // when it is stopped, or when the validated ledger index reaches the
+    // circuit breaker -- and that index only advances when this test closes
+    // another ledger. So a caller can establish any server state it likes,
+    // hold it, and be sure the store observes it. That is what makes the tests
+    // below state machines rather than races.
+    //
+    // Returns std::nullopt if the setup did not reach a parked store, having
+    // already reported the failure.
+    std::optional
+    parkInHealthWait(jtx::Env& env, bool createGap, OperatingMode modeAfterClose)
+    {
+        using namespace std::chrono_literals;
+        using namespace test::jtx;
+
+        auto& lm = env.app().getLedgerMaster();
+        auto& store = env.app().getSHAMapStore();
+        auto& netOPs = env.app().getOPs();
+
+        env.fund(XRP(1000), Account("alice"));
+        env.close();
+        if (!BEAST_EXPECT(initializeStore(env).has_value()))
+            return std::nullopt;
+
+        Parked parked;
+        // The store adopts the first validated ledger it sees as lastRotated,
+        // and which one that is depends on timing, so read it rather than
+        // assuming a value.
+        parked.lastRotated = store.getLastRotated();
+        if (!BEAST_EXPECT(parked.lastRotated))
+            return std::nullopt;
+
+        // Close ledgers until the next close is the one that makes
+        // validatedSeq reach lastRotated + deleteInterval.
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        while (maxSeq + 1 < parked.lastRotated + kDeleteInterval)
+        {
+            env.close();
+            ++maxSeq;
+            if (!BEAST_EXPECT(syncStore(env)))
+                return std::nullopt;
+            if (!BEAST_EXPECTS(
+                    store.getLastRotated() == parked.lastRotated,
+                    std::to_string(store.getLastRotated())))
+                return std::nullopt;
+        }
+
+        // Drop out of FULL before touching LedgerMaster's internals, matching
+        // testLedgerGaps. This also keeps the store from rotating on the
+        // triggering close before the caller has set the state it wants
+        // observed.
+        netOPs.setMode(OperatingMode::CONNECTED);
+
+        // The gap goes one below the sequence that the close further down makes
+        // validated, never at that sequence itself: healthWait() derives
+        // buildingIndex from numMissing == 1 && !haveLedger(index), so a gap at
+        // the validated index reads as "that ledger is about to be built" and
+        // takes the short trace wait, whereas a gap below it reads as a
+        // genuinely incomplete range and takes the full wait. Nothing refills
+        // the gap, so the wait loop ends only when the circuit breaker trips or
+        // stop() intervenes.
+        if (createGap)
+        {
+            std::size_t iterations = 30;
+            while (!lm.haveLedger(maxSeq) && --iterations > 0)
+            {
+                std::this_thread::sleep_for(10ms);
+            }
+            if (!BEAST_EXPECTS(lm.haveLedger(maxSeq), std::to_string(maxSeq)))
+                return std::nullopt;
+
+            // Give the server a moment to finish any internal work on the
+            // ledger about to be removed, as testLedgerGaps does.
+            std::this_thread::sleep_for(250ms);
+
+            lm.clearLedger(maxSeq);
+            if (!BEAST_EXPECT(!lm.haveLedger(maxSeq)))
+                return std::nullopt;
+            parked.gap = maxSeq;
+        }
+
+        // This close makes the store due to rotate.
+        env.close();
+        ++maxSeq;
+        parked.validated = maxSeq;
+        netOPs.setMode(modeAfterClose);
+
+        // Drain the job queue so that onLedgerClosed() has handed the ledger to
+        // the store. Without this, working_ may still be false from the
+        // previous cycle and rendezvous() would report "done" before the store
+        // has even looked at this ledger.
+        env.app().getJobQueue().rendezvous();
+
+        if (!BEAST_EXPECT(!store.rendezvous(1s)))
+            return std::nullopt;
+        if (!BEAST_EXPECTS(
+                store.getLastRotated() == parked.lastRotated,
+                std::to_string(store.getLastRotated())))
+            return std::nullopt;
+
+        return parked;
+    }
+
+    // Drive the store to the point where it has passed the health check that
+    // gates a rotation and is inside the rotation body, then make the server
+    // unhealthy so that the next health check in there parks it.
+    //
+    // The gap cannot be created up front the way parkInHealthWait() does it,
+    // because the gating check would see it and refuse to start the rotation at
+    // all -- which is what testLedgerGaps() exercises. So this waits for the
+    // message run() logs immediately after that check, which is the store
+    // publishing that it is committed to the rotation, and creates the gap then.
+    //
+    // The margin that makes that safe is clearPrior(), which runs between the
+    // log and the first health check inside the rotation. Under
+    // slowOnlineDelete() it works through three tables one sequence at a time,
+    // sleeping back_off_milliseconds before each, and checks health after every
+    // one of those sleeps. So the store spends on the order of a second per
+    // table repeatedly asking whether it is healthy, against the microseconds
+    // this function needs to clear a ledger once waitFor() has returned.
+    //
+    // The gap has to be the validated ledger itself. healthWait() counts missing
+    // ledgers over the range from lastGoodValidatedLedger_ to the validated
+    // index, and run() sets the former to the latter just before starting the
+    // rotation, so for the duration of the rotation that range begins as a
+    // single sequence and grows only as the caller closes more ledgers.
+    //
+    // Which health check inside the rotation ends up observing the gap is not
+    // pinned down, and does not need to be: whichever one it is returns the same
+    // answer, clearPrior() gives up, and run() reaches its first switch on
+    // healthWait() with the condition still in force. Every assertion below
+    // holds for any of them.
+    //
+    // Returns std::nullopt if the setup did not reach a parked store, having
+    // already reported the failure.
+    std::optional
+    parkMidRotation(jtx::Env& env, StoreLogs& log)
+    {
+        using namespace std::chrono_literals;
+        using namespace test::jtx;
+
+        auto& lm = env.app().getLedgerMaster();
+        auto& store = env.app().getSHAMapStore();
+
+        auto const alice = Account("alice");
+        env.fund(XRP(1000), alice);
+        env.close();
+        if (!BEAST_EXPECT(initializeStore(env).has_value()))
+            return std::nullopt;
+
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        // Close one ledger, carrying a transaction so that the sequence has rows
+        // in all three of the tables clearSql() works through.
+        auto closeOne = [&]() -> bool {
+            env(noop(alice));
+            env.close();
+            ++maxSeq;
+            return BEAST_EXPECT(syncStore(env));
+        };
+
+        // Let one rotation complete before setting up the one to be parked. The
+        // window this helper depends on only exists once the tables hold a full
+        // delete interval of rows: on the very first rotation there is at most
+        // one sequence to remove, so clearSql() sleeps once or not at all.
+        LedgerIndex const firstRotated = store.getLastRotated();
+        if (!BEAST_EXPECT(firstRotated))
+            return std::nullopt;
+        while (store.getLastRotated() == firstRotated)
+        {
+            if (!closeOne())
+                return std::nullopt;
+            // The rotation is due once maxSeq reaches firstRotated +
+            // kDeleteInterval. Allow one close beyond that before giving up,
+            // rather than closing ledgers forever.
+            if (!BEAST_EXPECTS(maxSeq <= firstRotated + kDeleteInterval, std::to_string(maxSeq)))
+                return std::nullopt;
+        }
+
+        Parked parked;
+        parked.lastRotated = store.getLastRotated();
+
+        // Close ledgers until the next close is the one that makes the store due
+        // to rotate again.
+        while (maxSeq + 1 < parked.lastRotated + kDeleteInterval)
+        {
+            if (!closeOne())
+                return std::nullopt;
+            if (!BEAST_EXPECTS(
+                    store.getLastRotated() == parked.lastRotated,
+                    std::to_string(store.getLastRotated())))
+                return std::nullopt;
+        }
+
+        // One rotation has already been logged, so wait for the next one rather
+        // than for the first.
+        auto const rotationsBefore = log.count(kRotating);
+
+        // This close makes the store due to rotate. Deliberately do not drain
+        // the store here: the point is to interrupt it partway through.
+        env(noop(alice));
+        env.close();
+        ++maxSeq;
+        parked.validated = maxSeq;
+
+        // Draining the job queue, on the other hand, is required. In standalone
+        // mode switchLCL() inserts the closed ledger into LedgerMaster's
+        // complete range and then posts an advance job, and publishing the
+        // ledger from that job inserts it a second time. Clearing the ledger
+        // between those two inserts does not leave a lasting gap: publication
+        // puts it straight back, the rotation's health checks see a healthy
+        // node, and the rotation runs to completion. Waiting for the queue to
+        // drain closes that window, because publication is what advances
+        // pubLedger_ -- once it has happened, the ledger is never published, and
+        // so never inserted, again.
+        //
+        // This waits only for the job queue, not for the store, whose thread is
+        // its own and is the thing being interrupted here.
+        env.app().getJobQueue().rendezvous();
+
+        // Publishing the ledger is what advances pubLedger_, so this is the
+        // observable confirmation that the window above has closed. Asserting it
+        // here means that if anything ever reopens it, this setup step says so
+        // directly instead of the tests below failing for reasons that look
+        // nothing like the cause.
+        auto const published = lm.getPublishedLedger();
+        if (!BEAST_EXPECTS(
+                published && published->header().seq >= parked.validated,
+                std::to_string(published ? published->header().seq : 0)))
+            return std::nullopt;
+
+        if (!BEAST_EXPECT(log.waitFor(kRotating, 10s, rotationsBefore + 1)))
+            return std::nullopt;
+
+        // The store is now inside clearPrior(). Remove the validated ledger so
+        // that every health check from here on reports a gap.
+        if (!BEAST_EXPECTS(lm.haveLedger(parked.validated), std::to_string(parked.validated)))
+            return std::nullopt;
+        lm.clearLedger(parked.validated);
+        parked.gap = parked.validated;
+        if (!BEAST_EXPECT(!lm.haveLedger(parked.gap)))
+            return std::nullopt;
+
+        // The rotation must now be stuck. Wait longer than
+        // recovery_wait_seconds, so that this is a settled state rather than a
+        // store that has yet to reach its next health check.
+        if (!BEAST_EXPECT(!store.rendezvous(1500ms)))
+            return std::nullopt;
+        if (!BEAST_EXPECTS(
+                store.getLastRotated() == parked.lastRotated,
+                std::to_string(store.getLastRotated())))
+            return std::nullopt;
+        // The rotation started, has not finished, and has not yet given up.
+        if (!BEAST_EXPECTS(
+                log.count(kRotating) == rotationsBefore + 1, std::to_string(log.count(kRotating))))
+            return std::nullopt;
+        if (!BEAST_EXPECTS(log.count(kFinished) == 1, std::to_string(log.count(kFinished))))
+            return std::nullopt;
+        if (!BEAST_EXPECTS(log.count(kExpired) == 0, std::to_string(log.count(kExpired))))
+            return std::nullopt;
+
+        return parked;
+    }
+
 public:
+    // Cover the [node_db] validation that SHAMapStoreImp performs when it is
+    // constructed. The rejected cases stop inside SHAMapStoreImp's constructor,
+    // so they cost only a partial Application construction; the accepted ones
+    // start a full node and immediately tear it down.
+    void
+    testConfig()
+    {
+        testcase("config validation");
+
+        // online_delete below the standalone minimum. ledger_history is still
+        // kDeleteInterval here, so it is too large for this online_delete as
+        // well; the assertion pins which of the two errors wins.
+        expectConfigRejected(
+            "online_delete must be at least " + std::to_string(kMinDeleteInterval),
+            [](Config& cfg) {
+                cfg.section(Sections::kNodeDatabase)
+                    .set(Keys::kOnlineDelete, std::to_string(kMinDeleteInterval - 1));
+            });
+
+        // ledger_history above online_delete asks the node to retain more
+        // history than online delete is allowed to keep.
+        expectConfigRejected(
+            "online_delete must not be less than ledger_history (currently " +
+                std::to_string(kDeleteInterval + 1) + ")",
+            [](Config& cfg) { cfg.ledgerHistory = kDeleteInterval + 1; });
+
+        // recovery_wait_seconds is the interval at which online delete rechecks
+        // the node's health while it waits for missing ledgers to arrive, so a
+        // zero wait would turn that into a spin.
+        expectConfigRejected("recovery_wait_seconds must be at least 1 second", [](Config& cfg) {
+            cfg.section(Sections::kNodeDatabase).set(Keys::kRecoveryWaitSeconds, "0");
+        });
+
+        // max_waiting_ledgers is the circuit breaker that eventually lets
+        // online delete stop waiting, so it has a floor rather than being
+        // free-form.
+        auto const tooFewWaiting =
+            "max_waiting_ledgers must be at least " + std::to_string(kMinWaitingLedgers);
+        expectConfigRejected(tooFewWaiting, [](Config& cfg) {
+            cfg.section(Sections::kNodeDatabase)
+                .set(Keys::kMaxWaitingLedgers, std::to_string(kMinWaitingLedgers - 1));
+        });
+        // 0 is not a magic "never give up" value, just a value below the floor.
+        expectConfigRejected(tooFewWaiting, [](Config& cfg) {
+            cfg.section(Sections::kNodeDatabase).set(Keys::kMaxWaitingLedgers, "0");
+        });
+
+        // The floor itself is accepted, and so is a value far above
+        // online_delete: there is no upper bound.
+        expectConfigAccepted([](Config& cfg) {
+            cfg.section(Sections::kNodeDatabase)
+                .set(Keys::kMaxWaitingLedgers, std::to_string(kMinWaitingLedgers));
+        });
+        expectConfigAccepted([](Config& cfg) {
+            cfg.section(Sections::kNodeDatabase)
+                .set(Keys::kMaxWaitingLedgers, std::to_string(kDeleteInterval * 100));
+        });
+
+        // All of the above is gated on online_delete being enabled. With it
+        // turned off, the same values are ignored rather than rejected.
+        expectConfigAccepted([](Config& cfg) {
+            auto& section = cfg.section(Sections::kNodeDatabase);
+            section.set(Keys::kOnlineDelete, "0");
+            section.set(Keys::kMaxWaitingLedgers, "0");
+            section.set(Keys::kRecoveryWaitSeconds, "0");
+        });
+    }
+
     void
     testClear()
     {
@@ -228,7 +933,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(kDeleteInterval + 4)));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + 3);
         lastRotated = store.getLastRotated();
@@ -255,7 +960,7 @@ public:
                 !getHash(ledgers[i]).empty());
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + lastRotated);
 
@@ -293,7 +998,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         // The database will always have back to ledger 2,
         // regardless of lastRotated.
@@ -308,7 +1013,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
         BEAST_EXPECT(lastRotated != store.getLastRotated());
@@ -324,7 +1029,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, kDeleteInterval + 1, lastRotated);
         BEAST_EXPECT(lastRotated != store.getLastRotated());
@@ -363,7 +1068,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - 2, 2);
         BEAST_EXPECT(lastRotated == store.getLastRotated());
@@ -373,7 +1078,7 @@ public:
         BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == ledgerSeq + (kDeleteInterval / 2));
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - 2, 2);
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
@@ -386,7 +1091,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -402,7 +1107,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -414,7 +1119,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - firstBatch, firstBatch);
 
@@ -436,7 +1141,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -448,7 +1153,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -469,7 +1174,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -481,7 +1186,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(syncStore(env));
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -493,7 +1198,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 {};
@@ -604,13 +1309,631 @@ public:
         BEAST_EXPECT(dbr->getName() == "3");
     }
 
+    void
+    testLedgerGaps()
+    {
+        // Note that this test is intentionally very similar to
+        // LedgerMaster_test::testCompleteLedgerRange, but has a different
+        // focus.
+
+        testcase("Wait for ledger gaps to fill in");
+
+        using namespace test::jtx;
+
+        Env env{*this, envconfig(onlineDelete)};
+
+        auto failureMessage = [&](char const* label, auto expected, auto actual) {
+            std::stringstream ss;
+            ss << label << ": Expected: " << expected << ", Got: " << actual;
+            return ss.str();
+        };
+
+        auto const alice = Account("alice");
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        auto& lm = env.app().getLedgerMaster();
+        LedgerIndex minSeq = 2;
+        auto& store = env.app().getSHAMapStore();
+        auto& netOPs = env.app().getOPs();
+        // Which of the existing complete ledgers the store initializes
+        // lastRotated from is a timing detail, so everything below derives from
+        // the observed value rather than assuming a particular one. Spinning
+        // until it equals a hard-coded value never terminates when a different
+        // one legitimately wins.
+        //
+        // The range check and the initializeStore() one both end the testcase
+        // rather than merely reporting, because lastRotated is the only value
+        // from the store that enters minSeq. A lastRotated of 0 -- the value
+        // getLastRotated() reports until the store has been handed a validated
+        // ledger -- makes minSeq 0 below, and the minSeq - 1 and minSeq - 2
+        // ranges then underflow to first > last, which aborts a Debug build
+        // inside missingFromCompleteLedgerRange().
+        auto const extraCloses = initializeStore(env);
+        if (!BEAST_EXPECT(extraCloses.has_value()))
+            return;
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        LedgerIndex lastRotated = store.getLastRotated();
+        if (!BEAST_EXPECTS(
+                lastRotated >= minSeq && lastRotated <= maxSeq, std::to_string(lastRotated)))
+            return;
+        // The BEAST_EXPECT above already returned if this is nullopt, but that
+        // is invisible to clang-tidy's optional model.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        BEAST_EXPECTS(maxSeq == 3 + *extraCloses, std::to_string(maxSeq));
+        std::stringstream initialRange;
+        initialRange << minSeq << "-" << maxSeq;
+        BEAST_EXPECTS(lm.getCompleteLedgers() == initialRange.str(), lm.getCompleteLedgers());
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+        // The inner range is empty unless initializeStore() had to close extra
+        // ledgers, and missingFromCompleteLedgerRange() treats first > last as a
+        // precondition violation that aborts a Debug build via UNREACHABLE, so
+        // only check it when it is well formed.
+        if (minSeq + 1 <= maxSeq - 1)
+        {
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0);
+        }
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+
+        auto expectedRange =
+            [](LedgerIndex minSeq, std::vector const& deleteSeqs, LedgerIndex maxSeq) {
+                std::stringstream expectedRange;
+                expectedRange << minSeq;
+                auto lastDelete = minSeq - 1;
+                for (auto deleteSeq : deleteSeqs)
+                {
+                    if (deleteSeq <= lastDelete)
+                        continue;
+                    expectedRange << "-" << (deleteSeq - 1);
+                    if (deleteSeq + 1 <= maxSeq)
+                        expectedRange << "," << (deleteSeq + 1);
+                    lastDelete = deleteSeq;
+                }
+                if (lastDelete + 1 < maxSeq)
+                {
+                    expectedRange << "-" << maxSeq;
+                }
+                return expectedRange.str();
+            };
+
+        auto deleteLedgerSeq =
+            [&lm, &store, &netOPs, &minSeq, &lastRotated, &expectedRange, &failureMessage, this](
+                Env& env,
+                LedgerIndex& maxSeq,
+                std::vector& deleteSeqs) -> LedgerIndex {
+            using namespace std::chrono_literals;
+
+            // The next ledger will trigger a rotation. Delete the
+            // current ledger from LedgerMaster.
+
+            netOPs.setMode(OperatingMode::CONNECTED);
+
+            LedgerIndex const deleteSeq = maxSeq;
+            std::size_t iterations = 30;
+            while (!lm.haveLedger(deleteSeq) && --iterations > 0)
+            {
+                std::this_thread::sleep_for(10ms);
+            }
+            // Even the slowest machines should be able to finalize deleteSeq within 10
+            // loops (100ms). If this test ever actually fails feel free to lower this
+            // cutoff. The intent of this test is to flag if the loop takes a very long
+            // time, but still allow the rest of this function to finish.
+            BEAST_EXPECTS(iterations > 20, std::to_string(iterations));
+            if (!BEAST_EXPECT(lm.haveLedger(deleteSeq)))
+                return 0;
+
+            // This test may be timing sensitive, because it's messing with server internals in ways
+            // that they can't be messed with normally. Sleep a little bit to give the server time
+            // to finish any internal work before we delete the ledger.
+            std::this_thread::sleep_for(250ms);
+
+            lm.clearLedger(deleteSeq);
+            deleteSeqs.push_back(deleteSeq);
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+
+            BEAST_EXPECTS(
+                lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                failureMessage(
+                    "Complete ledgers",
+                    expectedRange(minSeq, deleteSeqs, maxSeq),
+                    lm.getCompleteLedgers()));
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size());
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            // Close another ledger, which will trigger a rotation, but the
+            // rotation will be stuck until the missing ledger is filled in.
+            env.close();
+            // Do not call rendezvous() here without a timeout; it will block until the missing
+            // ledger is backfilled. That will not happen automatically. It's a manual step that
+            // is done later in this test.
+            ++maxSeq;
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            netOPs.setMode(OperatingMode::FULL);
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            BEAST_EXPECT(!store.rendezvous(10ms));
+            BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::FULL);
+
+            // Nothing has changed
+            BEAST_EXPECTS(
+                store.getLastRotated() == lastRotated,
+                failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+            BEAST_EXPECTS(
+                lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                failureMessage(
+                    "Complete ledgers",
+                    expectedRange(minSeq, deleteSeqs, maxSeq),
+                    lm.getCompleteLedgers()));
+
+            return deleteSeq;
+        };
+
+        std::vector deleteSeqs;
+
+        // Close enough ledgers to rotate a few times
+        while (maxSeq < 40)
+        {
+            for (int t = 0; t < 3; ++t)
+            {
+                env(noop(alice));
+            }
+            env.close();
+            BEAST_EXPECT(syncStore(env));
+
+            ++maxSeq;
+
+            if (maxSeq + 1 == lastRotated + kDeleteInterval)
+            {
+                using namespace std::chrono_literals;
+
+                {
+                    // Trigger the circuit breaker in SHAMapStoreImp::healthWait() to ensure it
+                    // doesn't block forever.
+                    LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs);
+                    if (!BEAST_EXPECT(deleteSeq > 0))
+                        return;
+                    if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                        return;
+
+                    // Close 7 more ledgers, waiting a little bit in between to
+                    // simulate the ledger making progress while online delete waits
+                    // for the missing ledger to be filled in.
+                    // After the 7th ledger, the circuit breaker will trigger and abort the attempt.
+                    while (maxSeq < lastRotated + (kDeleteInterval * 2) - 2)
+                    {
+                        env.close();
+                        ++maxSeq;
+                        // Nothing has changed
+                        BEAST_EXPECTS(
+                            store.getLastRotated() == lastRotated,
+                            failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                        BEAST_EXPECTS(
+                            lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                            failureMessage(
+                                "Complete Ledgers",
+                                expectedRange(minSeq, deleteSeqs, maxSeq),
+                                lm.getCompleteLedgers()));
+                        // The Store is "stuck" in healthWait() and won't finish the run() loop
+                        // until it's backfilled
+                        if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                            return;
+                    }
+
+                    // Close one more ledger, which will NOT trigger the circuit breaker. Wait for
+                    // the full 1 second recovery wait timeout to ensure the circuit breaker is not
+                    // triggered.
+                    env.close();
+                    ++maxSeq;
+                    // The Store is "stuck" in healthWait() and won't finish the run() loop
+                    // until it's backfilled
+                    BEAST_EXPECT(!store.rendezvous(1s));
+
+                    // Close one more ledger, which will trigger the circuit breaker and abort the
+                    // attempt to rotate.
+                    env.close();
+                    ++maxSeq;
+                    // Nothing has changed
+                    BEAST_EXPECTS(
+                        store.getLastRotated() == lastRotated,
+                        failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                    BEAST_EXPECTS(
+                        lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                        failureMessage(
+                            "Complete Ledgers",
+                            expectedRange(minSeq, deleteSeqs, maxSeq),
+                            lm.getCompleteLedgers()));
+
+                    // The circuit breaker has been triggered.
+                    BEAST_EXPECT(syncStore(env));
+                }
+                {
+                    // Recover before the circuit breaker triggers, so the test can continue.
+                    LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs);
+                    if (!BEAST_EXPECT(deleteSeq > 0))
+                        return;
+                    if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                        return;
+
+                    // Close 5 more ledgers, waiting a little bit in between to
+                    // simulate the ledger making progress while online delete waits
+                    // for the missing ledger to be filled in.
+                    // This ensures the healthWait check has time to run and
+                    // detect the gap.
+                    for (int l = 0; l < 5; ++l)
+                    {
+                        env.close();
+                        ++maxSeq;
+                        // Nothing has changed
+                        BEAST_EXPECTS(
+                            store.getLastRotated() == lastRotated,
+                            failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                        BEAST_EXPECTS(
+                            lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                            failureMessage(
+                                "Complete Ledgers",
+                                expectedRange(minSeq, deleteSeqs, maxSeq),
+                                lm.getCompleteLedgers()));
+                        if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                            return;
+                    }
+
+                    // The Store is "stuck" in healthWait() and won't finish the run() loop
+                    // until it's backfilled
+                    // Wait for the full 1 second recovery wait timeout to ensure the circuit
+                    // breaker is not triggered, and this isn't some other timing fluke.
+                    BEAST_EXPECT(!store.rendezvous(1s));
+
+                    // Put the missing ledger back in LedgerMaster
+                    lm.setLedgerRangePresent(deleteSeq, deleteSeq);
+                    BEAST_EXPECT(deleteSeqs.back() == deleteSeq);
+                    deleteSeqs.pop_back();
+
+                    // Wait for the rotation to finish
+                    BEAST_EXPECT(syncStore(env));
+
+                    minSeq = lastRotated;
+                    while (deleteSeqs.front() < minSeq)
+                    {
+                        deleteSeqs.erase(deleteSeqs.begin());
+                    }
+                    lastRotated = deleteSeq + 1;
+                }
+            }
+            BEAST_EXPECT(maxSeq != lastRotated + kDeleteInterval);
+            BEAST_EXPECTS(
+                env.closed()->header().seq == maxSeq,
+                failureMessage("maxSeq", maxSeq, env.closed()->header().seq));
+            BEAST_EXPECTS(
+                store.getLastRotated() == lastRotated,
+                failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+            {
+                auto const expected = expectedRange(minSeq, deleteSeqs, maxSeq);
+                BEAST_EXPECTS(
+                    lm.getCompleteLedgers() == expected,
+                    failureMessage("CompleteLedgers", expected, lm.getCompleteLedgers()));
+            }
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size());
+            // missingFromCompleteLedgerRange() treats first > last as a
+            // precondition violation and aborts a Debug build via UNREACHABLE.
+            // The range can only collapse if this test's model of minSeq /
+            // maxSeq has desynced from the store, so report that as a failure
+            // instead of taking down the whole unit test job.
+            if (minSeq + 1 <= maxSeq - 1)
+            {
+                BEAST_EXPECT(
+                    lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == deleteSeqs.size());
+            }
+            else
+            {
+                BEAST_EXPECTS(false, failureMessage("range collapsed", minSeq, maxSeq));
+            }
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == deleteSeqs.size() + 2);
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == deleteSeqs.size() + 2);
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == deleteSeqs.size() + 2);
+        }
+    }
+
+    // Cover the branches of SHAMapStoreImp::healthWait() that decide whether
+    // the server is healthy enough to rotate, and how loudly to complain while
+    // it is not. testLedgerGaps() covers the case where a gap holds the
+    // rotation back until the circuit breaker trips; these cover the rest of
+    // the decision table.
+    void
+    testHealthWaitState()
+    {
+        testcase("healthWait server state");
+
+        using namespace std::chrono_literals;
+        using namespace test::jtx;
+
+        auto logs = std::make_unique();
+        // Not `auto const*`: waitFor() blocks, so it is not const.
+        auto* const log = logs.get();
+        Env env{*this, envconfig(onlineDelete), std::move(logs), beast::Severity::Trace};
+
+        auto& store = env.app().getSHAMapStore();
+        auto& netOPs = env.app().getOPs();
+
+        // No gap: the only thing holding the store back is the operating mode.
+        auto const parked = parkInHealthWait(env, false, OperatingMode::CONNECTED);
+        if (!parked)
+            return;
+
+        // Hold the non-FULL mode until the store has logged that it is waiting
+        // on it. With no gap, a fresh validated ledger and a mode that is not
+        // DISCONNECTED, the only check left that can report unhealthy is
+        // "mode != FULL", and a mode that is not FULL is not expected to fix
+        // itself, so the wait is logged at warn, for the full duration.
+        //
+        // Waiting for the message rather than sleeping past it is what keeps
+        // this from depending on how quickly the store gets around to sampling.
+        // The store cannot leave the wait loop while the mode stays put -- the
+        // validated ledger index does not advance, so the circuit breaker is
+        // never reached -- so the rendezvous() below is not racing it.
+        BEAST_EXPECT(log->waitFor(beast::Severity::Warning, kFullWait, 10s));
+        BEAST_EXPECT(!store.rendezvous(10ms));
+        BEAST_EXPECT(netOPs.getOperatingMode() != OperatingMode::FULL);
+        BEAST_EXPECT(netOPs.getOperatingMode() != OperatingMode::DISCONNECTED);
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+
+        // Now make the mode FULL but the validated ledger stale. Advancing the
+        // clock without closing a ledger ages the validated ledger past
+        // age_threshold_seconds, which defaults to 60. The mode check can no
+        // longer be the reason the store is unhealthy, so the age check is.
+        //
+        // This one does sleep: what is being asserted is that the store did not
+        // rotate, and 1500ms is long enough for it to have re-sampled the server
+        // at least once -- the full wait is 1000ms -- so the age check, not a
+        // stale sample of the old mode, is what held it back.
+        auto const closeTime = env.now();
+        env.timeKeeper().set(closeTime + 2min);
+        netOPs.setMode(OperatingMode::FULL);
+        BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::FULL);
+        BEAST_EXPECT(!store.rendezvous(1500ms));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+
+        // Restore the clock. Nothing is wrong any more, so the rotation that
+        // has been waiting all along runs to completion.
+        env.timeKeeper().set(closeTime);
+        BEAST_EXPECT(syncStore(env));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->validated, std::to_string(store.getLastRotated()));
+    }
+
+    void
+    testHealthWaitGapLevels()
+    {
+        testcase("healthWait gap wait levels");
+
+        using namespace std::chrono_literals;
+        using namespace test::jtx;
+
+        auto logs = std::make_unique();
+        // Not `auto const*`: waitFor() blocks, so it is not const.
+        auto* const log = logs.get();
+        Env env{*this, envconfig(onlineDelete), std::move(logs), beast::Severity::Trace};
+
+        auto& lm = env.app().getLedgerMaster();
+        auto& store = env.app().getSHAMapStore();
+
+        auto const parked = parkInHealthWait(env, true, OperatingMode::FULL);
+        if (!parked)
+            return;
+
+        // The missing ledger is an older one; the validated ledger itself is
+        // present. The store has no reason to think the gap will close on its
+        // own, so it waits the full duration and says so at info -- not warn,
+        // because the server is otherwise healthy and has not been waiting long
+        // enough to have fallen behind.
+        BEAST_EXPECT(lm.haveLedger(parked->validated));
+        BEAST_EXPECT(!lm.haveLedger(parked->gap));
+        BEAST_EXPECT(log->waitFor(beast::Severity::Info, kFullWait, 10s));
+        // Nothing so far should have looked like a ledger being built.
+        BEAST_EXPECT(log->count(beast::Severity::Trace, kShortWait) == 0);
+        // Nothing fills the gap in, so the store is still in the wait loop.
+        BEAST_EXPECT(!store.rendezvous(10ms));
+
+        // Move the gap onto the validated ledger itself. That is the one case
+        // the store treats as transient -- the ledger is expected to be built
+        // shortly -- so it drops to trace and waits a tenth as long. Asserting
+        // that the shortened wait appears only after this swap is what pins the
+        // branch to the buildingIndex condition, rather than to anything
+        // incidental about a store that happens to be waiting.
+        lm.setLedgerRangePresent(parked->gap, parked->gap);
+        lm.clearLedger(parked->validated);
+        BEAST_EXPECT(lm.haveLedger(parked->gap));
+        BEAST_EXPECT(!lm.haveLedger(parked->validated));
+        BEAST_EXPECT(log->waitFor(beast::Severity::Trace, kShortWait, 10s));
+        BEAST_EXPECT(!store.rendezvous(10ms));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+
+        // Fill it in and the rotation completes.
+        lm.setLedgerRangePresent(parked->validated, parked->validated);
+        BEAST_EXPECT(syncStore(env));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->validated, std::to_string(store.getLastRotated()));
+    }
+
+    void
+    testHealthWaitDisconnected()
+    {
+        testcase("healthWait disconnected");
+
+        using namespace std::chrono_literals;
+        using namespace test::jtx;
+
+        Env env{*this, envconfig(onlineDelete)};
+
+        auto& lm = env.app().getLedgerMaster();
+        auto& store = env.app().getSHAMapStore();
+        auto& netOPs = env.app().getOPs();
+
+        auto const parked = parkInHealthWait(env, true, OperatingMode::FULL);
+        if (!parked)
+            return;
+
+        // While the server is FULL, the gap holds the rotation back.
+        BEAST_EXPECT(!store.rendezvous(1500ms));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+
+        // A disconnected server is not doing any ledger I/O, so the gap cannot
+        // have been caused by its own activity and will not close until it has
+        // peers again. The store deliberately takes advantage of that to get as
+        // much rotation done as possible: this is the one case where a gap does
+        // not hold online delete back at all.
+        netOPs.setMode(OperatingMode::DISCONNECTED);
+        BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::DISCONNECTED);
+        BEAST_EXPECT(syncStore(env));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->validated, std::to_string(store.getLastRotated()));
+        // The rotation ran with the gap still present -- nothing filled it in.
+        BEAST_EXPECT(!lm.haveLedger(parked->gap));
+    }
+
+    void
+    testHealthWaitStop()
+    {
+        testcase("healthWait stop");
+
+        using namespace test::jtx;
+
+        Env env{*this, envconfig(onlineDelete)};
+
+        auto& store = env.app().getSHAMapStore();
+
+        auto const parked = parkInHealthWait(env, true, OperatingMode::FULL);
+        if (!parked)
+            return;
+
+        // Stopping the store has to break it out of the wait loop, which it
+        // would otherwise never leave: the gap is never filled in and the
+        // validated ledger index never advances to reach the circuit breaker.
+        //
+        // stop() joins the store's thread, so its return is the
+        // synchronisation point here. rendezvous() afterwards is only a
+        // cross-check, and cannot block: the store is parked in the health
+        // check that gates a rotation, so Stopping there merely leaves
+        // readyToRotate false, and run() falls through to the top of its loop,
+        // where it clears working_ and notifies before returning on stop_.
+        store.stop();
+        BEAST_EXPECT(store.rendezvous());
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+    }
+
+    // The two tests below cover the health check that run() performs between the
+    // stages of a rotation it has already committed to, which is a different
+    // decision from the one that gates the rotation in the first place: giving
+    // up here means abandoning work in progress. run() makes it at four points
+    // -- after clearing prior ledgers, after copying the validated ledger, after
+    // freshening the caches, and after clearing them -- with the same three-way
+    // switch each time, and parkMidRotation() parks the store at the first of
+    // them.
+    void
+    testHealthWaitExpiredMidRotation()
+    {
+        testcase("healthWait circuit breaker mid-rotation");
+
+        using namespace test::jtx;
+
+        auto logs = std::make_unique();
+        auto* const log = logs.get();
+        Env env{*this, envconfig(slowOnlineDelete), std::move(logs), beast::Severity::Trace};
+
+        auto& lm = env.app().getLedgerMaster();
+        auto& store = env.app().getSHAMapStore();
+
+        auto const parked = parkMidRotation(env, *log);
+        if (!parked)
+            return;
+
+        // Advance the validated ledger index past the circuit breaker. The store
+        // has had no successful health check since the gap appeared, so once the
+        // index has moved max_waiting_ledgers on from the last one that did
+        // succeed, it abandons the rotation instead of waiting for the gap
+        // forever. Nothing here fills the gap in.
+        for (int i = 0; i < kMinWaitingLedgers; ++i)
+        {
+            env.close();
+            BEAST_EXPECT(!lm.haveLedger(parked->gap));
+        }
+
+        // Abandoning the rotation returns the store to waiting for work, so it
+        // reports itself idle -- but with lastRotated left where it started,
+        // unlike the completed rotation parkMidRotation() drove first.
+        BEAST_EXPECT(syncStore(env));
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+        BEAST_EXPECT(log->count(kExpired) > 0);
+        BEAST_EXPECTS(log->count(kFinished) == 1, std::to_string(log->count(kFinished)));
+        BEAST_EXPECT(!lm.haveLedger(parked->gap));
+    }
+
+    void
+    testHealthWaitStopMidRotation()
+    {
+        testcase("healthWait stop mid-rotation");
+
+        using namespace test::jtx;
+
+        auto logs = std::make_unique();
+        auto* const log = logs.get();
+        Env env{*this, envconfig(slowOnlineDelete), std::move(logs), beast::Severity::Trace};
+
+        auto& store = env.app().getSHAMapStore();
+
+        auto const parked = parkMidRotation(env, *log);
+        if (!parked)
+            return;
+
+        // Stopping has to break the store out of the rotation, which it would
+        // otherwise never leave: the gap is never filled in and the validated
+        // ledger index never advances to reach the circuit breaker. Note that
+        // being stopped outranks being healthy -- the health check reports it
+        // even when nothing is wrong with the server -- so this does not depend
+        // on the store still being parked when stop() lands.
+        //
+        // stop() joins the store's thread, so its return is the synchronisation
+        // point. Deliberately do not call the untimed rendezvous() afterwards:
+        // run() returns without clearing working_, so it would block forever.
+        store.stop();
+        BEAST_EXPECTS(
+            store.getLastRotated() == parked->lastRotated, std::to_string(store.getLastRotated()));
+        // The rotation was abandoned rather than completed, and the circuit
+        // breaker was not what abandoned it.
+        BEAST_EXPECTS(log->count(kFinished) == 1, std::to_string(log->count(kFinished)));
+        BEAST_EXPECTS(log->count(kExpired) == 0, std::to_string(log->count(kExpired)));
+    }
+
     void
     run() override
     {
+        testConfig();
         testClear();
         testAutomatic();
         testCanDelete();
         testRotate();
+        testLedgerGaps();
+        testHealthWaitState();
+        testHealthWaitGapLevels();
+        testHealthWaitDisconnected();
+        testHealthWaitStop();
+        testHealthWaitExpiredMidRotation();
+        testHealthWaitStopMidRotation();
     }
 };
 
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index bcd31bc6a0..71d968014f 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -376,11 +376,11 @@ public:
     }
 
     void
-    testSingleSigning()
+    testSingleSigning(FeatureBitset features)
     {
         testcase("Single signing");
         using namespace test::jtx;
-        Env env{*this, testableAmendments()};
+        Env env{*this, features};
         Account const alice("alice");
         Account const sponsor("sponsor");
         Account const invalid("invalid");
@@ -415,11 +415,11 @@ public:
     }
 
     void
-    testMultiSigning()
+    testMultiSigning(FeatureBitset features)
     {
         testcase("Multi signing");
         using namespace test::jtx;
-        Env env{*this, testableAmendments()};
+        Env env{*this, features};
         Account const alice("alice");
         Account const bob("bob");
         Account const sponsor("sponsor");
@@ -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);
@@ -1864,7 +1877,11 @@ public:
 
             PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
             Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+            // accepts closed-ended vaults; build one and advance past
+            // SubscriptionDate before creating a loan.
+            auto [vaultTx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = alice, .asset = xrpAsset});
             env(vaultTx);
             env.close();
 
@@ -1872,6 +1889,8 @@ public:
                 {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(loan_broker::set(alice, vaultKeylet.key),
@@ -5448,13 +5467,12 @@ public:
         using namespace test::jtx;
         using namespace std::chrono_literals;
 
-        // Finishing a self-escrow (source == destination) whose trust line
-        // was deleted while the escrow was outstanding auto-creates the line,
-        // and the outcome of that reserve check depends on whether the escrow
-        // reserve is released before delivery (Sponsor) or after (legacy).
-        // With the source's balance in the one-increment window
-        // [reserve(1), reserve(2)), the legacy order requires reserve(2) and
-        // fails, while the Sponsor order requires reserve(1) and succeeds.
+        // Finishing a self-escrow (source == destination) whose trust line was
+        // deleted while the escrow was outstanding auto-creates the line. With
+        // the source's balance in the one-increment window
+        // [reserve(1), reserve(2)), the finish succeeds only when the escrow
+        // reserve is released before delivery, which either featureSponsor or
+        // fixCleanup3_4_0 does.
         auto runTest = [&](FeatureBitset features, TER expected) {
             Account const alice("alice");
             Account const gw("gw");
@@ -5519,11 +5537,9 @@ public:
             }
         };
 
-        // Pre-amendment: legacy order — the escrow still counts against the
-        // reserve while the auto-created line is checked.
-        runTest(testableAmendments() - featureSponsor, tecNO_LINE_INSUF_RESERVE);
-
-        // Post-amendment: the escrow reserve is recycled into the new line.
+        runTest(testableAmendments() - featureSponsor - fixCleanup3_4_0, tecNO_LINE_INSUF_RESERVE);
+        runTest(testableAmendments() - featureSponsor, tesSUCCESS);
+        runTest(testableAmendments() - fixCleanup3_4_0, tesSUCCESS);
         runTest(testableAmendments(), tesSUCCESS);
     }
 
@@ -5566,9 +5582,9 @@ public:
             Ter(tesSUCCESS));
         env.close();
 
-        // The same helper (deltaAssetsTxAccount) drives the withdraw path, so a
-        // fee-sponsored withdrawal back to the depositor's own account also
-        // passes on the destination side.
+        // The same fee-correction logic (ValidVault::deltaAssetsForParty)
+        // drives the withdraw path, so a fee-sponsored withdrawal back to
+        // the depositor's own account also passes on the destination side.
         env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(50)}),
             Fee(XRP(1)),
             sponsor::As(sponsor, spfSponsorFee),
@@ -5659,8 +5675,12 @@ protected:
         testInvalidSponsorshipSet();
         testPseudoAccountSponsorship();
 
-        testSingleSigning();
-        testMultiSigning();
+        // The signing prefix of an alternate signature field changes with
+        // fixCleanup3_4_0, so sign and verify under both rule sets.
+        testSingleSigning(jtx::testableAmendments());
+        testSingleSigning(jtx::testableAmendments() - fixCleanup3_4_0);
+        testMultiSigning(jtx::testableAmendments());
+        testMultiSigning(jtx::testableAmendments() - fixCleanup3_4_0);
 
         testInvalidSponsorField();
 
@@ -5671,7 +5691,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/invariants/InvariantsAMM_test.cpp b/src/test/app/invariants/InvariantsAMM_test.cpp
new file mode 100644
index 0000000000..498c35c653
--- /dev/null
+++ b/src/test/app/invariants/InvariantsAMM_test.cpp
@@ -0,0 +1,249 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsAMM_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testAMMDeleteInvariants(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const enforceAMMDelete = features[fixCleanup3_3_0];
+        testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : "");
+
+        Env env(*this, features);
+        Account const issuer{"issuer"};
+        Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()};
+        STAmount const zeroLP{lptIssue, 0};
+        STAmount const nonZeroLP{lptIssue, 1};
+
+        auto const makeAMM = [](STAmount const& lptBalance) {
+            auto sleAMM = std::make_shared(keylet::amm(uint256(1)));
+            sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance);
+            return sleAMM;
+        };
+
+        auto const checkInvariant = [&](TxType txType,
+                                        TER result,
+                                        std::optional const& deletedLPBalance,
+                                        bool expected,
+                                        std::string const& expectedLog) {
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ValidAMM invariant;
+
+            if (deletedLPBalance)
+                invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr);
+
+            bool const actual = invariant.finalize(
+                STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog);
+
+            BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result");
+            auto const messages = sink.messages().str();
+            auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : "";
+            if (!expectedLogWhenEnforced.empty())
+            {
+                BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced);
+            }
+            else
+            {
+                BEAST_EXPECTS(messages.empty(), messages);
+            }
+        };
+
+        checkInvariant(
+            ttPAYMENT,
+            tesSUCCESS,
+            nonZeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMM failed, unexpected AMM deletion by");
+        checkInvariant(
+            ttAMM_DELETE,
+            tesSUCCESS,
+            std::nullopt,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS");
+        checkInvariant(
+            ttAMM_DELETE,
+            tesSUCCESS,
+            nonZeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance");
+        checkInvariant(
+            ttAMM_DELETE,
+            tecINCOMPLETE,
+            zeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS");
+
+        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, "");
+        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, "");
+
+        checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, "");
+        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, "");
+        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, "");
+    }
+
+    void
+    testAMM()
+    {
+        testcase << "AMM";
+        using namespace jtx;
+
+        MPTID mptID{};
+        uint256 ammID{};
+        AccountID ammAccountID{};
+        Account const gw{"gw"};
+        Issue lptIssue{};
+        PrettyAsset poolAsset{xrpIssue()};
+
+        auto deleteAMMAccount = [&](ApplyContext& ac, bool) {
+            auto sle = ac.view().peek(keylet::account(ammAccountID));
+            if (!sle)
+                return false;
+            ac.view().erase(sle);
+            return true;
+        };
+
+        auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) {
+            auto sle = ac.view().peek(keylet::amm(ammID));
+            if (!sle)
+                return false;
+            sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount});
+            ac.view().update(sle);
+            return true;
+        };
+        auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) {
+            return updateLPTokensBalance(ac, -1);
+        };
+        auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) {
+            return updateLPTokensBalance(ac, 200'000'000);
+        };
+        auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); };
+
+        auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) {
+            if (isMPT)
+            {
+                auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfMPTAmount, 1);
+                ac.view().update(sle);
+                return true;
+            }
+            auto sle = ac.view().peek(keylet::account(ammAccountID));
+            if (!sle)
+                return false;
+            sle->setFieldAmount(sfBalance, XRP(1));
+            ac.view().update(sle);
+            return true;
+        };
+
+        auto test = [&](auto const txType,
+                        auto&& update,
+                        bool isMPT,
+                        TER error = tecINVARIANT_FAILED) {
+            doInvariantCheck(
+                {{"AMM"}},
+                [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); },
+                XRPAmount{},
+                STTx{txType, [&](STObject& tx) {}},
+                {tecINVARIANT_FAILED, error},
+                [&](Account const&, Account const&, Env& env) {
+                    env.fund(XRP(1'000), gw);
+                    poolAsset = [&]() -> PrettyAsset {
+                        if (isMPT)
+                        {
+                            MPT const mpt = MPTTester({.env = env, .issuer = gw});
+                            mptID = mpt.issuanceID;
+                            return mpt;
+                        }
+                        return gw["USD"];
+                    }();
+                    AMM const amm(env, gw, XRP(100), poolAsset(100));
+                    ammAccountID = amm.ammAccount();
+                    ammID = amm.ammID();
+                    lptIssue = amm.lptIssue();
+                    return true;
+                });
+        };
+
+        for (bool const isMPT : {false, true})
+        {
+            // Under fixCleanup3_4_0 the MPT balance invariants also fire on the
+            // second pass, so both IOU and MPT pools now escalate to tef.
+            auto const error = TER(tefINVARIANT_FAILED);
+            for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW})
+            {
+                test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED);
+                test(txType, updateLPTokensBadAmount, isMPT);
+                test(txType, updateLPTokensBadBalance, isMPT);
+            }
+            for (auto txType : {ttAMM_BID, ttAMM_VOTE})
+            {
+                test(txType, updateAMMPool, isMPT, error);
+                test(txType, updateLPTokensBadAmount, isMPT);
+                test(txType, updateLPTokensBadBalance, isMPT);
+            }
+            for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT})
+            {
+                test(txType, updateAMM, isMPT);
+            }
+        }
+    }
+
+    // Test the invariant overwrite fix for both pre- and post-amendment
+    // behavior. With the fix enabled, |= accumulates violations across
+    // entries so a later valid entry cannot clear an earlier violation.
+    // Without the fix, = assignment means the last-visited entry wins.
+
+    void
+    run() override
+    {
+        testAMMDeleteInvariants(all_);
+        testAMMDeleteInvariants(all_ - fixCleanup3_3_0);
+        testAMM();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsAMM, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp
new file mode 100644
index 0000000000..650a21cb07
--- /dev/null
+++ b/src/test/app/invariants/InvariantsBase.cpp
@@ -0,0 +1,241 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+test::jtx::Env
+InvariantsBase::makeEnv(FeatureBitset features)
+{
+    return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled};
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    Preclose const& preclose,
+    TxAccount setTxAccount,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    doInvariantCheck(
+        makeEnv(test::jtx::testableAmendments()),
+        expectLogs,
+        precheck,
+        fee,
+        tx,
+        ters,
+        preclose,
+        setTxAccount,
+        loc,
+        initialResult);
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    test::jtx::Env&& env,
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    Preclose const& preclose,
+    TxAccount setTxAccount,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    using namespace test::jtx;
+
+    Account const a1{"A1"};
+    Account const a2{"A2"};
+    env.fund(XRP(1000), a1, a2);
+    if (preclose)
+        BEAST_EXPECT(preclose(a1, a2, env));
+    env.close();
+
+    if (setTxAccount != TxAccount::None)
+        tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
+
+    doInvariantCheck(
+        std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult);
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
+    test::jtx::Env&& env,
+    test::jtx::Account const& a1,
+    test::jtx::Account const& a2,
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    using namespace test::jtx;
+
+    OpenView ov{*env.current()};
+    test::StreamSink sink{beast::Severity::Warning};
+    beast::Journal const jlog{sink};
+    ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+
+    // Invariants normally run in the Transaction's "apply" (operator()) context, and can always
+    // access global Rules.
+    CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+    BEAST_EXPECT(precheck(a1, a2, ac));
+
+    auto transactor = makeTransactor(ac);
+    if (!BEAST_EXPECT(transactor))
+        return;
+
+    // Invoke the check twice to cover the tec and tef cases. Both passes run
+    // against the same view -- production would discard it in between -- so
+    // the second sees the same violation and escalates tec -> tef. A
+    // {tec, tef} pair therefore means "enforced whatever the incoming
+    // result", not that the transaction ends in tef on ledger.
+    if (!BEAST_EXPECT(ters.size() == 2))
+        return;
+
+    TER terActual = initialResult;
+    for (TER const& terExpect : ters)
+    {
+        TER const terInput = terActual;
+        terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
+        expect(
+            terExpect == terActual,
+            "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
+            loc.file_name(),
+            loc.line());
+        auto const messages = sink.messages().str();
+
+        // checkInvariants returns its input unchanged unless something
+        // fires, so a changed result means an invariant fired, and a firing
+        // invariant must log.
+        if (terActual != terInput)
+        {
+            expect(
+                messages.starts_with("Invariant failed:") ||
+                    messages.starts_with("Transaction caused an exception"),
+                messages,
+                loc.file_name(),
+                loc.line());
+        }
+
+        // std::cerr << messages << '\n';
+        for (auto const& m : expectLogs)
+        {
+            expect(messages.contains(m), m, loc.file_name(), loc.line());
+        }
+    }
+}
+
+Keylet
+InvariantsBase::createLoanBroker(
+    jtx::Account const& a,
+    jtx::Env& env,
+    jtx::PrettyAsset const& asset)
+{
+    using namespace jtx;
+
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+    // accepts closed-ended vaults. Build one with a comfortable
+    // subscription window; LoanBrokerSet itself is not phase-gated,
+    // so leaving the vault in the Subscription phase is fine here.
+    uint256 vaultID;
+    Vault const vault{env};
+    auto [tx, vKeylet, _] = vault.createClosedEnded(
+        {.owner = a,
+         .asset = asset,
+         .subscriptionOffset = std::chrono::seconds{60},
+         .investmentWindow = std::chrono::seconds{kMinInvestmentPeriod + 1'000'000u}});
+    env(tx);
+    BEAST_EXPECT(env.le(vKeylet));
+
+    vaultID = vKeylet.key;
+
+    // Create Loan Broker
+    using namespace loan_broker;
+
+    auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a)));
+    // Create a Loan Broker with all default values.
+    env(set(a, vaultID), Fee(kIncrement));
+
+    return loanBrokerKeylet;
+}
+
+SLE::pointer
+InvariantsBase::makeLoanSle(
+    uint256 const& loanBrokerID,
+    std::uint32_t loanSeq,
+    AccountID const& borrower)
+{
+    auto sleLoan =
+        std::make_shared(keylet::loan(loanBrokerID, SeqProxy::rawSequence(loanSeq)));
+    // SoeRequired fields.
+    sleLoan->at(sfLoanBrokerID) = loanBrokerID;
+    sleLoan->at(sfLoanSequence) = loanSeq;
+    sleLoan->at(sfBorrower) = borrower;
+    sleLoan->at(sfStartDate) = 0u;
+    sleLoan->at(sfPaymentInterval) = 1u;
+    sleLoan->at(sfPeriodicPayment) = Number(1);
+    // SoeDefault fields, materialized so that an invariant reading them through
+    // at() does not throw on this hand-built entry.
+    sleLoan->at(sfLoanServiceFee) = Number(0);
+    sleLoan->at(sfLatePaymentFee) = Number(0);
+    sleLoan->at(sfClosePaymentFee) = Number(0);
+    sleLoan->at(sfPrincipalOutstanding) = Number(0);
+    sleLoan->at(sfTotalValueOutstanding) = Number(0);
+    sleLoan->at(sfManagementFeeOutstanding) = Number(0);
+    sleLoan->setFieldU32(sfPaymentRemaining, 0);
+    sleLoan->makeFieldPresent(sfOwnerNode);
+    sleLoan->makeFieldPresent(sfLoanBrokerNode);
+    return sleLoan;
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsBase.h b/src/test/app/invariants/InvariantsBase.h
new file mode 100644
index 0000000000..6b4327eb78
--- /dev/null
+++ b/src/test/app/invariants/InvariantsBase.h
@@ -0,0 +1,134 @@
+#pragma once
+
+#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 Transactor;
+
+// Test-only factory — not part of the public API.
+// The returned Transactor holds a raw reference to ctx; the caller must ensure
+// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp
+std::unique_ptr
+makeTransactor(ApplyContext& ctx);
+
+}  // namespace xrpl
+
+namespace xrpl::test {
+
+class InvariantsBase : public beast::unit_test::Suite
+{
+protected:
+    // The optional Preclose function is used to process additional transactions
+    // on the ledger after creating two accounts, but before closing it, and
+    // before the Precheck function. These should only be valid functions, and
+    // not direct manipulations. Preclose is not commonly used.
+    using Preclose = std::function<
+        bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>;
+
+    // this is common setup/method for running a failing invariant check. The
+    // precheck function is used to manipulate the ApplyContext with view
+    // changes that will cause the check to fail.
+    using Precheck = std::function<
+        bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>;
+
+    enum class TxAccount : int { None = 0, A1, A2 };
+
+    test::jtx::Env
+    makeEnv(FeatureBitset features);
+
+    /**
+     * Run a specific test case to put the ledger into a state that will be
+     * detected by an invariant. Simulates the actions of a transaction that
+     * would violate an invariant.
+     *
+     * @param expectLogs One or more messages related to the failing invariant
+     *  that should be in the log output
+     * @param precheck See "Precheck" above
+     * @param fee If provided, the fee amount paid by the simulated transaction.
+     * @param tx A mock transaction that took the actions to trigger the
+     *  invariant. In most cases, only the type matters.
+     * @param ters The TER results expected on the two passes of the invariant
+     *  checker.
+     * @param preclose See "Preclose" above. Note that @preclose runs *before*
+     *  @precheck, but is the last parameter for historical reasons
+     * @param setTxAccount optionally set to add sfAccount to tx (either A1 or A2)
+     */
+    void
+    doInvariantCheck(
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        Preclose const& preclose = {},
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        // Result fed to the invariant checker on the first pass. Set it to a
+        // tec to exercise result-dependent invariants; the harness runs no
+        // transactor, so one never arises on its own.
+        TER initialResult = tesSUCCESS);
+
+    void
+    doInvariantCheck(
+        test::jtx::Env&& env,
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        Preclose const& preclose = {},
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS);
+
+    void
+    doInvariantCheck(
+        // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
+        test::jtx::Env&& env,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS);
+
+    Keylet
+    createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset);
+
+    // Build an ltLOAN SLE with every SoeRequired field explicitly set and
+    // every SoeDefault field the invariants read via `at()` materialized, so
+    // rawInsert-based tests don't accidentally trip an unrelated invariant
+    // or throw from a missing SoeDefault field.
+    static SLE::pointer
+    makeLoanSle(uint256 const& loanBrokerID, std::uint32_t loanSeq, AccountID const& borrower);
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsEscrowNFT_test.cpp b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp
new file mode 100644
index 0000000000..f0afa2377c
--- /dev/null
+++ b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp
@@ -0,0 +1,352 @@
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsEscrowNFT_test : public InvariantsBase
+{
+    void
+    testNoZeroEscrow()
+    {
+        using namespace test::jtx;
+        testcase << "no zero escrow";
+
+        doInvariantCheck(
+            {{"XRP net change of -1000000 doesn't match fee 0"},
+             {"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with negative amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+                sleNew->setFieldAmount(sfAmount, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"XRP net change was positive: 100000000000000001"},
+             {"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-large amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+                // Use `drops(1)` to bypass a call to STAmount::canonicalize
+                // with an invalid value
+                sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // IOU < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-little iou
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
+                STAmount const amt(usd, -1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // IOU bad currency
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with bad iou currency
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                Issue const bad{badCurrency(), AccountID(0x4985601)};
+                STAmount const amt(bad, 1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-little mpt
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                STAmount const amt(mpt, -1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT LockedAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance locked is less than locked
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount < LockedAmount
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is less than locked
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 1);
+                sleNew->setFieldU64(sfLockedAmount, 10);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT MPTAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptoken amount is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
+                sleNew->setFieldU64(sfMPTAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT LockedAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptoken locked amount is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
+                sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testNFTokenPageInvariants()
+    {
+        using namespace test::jtx;
+        testcase << "NFTokenPage";
+
+        // lambda that returns an STArray of NFTokenIDs.
+        uint256 const firstNFTID(
+            "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000");
+        auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) {
+            SOTemplate const* nfTokenTemplate =
+                InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken);
+
+            uint256 nftID(firstNFTID);
+            STArray ret;
+            for (int i = 0; i < nftCount; ++i)
+            {
+                STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) {
+                    object.setFieldH256(sfNFTokenID, nftID);
+                });
+                ret.pushBack(std::move(newNFToken));
+                ++nftID;
+            }
+            return ret;
+        };
+
+        doInvariantCheck(
+            {{"NFT page has invalid size"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0));
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page has invalid size"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33));
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFTs on page are not sorted"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(2);
+                std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1);
+
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT contains empty URI"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(1);
+                nfTokens[0].setFieldVL(sfURI, Blob{});
+
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfNextPageMin, nftPage->key());
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(1);
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+                nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT found in incorrect page"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(2);
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+    }
+
+    void
+    run() override
+    {
+        testNoZeroEscrow();
+        testNFTokenPageInvariants();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsEscrowNFT, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp
new file mode 100644
index 0000000000..4692463baa
--- /dev/null
+++ b/src/test/app/invariants/InvariantsMPT_test.cpp
@@ -0,0 +1,1577 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsMPT_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testMPT()
+    {
+        using namespace test::jtx;
+        testcase << "MPT";
+
+        MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))};
+        auto const nonCanonicalMPTAmount = [&](SField const& field) {
+            return STAmount{
+                field,
+                nonCanonicalMPTIssue,
+                kMaxMpTokenAmount + std::uint64_t{1},
+                0,
+                false,
+                STAmount::Unchecked{}};
+        };
+        auto const negativeMPTAmount = [&](SField const& field) {
+            return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}};
+        };
+        auto const nonCanonicalMPTPayment = [&]() {
+            return STTx{ttPAYMENT, [&](STObject& tx) {
+                            tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount));
+                        }};
+        };
+
+        doInvariantCheck(
+            makeEnv(all_ - fixCleanup3_2_0),
+            {},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{},
+            nonCanonicalMPTPayment(),
+            {tesSUCCESS, tesSUCCESS});
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(
+                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(
+                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount > MaximumAmount
+        doInvariantCheck(
+            {{"OutstandingAmount overflow"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 110);
+                sleNew->setFieldU64(sfMaximumAmount, 100);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPTToken amount doesn't add up to OutstandingAmount
+        doInvariantCheck(
+            {{"invalid OutstandingAmount balance"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 100);
+                sleNew->setFieldU64(sfMaximumAmount, 100);
+                ac.view().insert(sleNew);
+
+                sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
+                sleNew->setFieldU64(sfMPTAmount, 90);
+                ac.view().insert(sleNew);
+
+                return true;
+            });
+
+        // Overflow/Invalid balance on payment
+        auto testPayment = [&](std::string const& log, auto&& update) {
+            MPTID id;
+            doInvariantCheck(
+                {{log}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    return update(id, ac, a1);
+                },
+                XRPAmount{},
+                STTx{ttPAYMENT, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const& a2, Env& env) {
+                    Account const gw("gw");
+                    env.fund(XRP(1'000), gw);
+                    MPTTester const mpt(
+                        {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100});
+                    id = mpt.issuanceID();
+                    return true;
+                });
+        };
+        testPayment(
+            "invalid OutstandingAmount balance",
+            [&](MPTID const& id, ApplyContext& ac, Account const& a1) {
+                auto sle = ac.view().peek(keylet::mptoken(id, a1));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfMPTAmount, 101);
+                ac.view().update(sle);
+                return true;
+            });
+        testPayment(
+            "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
+                auto sle = ac.view().peek(keylet::mptokenIssuance(id));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfOutstandingAmount, 101);
+                ac.view().update(sle);
+                return true;
+            });
+
+        // The on-failure MPT checks (OutstandingAmount balance / transfer) apply
+        // to every non-tesSUCCESS result, with no per-result exemption: on a tec
+        // the transactor discards the view and re-applies only offer, trust
+        // line, NFT offer and credential deletions, so an MPT change reaching
+        // the invariant is a bug whatever the code. Seeded via initialResult.
+        {
+            MPTID id;
+            // preclose: gw issues an MPT held by A1 and A2.
+            auto const setup = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt(
+                    {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000});
+                id = mpt.issuanceID();
+                return true;
+            };
+
+            // Consistent mint: OutstandingAmount and A1's balance both grow by
+            // 10, so conservation holds and only the on-failure check fires.
+            Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleIss || !sleTok)
+                    return false;
+                (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10;
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleTok);
+                return true;
+            };
+
+            // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is
+            // unchanged, and CanTransfer keeps the ordinary transfer check
+            // quiet, so only the on-failure check fires.
+            Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleA = ac.view().peek(keylet::mptoken(id, a1.id()));
+                auto sleB = ac.view().peek(keylet::mptoken(id, a2.id()));
+                if (!sleIss || !sleA || !sleB)
+                    return false;
+                (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer;
+                (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10;
+                (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleA);
+                ac.view().update(sleB);
+                return true;
+            };
+
+            STTx const payment{ttPAYMENT, [](STObject&) {}};
+
+            // Negative controls: nothing fires on tesSUCCESS. Without these, the
+            // cases below would still pass if the result guard were dropped.
+            doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+
+            // tecKILLED and tecINCOMPLETE are not special: an MPT change paired
+            // with either fires, as with any other failure.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            // The same change under a third failure result: the check keys off
+            // "not tesSUCCESS", nothing finer.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A lock moves value within one holder, so it is not a two-sided
+            // transfer and the `senders || receivers` form is what catches it.
+            // OutstandingAmount and the holder total are unchanged, so the
+            // balance check stays quiet.
+            Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] < 10)
+                    return false;
+                // A fresh MPToken has no locked amount, so set it directly.
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10;
+                sleTok->setFieldU64(sfLockedAmount, 10);
+                ac.view().update(sleTok);
+                return true;
+            };
+            // Negative control: a lock is legitimate on tesSUCCESS.
+            doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            // The lock is caught under any failure result.
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A deleted MPToken has no amtAfter, so the sender/receiver counts
+            // skip it and only the deletedAuthorized_ term can catch it. That
+            // needs holders authorized but never paid, so the MPToken can be
+            // erased with a zero balance and OutstandingAmount untouched --
+            // otherwise the holder would register as a sender instead.
+            MPTID emptyId;
+            auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100});
+                emptyId = mpt.issuanceID();
+                return true;
+            };
+            Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                    return false;
+                ac.view().erase(sleTok);
+                return true;
+            };
+            // ValidMPTIssuance also reports the deletion, so assert on
+            // ValidMPTTransfer's message, which only the new check can produce.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                eraseToken,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupEmpty,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
+        // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback balance change is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100};
+                    }},
+                {tesSUCCESS, tesSUCCESS});
+        }
+
+        // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing.
+        {
+            Env env(*this, all_ - featureMPTokensV2);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback balance change is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tesSUCCESS, tesSUCCESS});
+        }
+
+        // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback balance change is invalid"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+
+                    sleToken->setFieldU64(sfMPTAmount, 80);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 80);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline and MPToken both changed"}},
+                [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleLine =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id()));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleLine || !sleToken || !sleIssuance)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sleLine->setFieldAmount(sfBalance, balance);
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleLine);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Clawback that modifies a trustline other than the one implied by the
+        // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for
+        // the mismatched line.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            auto const eur = issuer["EUR"];
+            env.trust(eur(100), holder);
+            env(pay(issuer, holder, eur(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback changed the wrong line"}},
+                [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency));
+                    if (!sle)
+                        return false;
+                    STAmount balance{Issue{eur.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Clawback leaving the holder's balance negative.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline or MPT balance is negative"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+                    // Make the holder's balance negative from their perspective.
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() < issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // IOU-amount clawback while only an MPToken changed: no trustline was
+        // recorded, so iou_.before is empty.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback changed the wrong line"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Valid trustline change but a zero clawback amount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback amount is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback tx missing the Holder field.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback missing holder"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback where the holder's MPToken was deleted (after is empty).
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback token is missing"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    // Keep the issuance consistent after removing the token.
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 0);
+                    ac.view().update(sleIssuance);
+                    ac.view().erase(sleToken);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback that changed a different holder's MPToken.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {holder, other},
+                 .pay = 100,
+                 .maxAmt = 200});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback changed the wrong token"}},
+                [id](Account const&, Account const& other, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, other));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 190);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Valid MPToken change but a zero MPT clawback amount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback amount is invalid"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 0};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // More MPTokens created than expected
+        std::array, 4> const tests = {
+            std::make_pair(ttAMM_WITHDRAW, 2),
+            std::make_pair(ttAMM_CLAWBACK, 2),
+            std::make_pair(ttAMM_CREATE, 3),
+            std::make_pair(ttCHECK_CASH, 2)};
+        for (auto const& [tx, nTokens] : tests)
+        {
+            doInvariantCheck(
+                {{std::string("MPToken created for the MPT issuer")}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+
+                    auto seq = sle->getFieldU32(sfSequence);
+                    for (int i = 0; i < nTokens; ++i)
+                    {
+                        MPTIssue const mpt{makeMptID(seq + i, a1)};
+                        auto sleNew =
+                            std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                        ac.view().insert(sleNew);
+
+                        sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
+                        ac.view().insert(sleNew);
+                    }
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{tx, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // More MPTokens deleted than expected
+        for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK})
+        {
+            MPTID id;
+            Account const a3("A3");
+            doInvariantCheck(
+                {{"MPT authorize  succeeded but created/deleted bad number of mptokens"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    for (auto const& a : {a1, a2, a3})
+                    {
+                        auto sle = ac.view().peek(keylet::mptoken(id, a));
+                        if (!sle)
+                            return false;
+                        ac.view().erase(sle);
+                    }
+                    return true;
+                },
+                XRPAmount{},
+                STTx{tx, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const& a2, Env& env) {
+                    Account const gw("gw");
+                    env.fund(XRP(1'000), gw, a3);
+                    MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}});
+                    id = mpt.issuanceID();
+                    return true;
+                });
+        }
+
+        // sfReferenceHolding can only be set on creation by VaultCreate. A
+        // non-VaultCreate transaction that creates an MPTokenIssuance with
+        // sfReferenceHolding present must trip the invariant.
+        doInvariantCheck(
+            {{"sfReferenceHolding set on a new MPTokenIssuance by a "
+              "non-VaultCreate transaction"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const sleAcct = ac.view().peek(keylet::account(a1.id()));
+                if (!sleAcct)
+                    return false;
+                MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldH256(sfReferenceHolding, uint256{1});
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}});
+
+        // sfReferenceHolding is immutable: changing the field on an
+        // existing MPTokenIssuance must trip the invariant. Set up a real
+        // vault via preclose (so the share issuance carries
+        // sfReferenceHolding), then mutate it in precheck to produce a
+        // before/after pair.
+        {
+            uint256 vaultKey;
+            doInvariantCheck(
+                {{"sfReferenceHolding was modified on an existing "
+                  "MPTokenIssuance"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
+                    if (!sleVault)
+                        return false;
+                    auto sleIssuance =
+                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+                    if (!sleIssuance)
+                        return false;
+                    sleIssuance->setFieldH256(sfReferenceHolding, uint256{2});
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const&, Env& env) {
+                    Account const issuer{"issuer"};
+                    env.fund(XRP(10'000), issuer);
+                    env.close();
+                    MPTTester mptt{env, issuer, kMptInitNoFund};
+                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+                    PrettyAsset const asset = mptt.issuanceID();
+                    mptt.authorize({.account = a1});
+                    env.close();
+
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+                    env(tx);
+                    env.close();
+                    vaultKey = keylet.key;
+                    return true;
+                });
+        }
+
+        // A vault pseudo-account's MPToken cannot be deleted by anything
+        // other than a VaultDelete transaction. Set up a vault, then have
+        // an arbitrary tx erase the pseudo's MPToken in precheck.
+        {
+            uint256 vaultKey;
+            doInvariantCheck(
+                {{"vault pseudo-account holding deleted by a "
+                  "non-VaultDelete transaction"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
+                    if (!sleVault)
+                        return false;
+                    auto const sleIssuance =
+                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+                    if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                        return false;
+                    auto sleHolding = ac.view().peek(
+                        keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
+                    if (!sleHolding)
+                        return false;
+                    ac.view().erase(sleHolding);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const&, Env& env) {
+                    Account const issuer{"issuer"};
+                    env.fund(XRP(10'000), issuer);
+                    env.close();
+                    MPTTester mptt{env, issuer, kMptInitNoFund};
+                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+                    PrettyAsset const asset = mptt.issuanceID();
+                    mptt.authorize({.account = a1});
+                    env.close();
+
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+                    env(tx);
+                    env.close();
+                    vaultKey = keylet.key;
+                    return true;
+                });
+        }
+
+        // Invalid transfer
+        std::array, 3> const invalidTransferTests = {
+            std::make_pair(ttAMM_WITHDRAW, false),
+            std::make_pair(ttPAYMENT, false),
+            std::make_pair(ttPAYMENT, true)};
+        // The two amendments that gate enforcement, in all four combinations.
+        FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0};
+        for (auto const gates :
+             {gatesEnabled,
+              gatesEnabled - featureMPTokensV2,
+              gatesEnabled - fixCleanup3_4_0,
+              FeatureBitset{}})
+        {
+            for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
+            {
+                for (auto const flag :
+                     {static_cast(lsfMPTLocked),
+                      ~lsfMPTCanTransfer,
+                      ~lsfMPTCanTrade,
+                      0u})
+                {
+                    MPTID id{};
+                    auto const isSuccess = !gates.any() || flag == 0 ||
+                        (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
+                        (tx == ttAMM_WITHDRAW &&
+                         (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
+                    std::pair const error = isSuccess
+                        ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS))
+                        : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED));
+                    doInvariantCheck(
+                        {{isSuccess ? "" : "invalid MPToken transfer between holders"}},
+                        [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                            auto update = [&](AccountID const& a, std::uint64_t v) {
+                                auto sle = ac.view().peek(keylet::mptoken(id, a));
+                                if (!sle)
+                                    return false;
+                                sle->at(sfMPTAmount) = v;
+                                ac.view().update(sle);
+                                return true;
+                            };
+                            auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id));
+                            if (!issuanceSle)
+                                return false;
+                            auto const flags = issuanceSle->at(sfFlags);
+                            if (flag == lsfMPTLocked)
+                            {
+                                issuanceSle->at(sfFlags) = flags | lsfMPTLocked;
+                            }
+                            else if (flag != 0u)
+                            {
+                                issuanceSle->at(sfFlags) = flags & flag;
+                            }
+                            issuanceSle->at(sfOutstandingAmount) = 200;
+                            ac.view().update(issuanceSle);
+                            return update(a1, 101) && update(a2, 99);
+                        },
+                        XRPAmount{},
+                        STTx{
+                            tx,
+                            [&](STObject& tx) {
+                                if (crossCurrencyPayment)
+                                {
+                                    tx.setFieldAmount(
+                                        sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id}));
+                                }
+                            }},
+                        {error.first, error.second},
+                        [&](Account const& a1, Account const& a2, Env& env) {
+                            Account const gw("gw");
+                            env.fund(XRP(1'000), gw);
+                            MPTTester const usd(
+                                {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100});
+                            id = usd.issuanceID();
+                            // Either gate enforces, so both must be off to stay
+                            // advisory. Disable after setting up the MPT; the
+                            // next env.close() is what makes it take effect.
+                            if (!gates[featureMPTokensV2])
+                                env.disableFeature(featureMPTokensV2);
+                            if (!gates[fixCleanup3_4_0])
+                                env.disableFeature(fixCleanup3_4_0);
+                            return true;
+                        });
+                }
+            }
+        }
+
+        // An orphan has a zero balance, so only deletion is legitimate (see
+        // "Skipping Deleted MPTs" in testConfidentialMPTTransfer).
+        {
+            MPTID orphanID;
+            auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) {
+                MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+                mpt.create({.flags = tfMPTCanTransfer});
+                orphanID = mpt.issuanceID();
+                // A2 is authorized but never paid, so its balance is zero and
+                // the issuance can be destroyed while its MPToken lives on.
+                mpt.authorize({.account = a2});
+                mpt.destroy();
+                return true;
+            };
+            // ValidMPTBalanceChanges also reports this, so assert on the
+            // orphan message, which only the missing-issuance branch produces.
+            doInvariantCheck(
+                {{"orphaned MPToken balance changed"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                        return false;
+                    (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                    ac.view().update(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan);
+            // Negative control: erasing the orphan is how it gets cleaned up.
+            doInvariantCheck(
+                {},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tesSUCCESS, tesSUCCESS},
+                setupOrphan);
+            // The same erase on a failure. The orphan branch continues, so only
+            // the pre-loop deletion check can report this one.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
+        // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
+        // through sfReferenceHolding to test the vault's underlying asset for
+        // each changed holder.
+        {
+            Account const gw{"gw"};
+            MPTID shareID{};
+
+            // Vault setup: a1 and a2 both deposit IOU and hold vault shares.
+            auto const setupVault = [&](Account const& a1,
+                                        Account const& a2,
+                                        Env& env) -> std::tuple {
+                env.fund(XRP(1'000), gw);
+                env.trust(gw["IOU"](10'000), a1);
+                env.trust(gw["IOU"](10'000), a2);
+                env.close();
+                env(pay(gw, a1, gw["IOU"](500)));
+                env(pay(gw, a2, gw["IOU"](500)));
+                env.close();
+
+                Vault const vault{env};
+                auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]});
+                env(createTx);
+                env.close();
+                env(vault.deposit(
+                    {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
+                env(vault.deposit(
+                    {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
+                env.close();
+
+                return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)};
+            };
+
+            // Simulate a vault-share transfer: a1 sends 10 shares to a2.
+            auto const precheck =
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool {
+                auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id()));
+                auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
+                if (!sle1 || !sle2)
+                    return false;
+                (*sle1)[sfMPTAmount] -= 10;
+                (*sle2)[sfMPTAmount] += 10;
+                ac.view().update(sle1);
+                ac.view().update(sle2);
+                return true;
+            };
+
+            // Case: vault pseudo-account's IOU trustline is frozen.
+            {
+                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                    auto [sid, vid] = setupVault(a1, a2, env);
+                    shareID = sid;
+                    env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze));
+                    env.close();
+                    return true;
+                };
+
+                doInvariantCheck(
+                    Env{*this, all_},
+                    {{"invalid MPToken transfer between holders"}},
+                    precheck,
+                    XRPAmount{},
+                    STTx{ttPAYMENT, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    preclose);
+            }
+
+            // Case: receiver's (a2's) IOU trustline is frozen.
+            {
+                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                    auto [sid, vid] = setupVault(a1, a2, env);
+                    shareID = sid;
+                    env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
+                    env.close();
+                    return true;
+                };
+
+                doInvariantCheck(
+                    Env{*this, all_},
+                    {{"invalid MPToken transfer between holders"}},
+                    precheck,
+                    XRPAmount{},
+                    STTx{ttPAYMENT, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    preclose);
+            }
+        }
+    }
+
+    void
+    testConfidentialMPTTransfer()
+    {
+        using namespace test::jtx;
+        testcase << "ValidConfidentialMPToken";
+
+        MPTID mptID;
+
+        // Generate an MPT with privacy, issue 100 tokens to A2.
+        // Perform a confidential conversion to populate encrypted state.
+        auto const precloseConfidential =
+            [&mptID](Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
+            mptID = mpt.issuanceID();
+
+            mpt.authorize({.account = a2});
+            mpt.pay(a1, a2, 100);
+
+            mpt.generateKeyPair(a1);
+            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
+
+            mpt.generateKeyPair(a2);
+            mpt.convert({
+                .account = a2,
+                .amt = 100,
+                .holderPubKey = mpt.getPubKey(a2),
+            });
+            return true;
+        };
+
+        // badDelete
+        doInvariantCheck(
+            {"MPToken deleted with encrypted fields while COA > 0"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Force an erase of the object while the COA remains 100
+                ac.view().erase(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseConfidential);
+
+        // badConsistency
+        doInvariantCheck(
+            {"MPToken encrypted field existence inconsistency"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Remove one of the required encrypted fields to create a mismatch
+                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        doInvariantCheck(
+            {"MPToken encrypted field existence inconsistency"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
+                sleToken->makeFieldAbsent(sfConfidentialBalanceInbox);
+                sleToken->makeFieldAbsent(sfConfidentialBalanceSpending);
+                sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00});
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // requiresPrivacyFlag
+        auto const precloseNoPrivacy = [&mptID](
+                                           Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            // completely omitted the tfMPTCanHoldConfidentialBalance flag here.
+            mpt.create({.flags = tfMPTCanTransfer});
+            mptID = mpt.issuanceID();
+            mpt.authorize({.account = a2});
+            mpt.pay(a1, a2, 100);
+            return true;
+        };
+
+        doInvariantCheck(
+            {"MPToken has encrypted fields but Issuance does not have "
+             "lsfMPTCanHoldConfidentialBalance "
+             "set"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Inject all three encrypted fields consistently (inbox+spending+issuer must be
+                // in sync or badConsistency fires first and masks requiresPrivacyFlag).
+                sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00});
+                sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00});
+                sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00});
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseNoPrivacy);
+
+        // badCOA
+        doInvariantCheck(
+            {"Confidential outstanding amount exceeds total outstanding amount"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+                // Total outstanding is natively 100; bloat the COA over 100
+                sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200);
+                ac.view().update(sleIssuance);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Conservation Violation
+        doInvariantCheck(
+            {"Token conservation violation for MPT"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+
+                sleIssuance->setFieldU64(
+                    sfConfidentialOutstandingAmount,
+                    sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10);
+                ac.view().update(sleIssuance);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0)
+        doInvariantCheck(
+            {"Invariant failed: OutstandingAmount changed "
+             "by confidential transaction that should not "
+             "modify it for MPT"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+                sleIssuance->setFieldU64(
+                    sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1);
+                ac.view().update(sleIssuance);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Send/MergeInbox and zero-COA-delta confidential transactions must not
+        // change public holder MPTAmount.
+        doInvariantCheck(
+            {"Invariant failed: MPTAmount changed by confidential "
+             "transaction that should not modify this field."},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1);
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
+            // Second pass is tef: the bumped MPTAmount also trips
+            // ValidMPTTransfer's on-failure check, which escalates the tec.
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseConfidential);
+
+        // badVersion
+        doInvariantCheck(
+            {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending "
+             "changed"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Blob const kChangedConfidentialSpending = {0xBA, 0xDD};
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending);
+
+                // DO NOT update sfConfidentialBalanceVersion
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Skipping Deleted MPTs (Issuance deleted)
+        auto const precloseOrphan = [&mptID](
+                                        Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
+            mptID = mpt.issuanceID();
+            mpt.authorize({.account = a2});
+
+            // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
+            mpt.generateKeyPair(a1);
+            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
+            mpt.generateKeyPair(a2);
+            mpt.convert({
+                .account = a2,
+                .amt = 0,
+                .holderPubKey = mpt.getPubKey(a2),
+            });
+
+            // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
+            mpt.destroy();
+            return true;
+        };
+
+        doInvariantCheck(
+            {},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Safely able to erase the deleted token.
+                ac.view().erase(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tesSUCCESS, tesSUCCESS},
+            precloseOrphan);
+    }
+
+public:
+    void
+    run() override
+    {
+        testConfidentialMPTTransfer();
+        testMPT();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsMPT, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsMisc_test.cpp b/src/test/app/invariants/InvariantsMisc_test.cpp
new file mode 100644
index 0000000000..a0084ac530
--- /dev/null
+++ b/src/test/app/invariants/InvariantsMisc_test.cpp
@@ -0,0 +1,1585 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsMisc_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testXRPNotCreated()
+    {
+        using namespace test::jtx;
+        testcase << "XRP created";
+        doInvariantCheck(
+            {{"XRP net change was positive: 500"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // put a single account in the view and "manufacture" some XRP
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto amt = sle->getFieldAmount(sfBalance);
+                sle->setFieldAmount(sfBalance, amt + STAmount{500});
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testAccountRootsNotRemoved()
+    {
+        using namespace test::jtx;
+        testcase << "account root removed";
+
+        // An account was deleted, but not by an AccountDelete transaction.
+        doInvariantCheck(
+            {{"an account root was deleted"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // remove an account from the view
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                ac.view().erase(sle);
+                return true;
+            });
+
+        // Successful AccountDelete transaction that didn't delete an account.
+        //
+        // Note that this is a case where a second invocation of the invariant
+        // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED.
+        // After a discussion with the team, we believe that's okay.
+        doInvariantCheck(
+            {{"account deletion succeeded without deleting an account"}},
+            [](Account const&, Account const&, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // Successful AccountDelete that deleted more than one account.
+        doInvariantCheck(
+            {{"account deletion succeeded but deleted multiple accounts"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // remove two accounts from the view
+                auto sleA1 = ac.view().peek(keylet::account(a1.id()));
+                auto sleA2 = ac.view().peek(keylet::account(a2.id()));
+                if (!sleA1 || !sleA2)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA2->at(sfBalance) = beast::kZero;
+                ac.view().erase(sleA1);
+                ac.view().erase(sleA2);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+    }
+
+    void
+    testAccountRootsDeletedClean()
+    {
+        using namespace test::jtx;
+        testcase << "account root deletion left artifact";
+
+        doInvariantCheck(
+            {{"account deletion left behind a non-zero balance"}},
+            // NOLINTNEXTLINE(readability-identifier-naming)
+            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                // A1 has a balance. Delete A1
+                auto const a1 = A1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1));
+                if (!sleA1)
+                    return false;
+                if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero))
+                    return false;
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a non-zero owner count"}},
+            // NOLINTNEXTLINE(readability-identifier-naming)
+            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                // Increment A1's owner count, then delete A1
+                auto const a1 = A1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1));
+                if (!sleA1)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sleA1->at(sfBalance) = beast::kZero;
+                BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0);
+                increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoredOwnerCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoringOwnerCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const a1Id = a1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1Id));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoringAccountCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setAccountID(sfSponsor, a2.id());
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            Env{*this, FeatureBitset{featureSponsor}},
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setAccountID(sfSponsor, a2.id());
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets)
+        {
+            if (!includeInTests)
+                continue;
+
+            using namespace std::string_literals;
+
+            doInvariantCheck(
+                {{"account deletion left behind a "s + type.cStr() + " object"}},
+                // NOLINTNEXTLINE(readability-identifier-naming)
+                [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                    // Add an object to the ledger for account A1, then delete
+                    // A1
+                    auto const a1 = A1.id();
+                    auto sleA1 = ac.view().peek(keylet::account(a1));
+                    if (!sleA1)
+                        return false;
+
+                    auto const key = std::invoke(keyletfunc, a1);
+                    auto const newSLE = std::make_shared(key);
+                    ac.view().insert(newSLE);
+                    // Clear the balance so the "account deletion left behind a
+                    // non-zero balance" check doesn't trip earlier than the
+                    // desired check.
+                    sleA1->at(sfBalance) = beast::kZero;
+                    ac.view().erase(sleA1);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+        }
+
+        // NFT special case
+        doInvariantCheck(
+            {{"account deletion left behind a NFTokenPage object"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                // remove an account from the view
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const&, Env& env) {
+                // Preclose callback to mint the NFT which will be deleted in
+                // the Precheck callback above.
+                env(token::mint(a1));
+
+                return true;
+            });
+
+        // AMM special cases
+        AccountID ammAcctID;
+        uint256 ammKey;
+        Issue ammIssue;
+        doInvariantCheck(
+            {{"account deletion left behind a DirectoryNode object"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // Delete the AMM account without cleaning up the directory or
+                // deleting the AMM object
+                auto sle = ac.view().peek(keylet::account(ammAcctID));
+                if (!sle)
+                    return false;
+
+                BEAST_EXPECT(sle->at(~sfAMMID));
+                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
+
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                // Preclose callback to create the AMM which will be partially
+                // deleted in the Precheck callback above.
+                AMM const amm(env, a1, XRP(100), a1["USD"](50));
+                ammAcctID = amm.ammAccount();
+                ammKey = amm.ammID();
+                ammIssue = amm.lptIssue();
+                return true;
+            });
+        doInvariantCheck(
+            {{"account deletion left behind a AMM object"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // Delete all the AMM's trust lines, remove the AMM from the AMM
+                // account's directory (this deletes the directory), and delete
+                // the AMM account. Do not delete the AMM object.
+                auto sle = ac.view().peek(keylet::account(ammAcctID));
+                if (!sle)
+                    return false;
+
+                BEAST_EXPECT(sle->at(~sfAMMID));
+                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
+
+                for (auto const& trustKeylet :
+                     {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)})
+                {
+                    auto const line = ac.view().peek(trustKeylet);
+                    if (!line)
+                    {
+                        return false;
+                    }
+
+                    STAmount const lowLimit = line->at(sfLowLimit);
+                    STAmount const highLimit = line->at(sfHighLimit);
+                    BEAST_EXPECT(
+                        trustDelete(
+                            ac.view(),
+                            line,
+                            lowLimit.getIssuer(),
+                            highLimit.getIssuer(),
+                            ac.journal) == tesSUCCESS);
+                }
+
+                auto const ammSle = ac.view().peek(keylet::amm(ammKey));
+                if (!BEAST_EXPECT(ammSle))
+                    return false;
+                auto const ownerDirKeylet = keylet::ownerDir(ammAcctID);
+
+                BEAST_EXPECT(
+                    ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false));
+                BEAST_EXPECT(
+                    !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet));
+
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                // Preclose callback to create the AMM which will be partially
+                // deleted in the Precheck callback above.
+                AMM const amm(env, a1, XRP(100), a1["USD"](50));
+                ammAcctID = amm.ammAccount();
+                ammKey = amm.ammID();
+                ammIssue = amm.lptIssue();
+                return true;
+            });
+    }
+
+    void
+    testTypesMatch()
+    {
+        using namespace test::jtx;
+        testcase << "ledger entry types don't match";
+        doInvariantCheck(
+            {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // replace an entry in the table with an SLE of a different type
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto const sleNew = std::make_shared(ltTICKET, sle->key());
+                ac.rawView().rawReplace(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"invalid ledger entry type added"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // add an entry in the table with an SLE of an invalid type
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                // make a dummy escrow ledger entry, then change the type to an
+                // unsupported value so that the valid type invariant check
+                // will fail.
+                auto const sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                // We don't use ltNICKNAME directly since it's marked deprecated
+                // to prevent accidental use elsewhere.
+                sleNew->type_ = static_cast('n');
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testXRPBalanceCheck()
+    {
+        using namespace test::jtx;
+        testcase << "XRP balance checks";
+
+        doInvariantCheck(
+            {{"Cannot return non-native STAmount as XRPAmount"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // non-native balance
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                STAmount const nonNative(a2["USD"](51));
+                sle->setFieldAmount(sfBalance, nonNative);
+                ac.view().update(sle);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}},
+            [this](Account const& a1, Account const&, ApplyContext& ac) {
+                // balance exceeds genesis amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Use `drops(1)` to bypass a call to STAmount::canonicalize
+                // with an invalid value
+                sle->setFieldAmount(sfBalance, kInitialXrp + drops(1));
+                BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative());
+                ac.view().update(sle);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"incorrect account XRP balance"},
+             {"XRP net change of -1000000001 doesn't match fee 0"}},
+            [this](Account const& a1, Account const&, ApplyContext& ac) {
+                // balance is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                sle->setFieldAmount(sfBalance, STAmount{1, true});
+                BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative());
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testTransactionFeeCheck()
+    {
+        using namespace test::jtx;
+        using namespace std::string_literals;
+        testcase << "Transaction fee checks";
+
+        doInvariantCheck(
+            {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{-1});
+
+        doInvariantCheck(
+            {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)},
+             {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{kInitialXrp});
+
+        doInvariantCheck(
+            {{"fee paid is 20 exceeds fee specified in transaction."},
+             {"XRP net change of 0 doesn't match fee 20"}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{20},
+            STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }});
+    }
+
+    void
+    testNoBadOffers()
+    {
+        using namespace test::jtx;
+        testcase << "no bad offers";
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer with negative takerpays
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer with negative takergets
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleNew->setFieldAmount(sfTakerGets, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer XRP to XRP
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, XRP(10));
+                sleNew->setFieldAmount(sfTakerGets, XRP(11));
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testValidNewAccountRoot()
+    {
+        using namespace test::jtx;
+        testcase << "valid new account root";
+
+        doInvariantCheck(
+            {{"account root created illegally"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert a new account root created by a non-payment into
+                // the view.
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"multiple accounts created in a single transaction"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert two new account roots into the view.
+                {
+                    Account const a3{"A3"};
+                    Keylet const acctKeylet = keylet::account(a3);
+                    auto const sleA3 = std::make_shared(acctKeylet);
+                    ac.view().insert(sleA3);
+                }
+                {
+                    Account const a4{"A4"};
+                    Keylet const acctKeylet = keylet::account(a4);
+                    auto const sleA4 = std::make_shared(acctKeylet);
+                    ac.view().insert(sleA4);
+                }
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"account created with wrong starting sequence number"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert a new account root with the wrong starting sequence.
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, ac.view().seq() + 1);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created by a wrong transaction type"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account created with wrong starting sequence number"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, ac.view().seq());
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_CREATE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created with wrong flags"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created with wrong flags"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(
+                    sfFlags,
+                    lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_CREATE, [](STObject& tx) {}});
+    }
+
+    void
+    testNoModifiedUnmodifiableFields()
+    {
+        testcase("no modified unmodifiable fields");
+        using namespace jtx;
+
+        // Initialize with a placeholder value because there's no default ctor
+        Keylet loanBrokerKeylet = keylet::amendments();
+        Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) {
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+            loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset);
+            return BEAST_EXPECT(env.le(loanBrokerKeylet));
+        };
+
+        {
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfSequence) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); },
+                [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); },
+                [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); },
+                [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const& a1, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(loanBrokerKeylet);
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createLoanBroker);
+            }
+        }
+
+        // Loan flag immutability lives in NoModifiedUnmodifiableFields's
+        // ltLOAN case: lsfLoanOverpayment must never toggle in either
+        // direction, and lsfLoanDefault (gated on featureLendingProtocolV1_1)
+        // may only transition from unset to set. Each case needs a loan that
+        // already exists in the base ledger, so that the apply-view modification
+        // is seen as a before/after change rather than an insertion.
+        {
+            struct Case
+            {
+                std::uint32_t before;
+                std::uint32_t after;
+                std::string expected;
+            };
+            auto const cases = std::to_array({
+                {.before = lsfLoanOverpayment,
+                 .after = 0,
+                 .expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
+                {.before = 0,
+                 .after = lsfLoanOverpayment,
+                 .expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
+                {.before = lsfLoanDefault,
+                 .after = 0,
+                 .expected = "lsfLoanDefault flag cleared on immutable ledger entry"},
+            });
+
+            for (auto const& c : cases)
+            {
+                Env env{*this, all_};
+                Account const a1{"A1"};
+                env.fund(XRP(1000), a1);
+                env.close();
+
+                OpenView ov{*env.current()};
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
+                auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                {
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
+                    sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(150);
+                    sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                    sleLoan->setFieldU32(sfFlags, c.before);
+                    ov.rawInsert(sleLoan);
+                }
+
+                STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+                test::StreamSink sink{beast::Severity::Warning};
+                beast::Journal const jlog{sink};
+                ApplyContext ac{
+                    env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+                CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+                auto sleLoan = ac.view().peek(loanKeylet);
+                if (!BEAST_EXPECT(sleLoan))
+                    continue;
+                sleLoan->setFieldU32(sfFlags, c.after);
+                ac.view().update(sleLoan);
+
+                auto transactor = makeTransactor(ac);
+                if (!BEAST_EXPECT(transactor))
+                    continue;
+                TER const result = transactor->checkInvariants(
+                    tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                BEAST_EXPECT(result == tecINVARIANT_FAILED);
+                BEAST_EXPECT(sink.messages().str().contains(c.expected));
+            }
+        }
+
+        // Pre-featureLendingProtocolV1_1 sibling of the lsfLoanOverpayment
+        // cases above: the same set-once immutability was originally enforced
+        // by ValidLoan::finalize, so with V1_1 disabled toggling the flag
+        // must trip that legacy check instead. lsfLoanDefault immutability
+        // did not exist pre-V1_1 and is not tested here.
+        {
+            auto const cases = std::to_array>({
+                {lsfLoanOverpayment, 0},
+                {0, lsfLoanOverpayment},
+            });
+
+            for (auto const& [before, after] : cases)
+            {
+                Env env{*this, all_ - featureLendingProtocolV1_1};
+                Account const a1{"A1"};
+                env.fund(XRP(1000), a1);
+                env.close();
+
+                OpenView ov{*env.current()};
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
+                auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                {
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
+                    sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(150);
+                    sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                    sleLoan->setFieldU32(sfFlags, before);
+                    ov.rawInsert(sleLoan);
+                }
+
+                STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+                test::StreamSink sink{beast::Severity::Warning};
+                beast::Journal const jlog{sink};
+                ApplyContext ac{
+                    env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+                CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+                auto sleLoan = ac.view().peek(loanKeylet);
+                if (!BEAST_EXPECT(sleLoan))
+                    continue;
+                sleLoan->setFieldU32(sfFlags, after);
+                ac.view().update(sleLoan);
+
+                auto transactor = makeTransactor(ac);
+                if (!BEAST_EXPECT(transactor))
+                    continue;
+                TER const result = transactor->checkInvariants(
+                    tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                BEAST_EXPECT(result == tecINVARIANT_FAILED);
+                BEAST_EXPECT(sink.messages().str().contains("Loan Overpayment flag changed"));
+            }
+        }
+
+        // Under featureLendingProtocolV1_1, ValidLoan::finalize requires
+        // interest due (total value minus principal and management fee) to be
+        // non-negative after each value is rounded to sfLoanScale. Test zero,
+        // each way to produce a one-unit deficit, and a two-unit deficit. At
+        // scale 0, an XRP-backed broker rejects any deficit, while an
+        // IOU-backed one permits one unit of rounding tolerance.
+        {
+            struct Case
+            {
+                Number totalValue;
+                Number principal;
+                Number managementFee;
+                bool expectFireIntegral;
+                bool expectFireTolerant;
+            };
+            // The first case sits exactly at the boundary, the middle three
+            // perturb one component so that interest due is -1, which is within
+            // the tolerance, and the last overshoots it at -2.
+            auto const cases = std::to_array({
+                {.totalValue = Number(100),
+                 .principal = Number(100),
+                 .managementFee = Number(0),
+                 .expectFireIntegral = false,
+                 .expectFireTolerant = false},
+                {.totalValue = Number(99),
+                 .principal = Number(100),
+                 .managementFee = Number(0),
+                 .expectFireIntegral = true,
+                 .expectFireTolerant = false},
+                {.totalValue = Number(100),
+                 .principal = Number(101),
+                 .managementFee = Number(0),
+                 .expectFireIntegral = true,
+                 .expectFireTolerant = false},
+                {.totalValue = Number(100),
+                 .principal = Number(100),
+                 .managementFee = Number(1),
+                 .expectFireIntegral = true,
+                 .expectFireTolerant = false},
+                {.totalValue = Number(98),
+                 .principal = Number(100),
+                 .managementFee = Number(0),
+                 .expectFireIntegral = true,
+                 .expectFireTolerant = true},
+            });
+
+            for (bool const integralAsset : {true, false})
+            {
+                for (auto const& c : cases)
+                {
+                    Env env{*this, all_};
+                    Account const a1{"A1"};
+                    Account const issuer{"issuer"};
+                    env.fund(XRP(1000), a1, issuer);
+                    env.close();
+
+                    // The check reads the broker's vault asset to decide
+                    // whether the rounding tolerance applies, so both
+                    // branches need a real broker over the relevant asset.
+                    auto const asset = [&]() -> PrettyAsset {
+                        if (integralAsset)
+                            return PrettyAsset{xrpIssue(), 1'000'000};
+                        PrettyAsset const iouAsset = issuer["IOU"];
+                        env(trust(a1, iouAsset(1000)));
+                        env(pay(issuer, a1, iouAsset(1000)));
+                        env.close();
+                        return iouAsset;
+                    }();
+
+                    auto const brokerKeylet = this->createLoanBroker(a1, env, asset);
+                    if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                        continue;
+                    env.close();
+
+                    OpenView ov{*env.current()};
+
+                    auto const loanKeylet =
+                        keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                    // Seed a loan whose interest due sits at the boundary. The
+                    // apply-view update below moves it.
+                    {
+                        auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
+                        sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                        sleLoan->at(sfTotalValueOutstanding) = Number(100);
+                        sleLoan->at(sfManagementFeeOutstanding) = Number(0);
+                        sleLoan->at(sfLoanScale) = 0;
+                        sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                        ov.rawInsert(sleLoan);
+                    }
+
+                    STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+                    test::StreamSink sink{beast::Severity::Warning};
+                    beast::Journal const jlog{sink};
+                    ApplyContext ac{
+                        env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+                    CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+                    auto sleLoan = ac.view().peek(loanKeylet);
+                    if (!BEAST_EXPECT(sleLoan))
+                        continue;
+                    sleLoan->at(sfTotalValueOutstanding) = c.totalValue;
+                    sleLoan->at(sfPrincipalOutstanding) = c.principal;
+                    sleLoan->at(sfManagementFeeOutstanding) = c.managementFee;
+                    ac.view().update(sleLoan);
+
+                    auto transactor = makeTransactor(ac);
+                    if (!BEAST_EXPECT(transactor))
+                        continue;
+                    TER const result = transactor->checkInvariants(
+                        tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                    auto const messages = sink.messages().str();
+                    if (integralAsset ? c.expectFireIntegral : c.expectFireTolerant)
+                    {
+                        BEAST_EXPECT(result == tecINVARIANT_FAILED);
+                        BEAST_EXPECT(messages.contains("Loan interest due is negative"));
+                    }
+                    else
+                    {
+                        // Other invariants may still fire on this raw-inserted
+                        // loan, so only assert the specific message is absent.
+                        BEAST_EXPECT(!messages.contains("Loan interest due is negative"));
+                    }
+                }
+            }
+        }
+
+        // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
+        // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
+        Keylet closedEndedVaultKeylet = keylet::amendments();
+        Preclose const createClosedEndedVault = [&, this](
+                                                    Account const& a, Account const&, Env& env) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create(
+                {.owner = a,
+                 .asset = xrpIssue(),
+                 .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            closedEndedVaultKeylet = keylet;
+            return BEAST_EXPECT(env.le(closedEndedVaultKeylet));
+        };
+
+        {
+            // Each mutation must keep the vault otherwise valid so that only the immutability check
+            // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind
+            // stays within the recognised range.
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(closedEndedVaultKeylet);
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createClosedEndedVault);
+            }
+        }
+
+        {
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const& a1, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(keylet::account(a1.id()));
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    });
+            }
+        }
+    }
+
+    void
+    testInvariantOverwrite(FeatureBitset features)
+    {
+        using namespace test::jtx;
+        bool const fixEnabled = features[fixCleanup3_1_3];
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+        std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS};
+
+        // Insert two trust line SLEs in hash-sorted order, with the "bad"
+        // entry at the lower-sorting key so it is visited first by
+        // ApplyStateTable::visit(). The configurer callables receive the
+        // SLE and the Issue corresponding to that side's keylet currency.
+        auto const insertOrderedTrustLinePair = [](ApplyContext& ac,
+                                                   Account const& a1,
+                                                   Account const& a2,
+                                                   Account const& a3,
+                                                   auto const& badConfig,
+                                                   auto const& goodConfig) {
+            char const* const c1 = "USD";
+            char const* const c2 = "EUR";
+            auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency);
+            auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency);
+
+            bool const k1First = k1.key < k2.key;
+            auto const& badKey = k1First ? k1 : k2;
+            auto const& goodKey = k1First ? k2 : k1;
+            Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()};
+            Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()};
+
+            auto const sleBad = std::make_shared(badKey);
+            badConfig(*sleBad, badIss);
+            ac.view().insert(sleBad);
+
+            auto const sleGood = std::make_shared(goodKey);
+            goodConfig(*sleGood, goodIss);
+            ac.view().insert(sleGood);
+        };
+
+        // Regression: bad XRP trust line followed by a valid trust line.
+        // With the fix, the invariant catches the violation. Without it,
+        // the valid entry overwrites the flag to false. The keylet
+        // currencies are non-XRP (the invariant inspects sfLowLimit /
+        // sfHighLimit issue, not the keylet currency).
+        testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"an XRP trust line was created"}}
+                       : std::vector{},
+            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Account const a3{"A3"};
+                insertOrderedTrustLinePair(
+                    ac,
+                    a1,
+                    a2,
+                    a3,
+                    [](SLE& sle, Issue const& iss) {
+                        // sfLowLimit has xrpIssue, making isXrp = true
+                        sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                    },
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                    });
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            fixEnabled ? failTers : passTers);
+
+        // Regression: bad deep-freeze trust line followed by a valid one.
+        testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"a trust line with deep freeze flag without "
+                                                   "normal freeze was created"}}
+                       : std::vector{},
+            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Account const a3{"A3"};
+                insertOrderedTrustLinePair(
+                    ac,
+                    a1,
+                    a2,
+                    a3,
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                        sle.setFieldU32(sfFlags, lsfLowDeepFreeze);
+                    },
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                        sle.setFieldU32(sfFlags, 0u);
+                    });
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            fixEnabled ? failTers : passTers);
+
+        // Regression: MPT OutstandingAmount exceeds max, but locked <=
+        // outstanding. Plain assignment would overwrite bad_ = true.
+        // With the fix, NoZeroEscrow catches it.
+        // Without the fix, NoZeroEscrow passes but ValidMPTIssuance
+        // still fires ("a MPT issuance was created").
+        testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"escrow specifies invalid amount"}}
+                       : std::vector{{"a MPT issuance was created"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_
+                sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1);
+                // locked is valid and <= outstanding -> must NOT clear bad_
+                sleNew->setFieldU64(sfLockedAmount, 10);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            failTers);
+    }
+
+    void
+    testSponsorship()
+    {
+        using namespace test::jtx;
+        using namespace std::string_literals;
+        testcase("Sponsorship");
+        {
+            auto const expectMessage =
+                "SponsoredOwnerCount does not equal SponsoringOwnerCount delta.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoringOwnerCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "OwnerCount must be greater than or equal to SponsoredOwnerCount.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfOwnerCount, 0);
+                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
+                    ac.view().update(sle);
+
+                    auto const sle2 = ac.view().peek(keylet::account(a2.id()));
+                    if (!sle2)
+                        return false;
+                    sle2->setFieldU32(sfSponsoringOwnerCount, 1);
+                    ac.view().update(sle2);
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta.";
+            uint256 checkID;
+
+            doInvariantCheck(
+                {{expectMessage}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto const check = ac.view().peek(keylet::check(checkID));
+                    if (!check)
+                        return false;
+                    check->setAccountID(sfSponsor, a2.id());
+                    ac.view().update(check);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&checkID](Account const& a1, Account const& a2, Env& env) {
+                    checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key;
+                    env(check::create(a1, a2, XRP(1)));
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "Invariant failed: Net delta of SponsoringAccountCount does "
+                "not match net delta of sfSponsor presence.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoringAccountCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setAccountID(sfSponsor, a2.id());
+                    ac.view().update(sle);
+                    return true;
+                });
+        }
+    }
+
+    void
+    testObjectHasPseudoAccount()
+    {
+        testcase << "object has pseudo-account";
+        using namespace jtx;
+
+        auto const amendments = all_ | fixCleanup3_3_0;
+
+        // Vault: object deleted without its pseudo-account
+        {
+            Keylet vaultKeylet = keylet::amendments();
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted Vault without deleting its pseudo-account"}},
+                [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(vaultKeylet);
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttVAULT_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&vaultKeylet](Account const& a1, Account const&, Env& env) {
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                    env(tx);
+                    vaultKeylet = keylet;
+                    return true;
+                });
+        }
+
+        // AMM: object deleted without its pseudo-account
+        {
+            uint256 ammID{};
+            Account const gw{"gw"};
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted AMM without deleting its pseudo-account"}},
+                [&ammID](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::amm(ammID));
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttAMM_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&ammID, &gw](Account const&, Account const&, Env& env) {
+                    env.fund(XRP(1'000), gw);
+                    AMM const amm(env, gw, XRP(100), gw["USD"](100));
+                    ammID = amm.ammID();
+                    return true;
+                });
+        }
+
+        // LoanBroker: object deleted without its pseudo-account
+        {
+            Keylet loanBrokerKeylet = keylet::amendments();
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted LoanBroker without deleting its pseudo-account"}},
+                [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(loanBrokerKeylet);
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) {
+                    PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                    loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
+                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
+                });
+        }
+
+        // Deleted object missing sfAccount field (defensive check).
+        // Manually construct the view to place a vault SLE without
+        // sfAccount into the base ledger, then erase it.
+        {
+            Env env{*this, amendments};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq()));
+            auto sleVault = std::make_shared(vaultKeylet);
+            sleVault->makeFieldAbsent(sfAccount);
+            ov.rawInsert(sleVault);
+
+            STTx const tx{ttVAULT_DELETE, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sle = ac.view().peek(vaultKeylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            ac.view().erase(sle);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tecINVARIANT_FAILED);
+            BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
+        }
+    }
+
+    void
+    testTxCheckException()
+    {
+        testcase << "txCheck exception";
+        using namespace jtx;
+
+        // A TxInvariantCheck that throws from the requested hook, so we can
+        // exercise checkInvariantsHelper's catch block via the
+        // transaction-specific layer (as opposed to the protocol layer,
+        // which testObjectHasPseudoAccount's last case already covers via a
+        // real Transactor's finalizeInvariants).
+        enum class ThrowFrom { VisitEntry, Finalize };
+
+        struct ThrowingTxInvariantCheck : TxInvariantCheck
+        {
+            ThrowFrom const throwFrom;
+
+            explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom)
+            {
+            }
+
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+                if (throwFrom == ThrowFrom::VisitEntry)
+                    throw std::runtime_error("test-injected visitEntry exception");
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                if (throwFrom == ThrowFrom::Finalize)
+                    throw std::runtime_error("test-injected finalize exception");
+                return true;
+            }
+        };
+
+        for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize})
+        {
+            Env env{*this};
+            Account const alice{"alice"};
+            env.fund(XRP(1000), alice);
+            env.close();
+
+            OpenView ov{*env.current()};
+            STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            // visitEntry only runs for entries the transaction touched, so
+            // make a modification for the traversal to report.
+            auto sle = ac.view().peek(keylet::account(alice.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            sle->at(sfSequence) = sle->at(sfSequence) + 1;
+            ac.view().update(sle);
+
+            ThrowingTxInvariantCheck throwing{throwFrom};
+            TER terActual = tesSUCCESS;
+            for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+            {
+                terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing);
+                BEAST_EXPECT(terExpect == terActual);
+                BEAST_EXPECT(sink.messages().str().contains(
+                    "Transaction caused an exception during invariant checks"));
+            }
+        }
+    }
+
+    void
+    testTxCheckFinalizeFalse()
+    {
+        testcase << "txCheck finalize returns false";
+        using namespace jtx;
+
+        // A TxInvariantCheck whose finalize returns false, so we can exercise
+        // the "Transaction has failed one or more transaction invariants"
+        // log path in checkInvariantsHelper independently of any real
+        // transactor. This is the transaction-layer analogue of the
+        // protocol-layer coverage in testObjectHasPseudoAccount / others.
+        struct FailingTxInvariantCheck : TxInvariantCheck
+        {
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                return false;
+            }
+        };
+
+        Env env{*this};
+        Account const alice{"alice"};
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        OpenView ov{*env.current()};
+        STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+        test::StreamSink sink{beast::Severity::Warning};
+        beast::Journal const jlog{sink};
+        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+        FailingTxInvariantCheck failing;
+        TER terActual = tesSUCCESS;
+        for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+        {
+            terActual = checkInvariants(ac, terActual, XRPAmount{}, failing);
+            BEAST_EXPECT(terExpect == terActual);
+            BEAST_EXPECT(sink.messages().str().contains(
+                "Transaction has failed one or more transaction invariants"));
+            // The protocol-layer log must not appear: only the tx-layer
+            // finalize failed here.
+            BEAST_EXPECT(!sink.messages().str().contains(
+                "Transaction has failed one or more global invariants"));
+        }
+    }
+
+    void
+    run() override
+    {
+        testXRPNotCreated();
+        testAccountRootsNotRemoved();
+        testAccountRootsDeletedClean();
+        testTypesMatch();
+        testXRPBalanceCheck();
+        testTransactionFeeCheck();
+        testNoBadOffers();
+        testValidNewAccountRoot();
+        testNoModifiedUnmodifiableFields();
+        testInvariantOverwrite(all_);
+        testInvariantOverwrite(all_ - fixCleanup3_1_3);
+        testObjectHasPseudoAccount();
+        testSponsorship();
+        testTxCheckException();
+        testTxCheckFinalizeFalse();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsMisc, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsPermissioned_test.cpp b/src/test/app/invariants/InvariantsPermissioned_test.cpp
new file mode 100644
index 0000000000..87349fb9e1
--- /dev/null
+++ b/src/test/app/invariants/InvariantsPermissioned_test.cpp
@@ -0,0 +1,957 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsPermissioned_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testPermissionedDomainInvariants(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const fixEnabled = features[fixCleanup3_1_3];
+        std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED};
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+
+        testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : "");
+
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain with no rules."}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                return createPermissionedDomain(ac, a1, a2, 0).get();
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 2";
+
+        static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1;
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                return !!createPermissionedDomain(ac, a1, a2, kTooBig);
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 3";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't sorted"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
+
+                STArray credentials(sfAcceptedCredentials, 2);
+                for (std::size_t n = 0; n < 2; ++n)
+                {
+                    auto cred = STObject::makeInnerObject(sfCredential);
+                    cred.setAccountID(sfIssuer, a2);
+                    auto credType = std::string("cred_type") + std::to_string(9 - n);
+                    cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                    credentials.pushBack(std::move(cred));
+                }
+                slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                ac.view().update(slePd);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 4";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't unique"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
+
+                STArray credentials(sfAcceptedCredentials, 2);
+                for (std::size_t n = 0; n < 2; ++n)
+                {
+                    auto cred = STObject::makeInnerObject(sfCredential);
+                    cred.setAccountID(sfIssuer, a2);
+                    cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
+                    credentials.pushBack(std::move(cred));
+                }
+                slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                ac.view().update(slePd);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 1";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain with no rules."}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD with empty rules
+                {
+                    STArray const credentials(sfAcceptedCredentials, 2);
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 2";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, kTooBig);
+
+                    for (std::size_t n = 0; n < kTooBig; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        auto credType = "cred_type2" + std::to_string(n);
+                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                        credentials.pushBack(std::move(cred));
+                    }
+
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 3";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't sorted"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, 2);
+                    for (std::size_t n = 0; n < 2; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        auto credType = std::string("cred_type2") + std::to_string(9 - n);
+                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                        credentials.pushBack(std::move(cred));
+                    }
+
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 4";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't unique"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, 2);
+                    for (std::size_t n = 0; n < 2; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
+                        credentials.pushBack(std::move(cred));
+                    }
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS};
+
+        std::vector const badMoreThan1{
+            {"transaction affected more than 1 permissioned domain entry."}};
+        std::vector const emptyV;
+        std::vector const badNoDomains{{"no domain objects affected by"}};
+        std::vector const badNotDeleted{
+            {"domain object modified, but not deleted by "}};
+        std::vector const badDeleted{{"domain object deleted by"}};
+        std::vector const badTx{
+            {"domain object(s) affected by an unauthorized transaction."}};
+
+        {
+            testcase << "PermissionedDomain set 2 domains ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badMoreThan1 : emptyV,
+                [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    createPermissionedDomain(ac, a1, a2, 2, 11);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del 2 domains";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badMoreThan1 : emptyV,
+                [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
+                    auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2});
+                    ac.view().erase(sle1);
+                    ac.view().erase(sle2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain set 0 domains ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badNoDomains : emptyV,
+                [](Account const&, Account const&, ApplyContext&) { return true; },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? badTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del 0 domains";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badNoDomains : emptyV,
+                [](Account const&, Account const&, ApplyContext&) { return true; },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? badTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain set, delete domain";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badDeleted : emptyV,
+                [&pd1](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
+                    ac.view().erase(sle1);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del, create domain ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badNotDeleted : emptyV,
+                [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain invalid tx";
+
+            doInvariantCheck(
+                fixEnabled ? badTx : emptyV,
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPAYMENT, [](STObject&) {}},
+                failTers);
+        }
+    }
+
+    void
+    testPermissionedDEX(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const fixEnabled = features[fixCleanup3_1_3];
+
+        testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : "");
+
+        doInvariantCheck(
+            makeEnv(features),
+            {{"domain doesn't exist"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10));
+                auto sleOffer = std::make_shared(offerKey);
+                sleOffer->setAccountID(sfAccount, a1);
+                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                ac.view().insert(sleOffer);
+                return true;
+            },
+            XRPAmount{},
+            STTx{
+                ttOFFER_CREATE,
+                [](STObject& tx) {
+                    tx.setFieldH256(
+                        sfDomainID,
+                        uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33"
+                                "70F3649CE134E5"});
+                    Account const a1{"A1"};
+                    tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                    tx.setFieldAmount(sfTakerGets, XRP(1));
+                }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // missing domain ID in offer object
+        doInvariantCheck(
+            makeEnv(features),
+            {{"hybrid offer is malformed"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                auto sleOffer = std::make_shared(offerKey);
+                sleOffer->setAccountID(sfAccount, a2);
+                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                sleOffer->setFlag(lsfHybrid);
+
+                STArray bookArr;
+                bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                ac.view().insert(sleOffer);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttOFFER_CREATE, [&](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // more than one entry in sfAdditionalBooks
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"hybrid offer is malformed"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+
+                    STArray bookArr;
+                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        // empty sfAdditionalBooks (size 0)
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? std::vector{{"hybrid offer is malformed"}}
+                           : std::vector{},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+
+                    STArray const bookArr;  // empty array, size 0
+                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED}
+                           : std::initializer_list{tesSUCCESS, tesSUCCESS});
+        }
+
+        // hybrid offer missing sfAdditionalBooks
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"hybrid offer is malformed"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"transaction consumed wrong domains"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttOFFER_CREATE,
+                    [&pd2, &a1](STObject& tx) {
+                        tx.setFieldH256(sfDomainID, pd2);
+                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                        tx.setFieldAmount(sfTakerGets, XRP(1));
+                    }},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"domain transaction affected regular offers"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttOFFER_CREATE,
+                    [&](STObject& tx) {
+                        Account const a1{"A1"};
+                        tx.setFieldH256(sfDomainID, pd1);
+                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                        tx.setFieldAmount(sfTakerGets, XRP(1));
+                    }},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+    }
+
+    void
+    testPermissionedDEXDeletedOfferFallback()
+    {
+        using namespace test::jtx;
+
+        testcase << "PermissionedDEX null after";
+
+        // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that
+        // domain lands in the set finalize consults. after == null is never
+        // tracked (pre-340: after-only; post-340: early return) — same result,
+        // both sides are coverage/regression that we do not fall back to before.
+        auto const check = [this](
+                               FeatureBitset features,
+                               bool const afterIsNull,
+                               bool const isDelete,
+                               bool const expectInvariantFailure) {
+            Env env(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2);
+            env.close();
+
+            auto sleOffer =
+                std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10)));
+            sleOffer->setAccountID(sfAccount, a2);
+            sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+            sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+            sleOffer->setFieldH256(sfDomainID, pd1);
+
+            CurrentTransactionRulesGuard const rulesGuard(env.current()->rules());
+
+            ValidPermissionedDEX invariant;
+            if (afterIsNull)
+            {
+                // Defensive path: after is null. Must not fall back to before.
+                invariant.visitEntry(isDelete, sleOffer, nullptr);
+            }
+            else
+            {
+                // Normal / real-erase path: after is the offer on pd1.
+                invariant.visitEntry(isDelete, nullptr, sleOffer);
+            }
+
+            STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) {
+                              tx.setFieldH256(sfDomainID, pd2);
+                              tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                              tx.setFieldAmount(sfTakerGets, XRP(1));
+                          }};
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            bool const passed =
+                invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog);
+            BEAST_EXPECT(passed != expectInvariantFailure);
+            if (expectInvariantFailure)
+            {
+                BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains"));
+            }
+            else
+            {
+                BEAST_EXPECT(sink.messages().str().empty());
+            }
+        };
+
+        auto const pre = all_ - fixCleanup3_4_0;
+        auto const post = all_;
+
+        // after == null: not tracked
+        check(pre, true, true, false);
+        check(post, true, true, false);
+
+        // after == offer on pd1
+        // pre-340: domainsOld_ (delete still inserted) → fail
+        check(pre, false, true, true);
+        // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail
+        check(post, false, true, false);
+        check(post, false, false, true);
+    }
+
+    void
+    testBookDirectoryExchangeRate()
+    {
+        using namespace test::jtx;
+        testcase << "book directory exchange rate";
+
+        auto const getBookRootKey = [](Account const& account, std::uint64_t quality) {
+            Book const book{xrpIssue(), account["USD"], std::nullopt};
+            return keylet::quality(keylet::book(book), quality);
+        };
+
+        // Root book-directory pages carry exchange-rate metadata that must
+        // match the quality encoded in the directory key.
+        auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) {
+            auto sleDir = std::make_shared(dir);
+            sleDir->setFieldH256(sfRootIndex, dir.key);
+            STVector256 indexes;
+            indexes.pushBack(uint256{1});
+            sleDir->setFieldV256(sfIndexes, indexes);
+            sleDir->setFieldU64(sfExchangeRate, exchangeRate);
+            return sleDir;
+        };
+
+        // Child pages do not carry quality metadata; they only point back to
+        // the root directory.
+        auto const makeChildPage = [](Keylet const& rootDir) {
+            auto sleDir = std::make_shared(keylet::page(rootDir, 1));
+            sleDir->setFieldH256(sfRootIndex, rootDir.key);
+            STVector256 indexes;
+            indexes.pushBack(uint256{2});
+            sleDir->setFieldV256(sfIndexes, indexes);
+            return sleDir;
+        };
+
+        auto const makeOfferCreateTx = [] {
+            return STTx{ttOFFER_CREATE, [](STObject& tx) {
+                            Account const account{"A1"};
+                            tx.setFieldAmount(sfTakerPays, XRP(1));
+                            tx.setFieldAmount(sfTakerGets, account["USD"](1));
+                        }};
+        };
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+
+        // Creating a root book directory with mismatched exchange-rate
+        // metadata violates the invariant.
+        doInvariantCheck(
+            {{"book directory exchange rate does not match directory quality"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const directoryQuality = STAmount::kURateOne;
+                auto const dir = getBookRootKey(a1, directoryQuality);
+                ac.view().insert(makeRootPage(dir, directoryQuality + 1));
+                return true;
+            },
+            XRPAmount{},
+            makeOfferCreateTx(),
+            failTers);
+
+        // A new child page must point to an existing root page.
+        doInvariantCheck(
+            {{"book directory root missing"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const directoryQuality = STAmount::kURateOne;
+                auto const rootDir = getBookRootKey(a1, directoryQuality);
+                // Insert only the child page.  It points at rootDir, but the
+                // corresponding root page is intentionally missing.
+                ac.view().insert(makeChildPage(rootDir));
+                return true;
+            },
+            XRPAmount{},
+            makeOfferCreateTx(),
+            failTers);
+
+        // Legacy bad-root tolerance:
+        // - The view contains a pre-existing root page with bad sfExchangeRate
+        //   metadata.
+        // - The simulated transaction only creates a child page pointing to
+        //   that root.
+        // - The invariant must pass because this transaction did not create
+        //   the bad root, only adding a child page.
+        {
+            Env env{*this, all_};
+            Account const a1{"A1"};
+            env.fund(XRP(1000), a1);
+            env.close();
+
+            OpenView view{*env.current()};
+            auto const directoryQuality = STAmount::kURateOne;
+            auto const rootDir = getBookRootKey(a1, directoryQuality);
+            view.rawInsert(makeRootPage(rootDir, directoryQuality + 1));
+
+            ValidBookDirectory invariant;
+            invariant.visitEntry(false, nullptr, makeChildPage(rootDir));
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            BEAST_EXPECT(
+                invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+        }
+
+        // A bad root is rejected when added, ignored when a legacy bad root is
+        // modified without changing sfRootIndex or deleted, and checked when a
+        // modified directory changes sfRootIndex.
+        {
+            Env env{*this, all_};
+            Account const a1{"A1"};
+            env.fund(XRP(1000), a1);
+            env.close();
+
+            OpenView view{*env.current()};
+            auto const directoryQuality = STAmount::kURateOne;
+            auto const rootDir = getBookRootKey(a1, directoryQuality);
+            auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1);
+            auto const badRoot = makeRootPage(rootDir, directoryQuality + 1);
+            view.rawInsert(badRoot);
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+
+            {
+                // add
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, nullptr, badRoot);
+
+                BEAST_EXPECT(
+                    !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+            {
+                // modify (without changing the sfRootIndex)
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, badRoot, badRoot);
+
+                BEAST_EXPECT(
+                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+            {
+                // modify (changing sfRootIndex to a missing root)
+                auto const childBefore = makeChildPage(rootDir);
+                auto const childAfter = std::make_shared(*childBefore, childBefore->key());
+                childAfter->setFieldH256(sfRootIndex, missingRootDir.key);
+
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, childBefore, childAfter);
+
+                test::StreamSink missingRootSink{beast::Severity::Warning};
+                beast::Journal const missingRootJlog{missingRootSink};
+                BEAST_EXPECT(!invariant.finalize(
+                    makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog));
+                BEAST_EXPECT(
+                    missingRootSink.messages().str().contains("book directory root missing"));
+            }
+            {
+                // delete
+                view.rawErase(badRoot);
+                BEAST_EXPECT(!view.exists(rootDir));
+
+                ValidBookDirectory invariant;
+                invariant.visitEntry(true, badRoot, badRoot);
+                BEAST_EXPECT(
+                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+        }
+    }
+
+    static SLE::pointer
+    createPermissionedDomain(
+        ApplyContext& ac,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::uint32_t numCreds = 2,
+        std::uint32_t seq = 10)
+    {
+        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq));
+        auto sle = std::make_shared(pdKeylet);
+
+        sle->setAccountID(sfOwner, a1);
+        sle->setFieldU32(sfSequence, seq);
+
+        if (numCreds != 0u)
+        {
+            // This array is sorted naturally, but if you are going to change
+            // this behavior, don't forget to use credentials::makeSorted
+            STArray credentials(sfAcceptedCredentials, numCreds);
+            for (std::size_t n = 0; n < numCreds; ++n)
+            {
+                auto cred = STObject::makeInnerObject(sfCredential);
+                cred.setAccountID(sfIssuer, a2);
+                auto credType = "cred_type" + std::to_string(n);
+                cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                credentials.pushBack(std::move(cred));
+            }
+            sle->setFieldArray(sfAcceptedCredentials, credentials);
+        }
+
+        ac.view().insert(sle);
+        return sle;
+    }
+
+    static std::pair
+    createPermissionedDomainEnv(
+        test::jtx::Env& env,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::uint32_t numCreds = 2)
+    {
+        using namespace test::jtx;
+
+        pdomain::Credentials credentials;
+
+        for (std::size_t n = 0; n < numCreds; ++n)
+        {
+            auto credType = "cred_type" + std::to_string(n);
+            credentials.push_back({.issuer = a2, .credType = credType});
+        }
+
+        std::uint32_t const seq = env.seq(a1);
+        env(pdomain::setTx(a1, credentials));
+        uint256 const key = pdomain::getNewDomain(env.meta());
+
+        return {seq, key};
+    }
+
+    void
+    run() override
+    {
+        testPermissionedDomainInvariants(all_);
+        testPermissionedDomainInvariants(all_ - fixCleanup3_1_3);
+        testPermissionedDEX(all_);
+        testPermissionedDEX(all_ - fixCleanup3_1_3);
+        testPermissionedDEXDeletedOfferFallback();
+        testBookDirectoryExchangeRate();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsPermissioned, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp
new file mode 100644
index 0000000000..6c8a710bef
--- /dev/null
+++ b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp
@@ -0,0 +1,744 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsPseudoAccount_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testValidPseudoAccounts()
+    {
+        testcase << "valid pseudo accounts";
+
+        using namespace jtx;
+
+        AccountID pseudoAccountID;
+        Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) {
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+            // Create vault
+            Vault const vault{env};
+            auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset});
+            env(tx);
+            env.close();
+            if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle))
+            {
+                pseudoAccountID = vSle->at(sfAccount);
+            }
+
+            return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID)));
+        };
+
+        /* Cases to check
+            "pseudo-account has 0 pseudo-account fields set"
+            "pseudo-account has 2 pseudo-account fields set"
+            "pseudo-account sequence changed"
+            "pseudo-account flags are not set"
+            "pseudo-account has a regular key"
+            "pseudo-account has a sponsorship field"
+        */
+        struct Mod
+        {
+            std::string expectedFailure;
+            std::function func;
+        };
+        auto const mods = std::to_array({
+            {
+                .expectedFailure = "pseudo-account has 0 pseudo-account fields set",
+                .func =
+                    [this](SLE::pointer& sle) {
+                        BEAST_EXPECT(sle->at(~sfVaultID));
+                        sle->at(~sfVaultID) = std::nullopt;
+                    },
+            },
+            {
+                .expectedFailure = "pseudo-account sequence changed",
+                .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; },
+            },
+            {
+                .expectedFailure = "pseudo-account flags are not set",
+                .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a regular key",
+                .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); },
+            },
+        });
+
+        for (auto const& mod : mods)
+        {
+            doInvariantCheck(
+                {{mod.expectedFailure}},
+                [&](Account const& a1, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
+                    if (!sle)
+                        return false;
+                    mod.func(sle);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createPseudo);
+        }
+        for (auto const pField : getPseudoAccountFields())
+        {
+            // createPseudo creates a vault, so sfVaultID will be set, and
+            // setting it again will not cause an error
+            if (pField == &sfVaultID)
+                continue;
+            doInvariantCheck(
+                {{"pseudo-account has 2 pseudo-account fields set"}},
+                [&](Account const& a1, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
+                    if (!sle)
+                        return false;
+
+                    auto const vaultID = ~sle->at(~sfVaultID);
+                    BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField));
+                    sle->setFieldH256(*pField, *vaultID);
+
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createPseudo);
+        }
+
+        // Take one of the regular accounts and set the sequence to 0, which
+        // will make it look like a pseudo-account
+        doInvariantCheck(
+            {{"pseudo-account has 0 pseudo-account fields set"},
+             {"pseudo-account sequence changed"},
+             {"pseudo-account flags are not set"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                sle->at(sfSequence) = 0;
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testValidLoanBroker()
+    {
+        testcase << "valid loan broker";
+
+        using namespace jtx;
+
+        enum class Asset { XRP, IOU, MPT };
+        auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT});
+
+        for (auto const assetType : assetTypes)
+        {
+            // Initialize with a placeholder value because there's no default
+            // ctor
+            auto const setupAsset =
+                [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset {
+                switch (assetType)
+                {
+                    case Asset::IOU: {
+                        PrettyAsset const iouAsset = issuer["IOU"];
+                        env(trust(alice, iouAsset(1000)));
+                        env(pay(issuer, alice, iouAsset(1000)));
+                        env.close();
+                        return iouAsset;
+                    }
+                    case Asset::MPT: {
+                        MPTTester mptt{env, issuer, kMptInitNoFund};
+                        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                        PrettyAsset const mptAsset = mptt.issuanceID();
+                        mptt.authorize({.account = alice});
+                        env(pay(issuer, alice, mptAsset(1000)));
+                        env.close();
+                        return mptAsset;
+                    }
+                    case Asset::XRP:
+                    default:
+                        return PrettyAsset{xrpIssue(), 1'000'000};
+                }
+            };
+
+            Keylet loanBrokerKeylet = keylet::amendments();
+            Preclose const createLoanBroker =
+                [&, this](Account const& alice, Account const& issuer, Env& env) {
+                    auto const asset = setupAsset(alice, issuer, env);
+                    loanBrokerKeylet = this->createLoanBroker(alice, env, asset);
+                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
+                };
+
+            // Ensure the test scenarios are set up completely. The test cases
+            // will need to recompute any of these values it needs for itself
+            // rather than trying to return a bunch of items
+            auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac)
+                -> std::optional> {
+                if (loanBrokerKeylet.type != ltLOAN_BROKER)
+                    return {};
+                auto sleBroker = ac.view().peek(loanBrokerKeylet);
+                if (!sleBroker)
+                    return {};
+                if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0))
+                    return {};
+                // Need to touch sleBroker so that it is included in the
+                // modified entries for the invariant to find
+                ac.view().update(sleBroker);
+
+                // The pseudo-account holds the directory, so get it
+                auto const pseudoAccountID = sleBroker->at(sfAccount);
+                auto const pseudoAccountKeylet = keylet::account(pseudoAccountID);
+                // Strictly speaking, we don't need to load the
+                // ACCOUNT_ROOT, but check anyway
+                auto slePseudo = ac.view().peek(pseudoAccountKeylet);
+                if (!BEAST_EXPECT(slePseudo))
+                    return {};
+                // Make sure the directory doesn't already exist
+                auto const dirKeylet = keylet::ownerDir(pseudoAccountID);
+                auto sleDir = ac.view().peek(dirKeylet);
+                auto const describe = describeOwnerDir(pseudoAccountID);
+                if (!sleDir)
+                {
+                    // Create the directory
+                    BEAST_EXPECT(
+                        ::xrpl::directory::createRoot(
+                            ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0);
+
+                    sleDir = ac.view().peek(dirKeylet);
+                }
+
+                return std::make_pair(slePseudo, sleDir);
+            };
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has multiple directory "
+                  "pages"}},
+                [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
+
+                    BEAST_EXPECT(
+                        ::xrpl::directory::insertPage(
+                            ac.view(),
+                            0,
+                            sleDir,
+                            0,
+                            sleDir,
+                            slePseudo->key(),
+                            keylet::page(sleDir->key(), 0),
+                            describe) == 1);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has multiple indexes in "
+                  "the Directory root"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto indexes = sleDir->getFieldV256(sfIndexes);
+
+                    // Put some extra garbage into the directory
+                    for (auto const& key : {slePseudo->key(), sleDir->key()})
+                    {
+                        ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
+                    }
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker directory corrupt"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
+                    // Empty vector will overwrite the existing entry for the
+                    // holding, if any, avoiding the "has multiple indexes"
+                    // failure.
+                    STVector256 indexes;
+
+                    // Put one meaningless key into the directory
+                    auto const key = keylet::account(Account("random").id()).key;
+                    ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has an unexpected entry in "
+                  "the directory"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    // Empty vector will overwrite the existing entry for the
+                    // holding, if any, avoiding the "has multiple indexes"
+                    // failure.
+                    STVector256 indexes;
+
+                    ::xrpl::directory::insertKey(
+                        ac.view(), sleDir, 0, false, indexes, slePseudo->key());
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker sequence number decreased"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    if (loanBrokerKeylet.type != ltLOAN_BROKER)
+                        return false;
+                    auto sleBroker = ac.view().peek(loanBrokerKeylet);
+                    if (!sleBroker)
+                        return false;
+                    if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0))
+                        return false;
+                    // Need to touch sleBroker so that it is included in the
+                    // modified entries for the invariant to find
+                    ac.view().update(sleBroker);
+
+                    sleBroker->at(sfLoanSequence) -= 1;
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            // Test: cover available less than pseudo-account asset balance
+            {
+                Keylet brokerKeylet = keylet::amendments();
+                Preclose const createBrokerWithCover =
+                    [&, this](Account const& alice, Account const& issuer, Env& env) {
+                        auto const asset = setupAsset(alice, issuer, env);
+                        brokerKeylet = this->createLoanBroker(alice, env, asset);
+                        if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                            return false;
+                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
+                        env.close();
+                        return BEAST_EXPECT(env.le(brokerKeylet));
+                    };
+
+                doInvariantCheck(
+                    {{"Loan Broker cover available is less than pseudo-account asset balance"}},
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(brokerKeylet);
+                        if (!BEAST_EXPECT(sle))
+                            return false;
+                        // Pseudo-account holds 10 units, set cover to 5
+                        sle->at(sfCoverAvailable) = Number(5);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createBrokerWithCover);
+            }
+
+            // Test: cover available greater than pseudo-account asset balance
+            // (requires fixCleanup3_1_3)
+            doInvariantCheck(
+                {{"Loan Broker cover available is greater than pseudo-account asset balance"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(loanBrokerKeylet);
+                    if (!BEAST_EXPECT(sle))
+                        return false;
+                    // Pseudo-account has no cover deposited; set cover
+                    // higher than any incidental balance
+                    sle->at(sfCoverAvailable) = Number(1'000'000);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            // Deleting the IOU holding while leaving the broker unchanged must
+            // still expose CoverAvailable exceeding the now-zero balance: the
+            // broker is discovered through the deleted trust line. XRP has no
+            // holding SLE, while deleting an MPToken triggers other invariants,
+            // so IOU isolates this check. Verify that fixCleanup3_1_3 gates it
+            // by expecting failure only when the amendment is enabled.
+            if (assetType == Asset::IOU)
+            {
+                Keylet brokerKeylet = keylet::amendments();
+                Preclose const createBrokerWithCover =
+                    [&, this](Account const& alice, Account const& issuer, Env& env) {
+                        auto const asset = setupAsset(alice, issuer, env);
+                        brokerKeylet = this->createLoanBroker(alice, env, asset);
+                        if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                            return false;
+                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
+                        env.close();
+                        return BEAST_EXPECT(env.le(brokerKeylet));
+                    };
+
+                Precheck const deleteHolding =
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        if (brokerKeylet.type != ltLOAN_BROKER)
+                            return false;
+                        // Read (don't touch) the broker so it is only found via
+                        // the deleted holding, not as a modified entry.
+                        auto const sleBroker = ac.view().read(brokerKeylet);
+                        if (!BEAST_EXPECT(sleBroker))
+                            return false;
+                        auto const pseudoAccountID = sleBroker->at(sfAccount);
+
+                        // Erase every holding in the pseudo-account directory
+                        // and the directory root itself, mirroring a bug that
+                        // removed the cover holding without zeroing
+                        // CoverAvailable. Removing the root also keeps the
+                        // zero-OwnerCount directory check from firing first.
+                        auto sleDir = ac.view().peek(keylet::ownerDir(pseudoAccountID));
+                        if (!BEAST_EXPECT(sleDir))
+                            return false;
+                        for (auto const& index : sleDir->getFieldV256(sfIndexes))
+                        {
+                            if (auto holding = ac.view().peek(keylet::unchecked(index)))
+                            {
+                                ac.view().erase(holding);
+                            }
+                        }
+                        ac.view().erase(sleDir);
+                        return true;
+                    };
+
+                // With fixCleanup3_1_3: the invariant fires.
+                doInvariantCheck(
+                    makeEnv(all_),
+                    {{"Loan Broker cover available is greater than pseudo-account asset balance"}},
+                    deleteHolding,
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createBrokerWithCover);
+
+                // Without fixCleanup3_1_3: the same state is silently accepted.
+                doInvariantCheck(
+                    makeEnv(all_ - fixCleanup3_1_3),
+                    {},
+                    deleteHolding,
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject&) {}},
+                    {tesSUCCESS, tesSUCCESS},
+                    createBrokerWithCover);
+            }
+
+            // A LoanBroker may only be removed by ttLOAN_BROKER_DELETE. Erase
+            // the broker in the apply view under a non-delete tx type and
+            // expect the deletion-tx invariant to fire.
+            doInvariantCheck(
+                {{"Loan Broker deleted by a transaction other than LoanBrokerDelete"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    if (loanBrokerKeylet.type != ltLOAN_BROKER)
+                        return false;
+                    auto sleBroker = ac.view().peek(loanBrokerKeylet);
+                    if (!BEAST_EXPECT(sleBroker))
+                        return false;
+                    ac.view().erase(sleBroker);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+        }
+
+        // A LoanBrokerDelete must not remove a broker whose pre-transaction
+        // DebtTotal is non-zero. visitEntry captures `before` from the parent
+        // view, so the DebtTotal must be seeded in the OpenView before the
+        // ApplyContext is constructed; a Precheck modification would only
+        // land in the applyView (visible as `after`) and would leave `before`
+        // at the createLoanBroker-produced zero.
+        {
+            Env env{*this};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+            auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
+            if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                return;
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            // Seed a non-zero DebtTotal in the base view so `before` at
+            // visitEntry time reports it.
+            {
+                auto const sleBrokerRead = ov.read(brokerKeylet);
+                if (!BEAST_EXPECT(sleBrokerRead))
+                    return;
+                auto sleBroker = std::make_shared(*sleBrokerRead);
+                sleBroker->at(sfDebtTotal) = Number(1);
+                ov.rawReplace(sleBroker);
+            }
+
+            STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sleBroker = ac.view().peek(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return;
+            ac.view().erase(sleBroker);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tecINVARIANT_FAILED);
+            BEAST_EXPECT(
+                sink.messages().str().contains("Loan Broker deleted with non-zero debt total"));
+        }
+
+        // Residual DebtTotal dust that rounds to zero at the vault asset's
+        // scale must not trip the invariant: LoanBrokerDelete::preclaim
+        // deliberately permits it, so the invariant must not be stricter.
+        // Other invariants may still object to a hand-erased broker, so only
+        // the absence of the DebtTotal complaint is asserted.
+        {
+            Env env{*this};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+            auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
+            if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                return;
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            // A thousandth of a drop: non-zero, but zero once quantized to XRP.
+            {
+                auto const sleBrokerRead = ov.read(brokerKeylet);
+                if (!BEAST_EXPECT(sleBrokerRead))
+                    return;
+                auto sleBroker = std::make_shared(*sleBrokerRead);
+                sleBroker->at(sfDebtTotal) = Number(1, -3);
+                ov.rawReplace(sleBroker);
+            }
+
+            STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sleBroker = ac.view().peek(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return;
+            ac.view().erase(sleBroker);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            [[maybe_unused]] TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(
+                !sink.messages().str().contains("Loan Broker deleted with non-zero debt total"));
+        }
+
+        // A LoanBrokerDelete must not remove a broker whose pre-transaction
+        // OwnerCount is non-zero. DebtTotal is left at zero so the earlier
+        // check passes and the OwnerCount check is what fires.
+        {
+            Env env{*this};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+            auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
+            if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                return;
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            {
+                auto const sleBrokerRead = ov.read(brokerKeylet);
+                if (!BEAST_EXPECT(sleBrokerRead))
+                    return;
+                auto sleBroker = std::make_shared(*sleBrokerRead);
+                sleBroker->at(sfOwnerCount) = 1;
+                ov.rawReplace(sleBroker);
+            }
+
+            STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sleBroker = ac.view().peek(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return;
+            ac.view().erase(sleBroker);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tecINVARIANT_FAILED);
+            BEAST_EXPECT(
+                sink.messages().str().contains("Loan Broker deleted with non-zero owner count"));
+        }
+
+        // Only one LoanBroker may be deleted per transaction. Create two
+        // brokers under different owners, then erase both in the apply view
+        // and expect the multi-deletion invariant to fire.
+        {
+            Keylet loanBrokerKeylet1 = keylet::amendments();
+            Keylet loanBrokerKeylet2 = keylet::amendments();
+            Preclose const createTwoBrokers = [&, this](
+                                                  Account const& a1, Account const& a2, Env& env) {
+                PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                loanBrokerKeylet1 = this->createLoanBroker(a1, env, xrpAsset);
+                loanBrokerKeylet2 = this->createLoanBroker(a2, env, xrpAsset);
+                return BEAST_EXPECT(env.le(loanBrokerKeylet1) && env.le(loanBrokerKeylet2));
+            };
+
+            doInvariantCheck(
+                {{"more than one Loan Broker deleted in a single transaction"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle1 = ac.view().peek(loanBrokerKeylet1);
+                    auto sle2 = ac.view().peek(loanBrokerKeylet2);
+                    if (!BEAST_EXPECT(sle1 && sle2))
+                        return false;
+                    ac.view().erase(sle1);
+                    ac.view().erase(sle2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createTwoBrokers);
+        }
+    }
+
+    void
+    run() override
+    {
+        testValidPseudoAccounts();
+        testValidLoanBroker();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsPseudoAccount, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsTrustLine_test.cpp b/src/test/app/invariants/InvariantsTrustLine_test.cpp
new file mode 100644
index 0000000000..e0995fc431
--- /dev/null
+++ b/src/test/app/invariants/InvariantsTrustLine_test.cpp
@@ -0,0 +1,237 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsTrustLine_test : public InvariantsBase
+{
+    void
+    testNoXRPTrustLine()
+    {
+        using namespace test::jtx;
+        testcase << "trust lines with XRP not allowed";
+        doInvariantCheck(
+            {{"an XRP trust line was created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create simple trust SLE with xrp currency
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency));
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testNoDeepFreezeTrustLinesWithoutFreeze()
+    {
+        using namespace test::jtx;
+        testcase << "trust lines with deep freeze flag without freeze "
+                    "not allowed";
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze | lsfHighFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowFreeze | lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testTransfersNotFrozen()
+    {
+        using namespace test::jtx;
+        testcase << "transfers when frozen";
+
+        Account const g1{"G1"};
+        // Helper function to establish the trustlines
+        auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) {
+            // Preclose callback to establish trust lines with gateway
+            env.fund(XRP(1000), g1);
+
+            env.trust(g1["USD"](10000), a1);
+            env.trust(g1["USD"](10000), a2);
+            env.close();
+
+            env(pay(g1, a1, g1["USD"](1000)));
+            env(pay(g1, a2, g1["USD"](1000)));
+            env.close();
+
+            return true;
+        };
+
+        auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
+            createTrustlines(a1, a2, env);
+            env(trust(g1, a1["USD"](10000), tfSetFreeze));
+            env.close();
+
+            return true;
+        };
+
+        auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
+            a1FrozenByIssuer(a1, a2, env);
+            env(trust(g1, a1["USD"](10000), tfSetDeepFreeze));
+            env.close();
+
+            return true;
+        };
+
+        auto const changeBalances = [&](Account const& a1,
+                                        Account const& a2,
+                                        ApplyContext& ac,
+                                        int a1Balance,
+                                        int a2Balance) {
+            auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"]));
+            auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"]));
+
+            sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance));
+            sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance));
+
+            ac.view().update(sleA1);
+            ac.view().update(sleA2);
+        };
+
+        // test: imitating frozen A1 making a payment to A2.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -900, -1100);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1FrozenByIssuer);
+
+        // test: imitating deep frozen A1 making a payment to A2.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -900, -1100);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1DeepFrozenByIssuer);
+
+        // test: imitating A2 making a payment to deep frozen A1.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -1100, -900);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1DeepFrozenByIssuer);
+    }
+
+    void
+    run() override
+    {
+        testNoXRPTrustLine();
+        testNoDeepFreezeTrustLinesWithoutFreeze();
+        testTransfersNotFrozen();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsTrustLine, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
new file mode 100644
index 0000000000..dcf783a1b5
--- /dev/null
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -0,0 +1,3206 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsVault_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testVault()  // NOLINT(readability-function-size)
+    {
+        using namespace test::jtx;
+
+        struct AccountAmount
+        {
+            AccountID account;
+            int amount;
+        };
+        // Parameters for a synthetic loan object created alongside a vault
+        // adjustment. The interest due booked to the vault is
+        // totalValueOutstanding - principalOutstanding - managementFeeOutstanding.
+        struct LoanParams
+        {
+            int principalOutstanding = 0;
+            int totalValueOutstanding = 0;
+            int managementFeeOutstanding = 0;
+            AccountID borrower = beast::kZero;
+            // Broker the created loan references. Left unset when the test does
+            // not depend on the broker resolving to a real ledger entry.
+            uint256 brokerKey = beast::kZero;
+        };
+        struct Adjustments
+        {
+            // NOLINTBEGIN(readability-redundant-member-init)
+            std::optional assetsTotal = std::nullopt;
+            std::optional assetsAvailable = std::nullopt;
+            std::optional lossUnrealized = std::nullopt;
+            std::optional assetsMaximum = std::nullopt;
+            std::optional sharesTotal = std::nullopt;
+            std::optional vaultAssets = std::nullopt;
+            std::optional accountAssets = std::nullopt;
+            std::optional accountShares = std::nullopt;
+            std::optional createLoan = std::nullopt;
+            // Number of loan objects to create (only used when createLoan is
+            // set); a valid loan set creates exactly one.
+            int loanCount = 1;
+            // NOLINTEND(readability-redundant-member-init)
+        };
+        constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
+            // Avoid uint64 + negative-int wrap (flagged by UBSan
+            // unsigned-integer-overflow) when adjusting UINT64 fields.
+            auto const addSigned = [](std::uint64_t current, int adj) -> std::uint64_t {
+                return adj >= 0  //
+                    ? current + static_cast(adj)
+                    : current - static_cast(-adj);
+            };
+            auto sleVault = ac.peek(keylet);
+            if (!sleVault)
+                return false;
+
+            auto const mptIssuanceID = (*sleVault)[sfShareMPTID];
+            auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID));
+            if (!sleShares)
+                return false;
+
+            // These two fields are adjusted in absolute terms
+            if (args.lossUnrealized)
+                (*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
+            if (args.assetsMaximum)
+                (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
+
+            // Remaining fields are adjusted in terms of difference
+            if (args.assetsTotal)
+                (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal;
+            if (args.assetsAvailable)
+            {
+                (*sleVault)[sfAssetsAvailable] =
+                    *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable;
+            }
+            ac.update(sleVault);
+
+            if (args.sharesTotal)
+            {
+                (*sleShares)[sfOutstandingAmount] =
+                    addSigned(*(*sleShares)[sfOutstandingAmount], *args.sharesTotal);
+                ac.update(sleShares);
+            }
+
+            auto const assets = *(*sleVault)[sfAsset];
+            auto const pseudoId = *(*sleVault)[sfAccount];
+            if (args.vaultAssets)
+            {
+                if (assets.native())
+                {
+                    auto slePseudoAccount = ac.peek(keylet::account(pseudoId));
+                    if (!slePseudoAccount)
+                        return false;
+                    (*slePseudoAccount)[sfBalance] =
+                        *(*slePseudoAccount)[sfBalance] + *args.vaultAssets;
+                    ac.update(slePseudoAccount);
+                }
+                else if (assets.holds())
+                {
+                    auto const mptId = assets.get().getMptID();
+                    auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId));
+                    if (!sleMPToken)
+                        return false;
+                    (*sleMPToken)[sfMPTAmount] =
+                        addSigned(*(*sleMPToken)[sfMPTAmount], *args.vaultAssets);
+                    ac.update(sleMPToken);
+                }
+                else
+                {
+                    return false;  // Not supporting testing with IOU
+                }
+            }
+
+            if (args.accountAssets)
+            {
+                auto const& pair = *args.accountAssets;
+                if (assets.native())
+                {
+                    auto sleAccount = ac.peek(keylet::account(pair.account));
+                    if (!sleAccount)
+                        return false;
+                    (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount;
+                    ac.update(sleAccount);
+                }
+                else if (assets.holds())
+                {
+                    auto const mptID = assets.get().getMptID();
+                    auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account));
+                    if (!sleMPToken)
+                        return false;
+                    (*sleMPToken)[sfMPTAmount] =
+                        addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
+                    ac.update(sleMPToken);
+                }
+                else
+                {
+                    return false;  // Not supporting testing with IOU
+                }
+            }
+
+            if (args.accountShares)
+            {
+                auto const& pair = *args.accountShares;
+                auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account));
+                if (!sleMPToken)
+                    return false;
+                (*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
+                ac.update(sleMPToken);
+            }
+
+            if (args.createLoan)
+            {
+                auto const& lp = *args.createLoan;
+                bool const anyOutstanding = lp.principalOutstanding != 0 ||
+                    lp.totalValueOutstanding != 0 || lp.managementFeeOutstanding != 0;
+                // The vault key stands in for an unset broker: it keeps the loan
+                // keylet distinct per vault while resolving to no broker.
+                uint256 const brokerKey = lp.brokerKey != beast::kZero ? lp.brokerKey : keylet.key;
+                for (std::uint32_t seq = 1; seq <= static_cast(args.loanCount);
+                     ++seq)
+                {
+                    auto sleLoan = makeLoanSle(brokerKey, seq, lp.borrower);
+                    sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding);
+                    sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding);
+                    sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0);
+                    ac.insert(sleLoan);
+                }
+            }
+            return true;
+        };
+
+        static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments {
+            Adjustments sample = {
+                .assetsTotal = adjustment,
+                .assetsAvailable = adjustment,
+                .lossUnrealized = 0,
+                .sharesTotal = adjustment,
+                .vaultAssets = adjustment,
+                .accountAssets =  //
+                AccountAmount{.account = id, .amount = -adjustment},
+                .accountShares =  //
+                AccountAmount{.account = id, .amount = adjustment}};
+            fn(sample);
+            return sample;
+        };
+
+        Account const a3{"A3"};
+        Account const a4{"A4"};
+        auto const precloseXrp = [&](Account const& a1,
+                                     Account const& a2,
+                                     Env& env,
+                                     VaultVersion version = VaultVersion::CashBasis) -> bool {
+            env.fund(XRP(1000), a3, a4);
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+            env(tx);
+            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+            env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+            return true;
+        };
+
+        auto const createClosedXrpBroker =
+            [&](Account const& owner, Env& env) -> std::optional> {
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+            auto const brokerKeylet = createLoanBroker(owner, env, xrpAsset);
+            auto const sleBroker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return std::nullopt;
+            auto const vaultKeylet = keylet::vault(sleBroker->at(sfVaultID));
+            env.close(std::chrono::seconds{61});
+            return std::pair{vaultKeylet, brokerKeylet};
+        };
+
+        testcase << "Vault general checks";
+        doInvariantCheck(
+            {"vault deletion succeeded without deleting a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+                sleVault->setAccountID(sfAccount, a1.id());
+                ac.view().insert(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"vault deleted by a wrong transaction type",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation updated more than single vault",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                {
+                    auto const keylet =
+                        keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                    auto sleVault = ac.view().peek(keylet);
+                    if (!sleVault)
+                        return false;
+                    ac.view().erase(sleVault);
+                }
+                {
+                    auto const keylet =
+                        keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq()));
+                    auto sleVault = ac.view().peek(keylet);
+                    if (!sleVault)
+                        return false;
+                    ac.view().erase(sleVault);
+                }
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                {
+                    auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                    env(tx);
+                }
+                {
+                    auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()});
+                    env(tx);
+                }
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation updated more than single vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const insertVault = [&](Account const a) {
+                    auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence));
+                    auto sleVault = std::make_shared(vaultKeylet);
+                    auto const vaultPage = ac.view().dirInsert(
+                        keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id()));
+                    sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+                    sleVault->setAccountID(sfAccount, a.id());
+                    ac.view().insert(sleVault);
+                };
+                insertVault(a1);
+                insertVault(a2);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"deleted vault must also delete shares",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"deleted vault must have no shares outstanding",
+             "deleted vault must have no assets outstanding",
+             "deleted vault must have no assets available"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().erase(sleVault);
+                ac.view().erase(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                // Note, such an "orphaned" update of MPT issuance attached to a
+                // vault is invalid; ttVAULT_SET must also update Vault object.
+                sleShares->setFieldH256(sfDomainID, uint256(13));
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"updated vault must have shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsMaximum] = 200;
+                ac.view().update(sleVault);
+
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().erase(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without updating shares",
+             "assets available must not be greater than assets outstanding"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsTotal] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                return true;
+            });
+
+        doInvariantCheck(
+            {"set must not change assets outstanding",
+             "set must not change assets available",
+             "set must not change shares outstanding",
+             "set must not change vault balance",
+             "assets available must not be negative",
+             "assets available must not be greater than assets outstanding",
+             "assets outstanding must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount]));
+                if (!slePseudoAccount)
+                    return false;
+                (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10;
+                ac.view().update(slePseudoAccount);
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsAvailable = (kDropsPerXrp * -100).value();
+                                   sample.assetsTotal = (kDropsPerXrp * -200).value();
+                                   sample.sharesTotal = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // Under featureLendingProtocolV1_1 the immutability of sfAsset, sfAccount,
+        // sfShareMPTID and sfLEVersion is enforced by NoModifiedUnmodifiableFields.
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setAccountID(sfAccount, a2.id());
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfShareMPTID] = MPTID(42);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfLEVersion] = std::to_underlying(VaultVersion::Legacy);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&precloseXrp](Account const& a1, Account const& a2, Env& env) {
+                return precloseXrp(a1, a2, env, VaultVersion::CashBasis);
+            });
+
+        // Pre-featureLendingProtocolV1_1 sfAsset, sfAccount and sfShareMPTID are
+        // guarded by ValidVault instead, so both paths need coverage. ValidVault
+        // returns early once the result is already tec, hence no escalation to
+        // tef on the second pass.
+        auto const preLendingV11Amendments = all_ - featureLendingProtocolV1_1;
+        doInvariantCheck(
+            makeEnv(preLendingV11Amendments),
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            makeEnv(preLendingV11Amendments),
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setAccountID(sfAccount, a2.id());
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            makeEnv(preLendingV11Amendments),
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfShareMPTID] = MPTID(42);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"vault transaction must not change loss unrealized",
+             "set must not change assets outstanding"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = 13;
+                                   sample.assetsTotal = 20;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"loss unrealized must not exceed the difference "
+             "between assets outstanding and available",
+             "vault transaction must not change loss unrealized"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = 13;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is
+        // allowed to change loss unrealized, so it isolates this check from the
+        // "must not change loss unrealized" invariant. Gated behind
+        // fixCleanup3_4_0 (see below).
+        doInvariantCheck(
+            {"loss unrealized must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // Without fixCleanup3_4_0 the same state must NOT trip the invariant,
+        // preserving pre-amendment behavior (no fork risk). Also remove
+        // featureLendingProtocolV1_1 so finalizeLoanManage's stricter checks
+        // (exactly one loan touched) do not fire from a bare vault mutation
+        // that does not touch a loan.
+        doInvariantCheck(
+            makeEnv(all_ - fixCleanup3_4_0 - featureLendingProtocolV1_1),
+            {},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
+            {tesSUCCESS, tesSUCCESS},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"set assets outstanding must not exceed assets maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = 1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // The cap check has two post-fixCleanup3_4_0 triggers: the transaction
+        // supplied sfAssetsMaximum, or the cap changed. The case above covers
+        // the cap-changed one (its ttVAULT_SET carries no fields). This covers
+        // the other: the cap is left alone at 30 XRP and the transaction
+        // carries sfAssetsMaximum, so only the isFieldPresent disjunct can
+        // fire. AssetsTotal is pushed past the cap here rather than in
+        // preclose because VaultSet::doApply refuses to set a cap below
+        // AssetsTotal, so the over-cap state is only reachable by fabrication.
+        // Raising AssetsTotal also trips the "must not change assets
+        // outstanding" check, hence two expected messages.
+        Number const vaultCap = XRP(30).number();
+        doInvariantCheck(
+            {"set must not change assets outstanding",
+             "set assets outstanding must not exceed assets maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsTotal = XRP(1).value().xrp().drops();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [&](STObject& tx) { tx[sfAssetsMaximum] = vaultCap; }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                env.fund(XRP(1000), a3, a4);
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                tx[sfAssetsMaximum] = vaultCap;
+                env(tx);
+                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+                env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+                return true;
+            },
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"assets maximum must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"set must not change shares outstanding",
+             "updated zero sized vault must have no assets outstanding",
+             "updated zero sized vault must have no assets available"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfOutstandingAmount] = 0;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"updated shares must not exceed maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfMaximumAmount] = 10;
+                ac.view().update(sleShares);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"updated shares must not exceed maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // ttLOAN_SET pre-featureLendingProtocolV1_1: finalizeLoanSet short-
+        // circuits and returns success without inspecting the loan or the
+        // vault. The same state that trips the principal-outstanding check
+        // under V1_1 must be silently accepted here.
+        doInvariantCheck(
+            makeEnv(all_ - featureLendingProtocolV1_1),
+            {},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(
+                    ac.view(),
+                    keylet,
+                    Adjustments{
+                        .assetsAvailable = -200,
+                        .vaultAssets = -200,
+                        .accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
+                        .createLoan = LoanParams{
+                            .principalOutstanding = 300,
+                            .totalValueOutstanding = 300,
+                            .borrower = a1.id(),
+                        }});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
+            {tesSUCCESS, tesSUCCESS},
+            precloseXrp);
+
+        // ttLOAN_MANAGE: a loan is created rather than modified. This object-
+        // existence rule applies on both invariant passes.
+        doInvariantCheck(
+            {"Loan created by a transaction other than LoanSet"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(
+                    ac.view(),
+                    keylet,
+                    Adjustments{
+                        .createLoan = LoanParams{
+                            .principalOutstanding = 100,
+                            .totalValueOutstanding = 100,
+                            .borrower = a1.id(),
+                        }});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        // ttLOAN_MANAGE: loss unrealized driven negative
+        doInvariantCheck(
+            {"loss unrealized must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // Loan flags may only change under the transaction types that own
+        // those transitions.
+        {
+            struct Case
+            {
+                std::uint32_t before;
+                std::uint32_t after;
+                std::string expected;
+            };
+            auto const cases = std::to_array({
+                {.before = 0,
+                 .after = lsfLoanImpaired,
+                 .expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
+                {.before = lsfLoanImpaired,
+                 .after = 0,
+                 .expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
+                {.before = 0,
+                 .after = lsfLoanDefault,
+                 .expected = "lsfLoanDefault changed outside LoanManage"},
+            });
+
+            for (auto const& c : cases)
+            {
+                Env env{*this, all_};
+                Account const a1{"A1"};
+                Account const a2{"A2"};
+                env.fund(XRP(1000), a1, a2);
+                auto const keys = createClosedXrpBroker(a1, env);
+                if (!keys)
+                    continue;
+                auto const& brokerKeylet = keys->second;
+
+                OpenView ov{*env.current()};
+                auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                {
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
+                    sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(150);
+                    sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                    sleLoan->setFieldU32(sfFlags, c.before);
+                    ov.rawInsert(sleLoan);
+                }
+
+                STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+                test::StreamSink sink{beast::Severity::Warning};
+                beast::Journal const jlog{sink};
+                ApplyContext ac{
+                    env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+                CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+                auto sleLoan = ac.view().peek(loanKeylet);
+                if (!BEAST_EXPECT(sleLoan))
+                    continue;
+                sleLoan->setFieldU32(sfFlags, c.after);
+                ac.view().update(sleLoan);
+
+                auto transactor = makeTransactor(ac);
+                if (!BEAST_EXPECT(transactor))
+                    continue;
+                TER const result = transactor->checkInvariants(
+                    tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                BEAST_EXPECT(result == tecINVARIANT_FAILED);
+                BEAST_EXPECT(sink.messages().str().contains(c.expected));
+            }
+        }
+
+        // ttLOAN_MANAGE (default): a defaulted loan atomically enters a
+        // terminal state, which drops sfNextPaymentDueDate from the ledger
+        // entry. Seed a loan that already carries lsfLoanDefault so the
+        // "must newly set" check passes, then leave sfNextPaymentDueDate
+        // present and non-zero on the after-image; the residual due-date
+        // check must then fire.
+        {
+            Env env{*this, all_};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            BEAST_EXPECT(precloseXrp(a1, a2, env));
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+            // Pre-insert a loan that is not yet defaulted but has a
+            // NextPaymentDueDate set; the apply-view mutation below flips
+            // lsfLoanDefault (so the "must newly set" check passes) while
+            // leaving the due date behind.
+            {
+                auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                sleLoan->setFieldU32(sfNextPaymentDueDate, 123);
+                ov.rawInsert(sleLoan);
+            }
+
+            STTx const tx{
+                ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sleLoan = ac.view().peek(loanKeylet);
+            if (!BEAST_EXPECT(sleLoan))
+                return;
+            sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
+            ac.view().update(sleLoan);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tecINVARIANT_FAILED);
+            BEAST_EXPECT(sink.messages().str().contains(
+                "Loan with zero payments must have zero next payment due date"));
+        }
+
+        // ttLOAN_PAY pre-featureLendingProtocolV1_1: finalizeLoanPay short-
+        // circuits and returns success. The same "no vault balance change"
+        // state that trips the check under V1_1 must be silently accepted
+        // here.
+        doInvariantCheck(
+            makeEnv(all_ - featureLendingProtocolV1_1),
+            {},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, Adjustments{});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tesSUCCESS, tesSUCCESS},
+            precloseXrp);
+
+        // ttLOAN_PAY: cash is credited to the vault and a loan is created
+        // rather than modified. This object-existence rule applies on both
+        // invariant passes.
+        doInvariantCheck(
+            {"Loan created by a transaction other than LoanSet"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(
+                    ac.view(),
+                    keylet,
+                    Adjustments{
+                        .assetsTotal = 50,
+                        .assetsAvailable = 50,
+                        .vaultAssets = 50,
+                        .accountAssets = AccountAmount{.account = a2.id(), .amount = -50},
+                        .createLoan = LoanParams{
+                            .principalOutstanding = 100,
+                            .totalValueOutstanding = 100,
+                            .borrower = a1.id(),
+                        }});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        // ttLOAN_PAY: loss unrealized driven negative. The cash inflow is
+        // valid, but loss unrealized is set below zero.
+        doInvariantCheck(
+            {"loss unrealized must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(
+                    ac.view(),
+                    keylet,
+                    Adjustments{
+                        .assetsTotal = 100,
+                        .assetsAvailable = 100,
+                        .lossUnrealized = -1,
+                        .vaultAssets = 100,
+                        .accountAssets = AccountAmount{.account = a2.id(), .amount = -100}});
+            },
+            XRPAmount{},
+            STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // ttLOAN_PAY success post-conditions. A loan left with payments still
+        // remaining after a successful payment must show that payment in its
+        // balance and schedule: PrincipalOutstanding and PaymentRemaining both
+        // strictly decrease, and NextPaymentDueDate advances by a positive
+        // multiple of PaymentInterval. Each case seeds the same loan, then applies
+        // an after-image that breaks exactly one of those conditions.
+        {
+            struct Case
+            {
+                Number principal;
+                std::uint32_t remaining;
+                std::uint32_t dueDate;
+                std::string expected;
+            };
+            auto const cases = std::to_array({
+                {.principal = Number(100),
+                 .remaining = 1,
+                 .dueDate = 110,
+                 .expected = "loan pay must strictly decrease PrincipalOutstanding"},
+                {.principal = Number(50),
+                 .remaining = 2,
+                 .dueDate = 110,
+                 .expected = "loan pay must decrease PaymentRemaining"},
+                {.principal = Number(50),
+                 .remaining = 1,
+                 .dueDate = 100,
+                 .expected = "loan pay must advance NextPaymentDueDate"},
+                // Advanced, but not by a whole number of payment intervals.
+                {.principal = Number(50),
+                 .remaining = 1,
+                 .dueDate = 105,
+                 .expected = "loan pay must advance NextPaymentDueDate"},
+            });
+
+            for (auto const& c : cases)
+            {
+                Env env{*this, all_};
+                Account const a1{"A1"};
+                Account const a2{"A2"};
+                env.fund(XRP(1000), a1, a2);
+                auto const keys = createClosedXrpBroker(a1, env);
+                if (!keys)
+                    continue;
+                auto const& brokerKeylet = keys->second;
+
+                OpenView ov{*env.current()};
+                auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                {
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                    sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(150);
+                    sleLoan->at(sfPaymentInterval) = 10u;
+                    sleLoan->setFieldU32(sfPaymentRemaining, 2);
+                    sleLoan->setFieldU32(sfNextPaymentDueDate, 100);
+                    ov.rawInsert(sleLoan);
+                }
+
+                STTx const tx{
+                    ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(50)); }};
+                test::StreamSink sink{beast::Severity::Warning};
+                beast::Journal const jlog{sink};
+                ApplyContext ac{
+                    env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+                CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+                auto sleLoan = ac.view().peek(loanKeylet);
+                if (!BEAST_EXPECT(sleLoan))
+                    continue;
+                sleLoan->at(sfPrincipalOutstanding) = c.principal;
+                sleLoan->setFieldU32(sfPaymentRemaining, c.remaining);
+                sleLoan->setFieldU32(sfNextPaymentDueDate, c.dueDate);
+                ac.view().update(sleLoan);
+
+                auto transactor = makeTransactor(ac);
+                if (!BEAST_EXPECT(transactor))
+                    continue;
+                TER const result = transactor->checkInvariants(
+                    tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                BEAST_EXPECT(result == tecINVARIANT_FAILED);
+                BEAST_EXPECT(sink.messages().str().contains(c.expected));
+            }
+        }
+
+        // ttLOAN_MANAGE (default): the write-off is rounded downward at the
+        // pre-default AssetsTotal scale. A near-total IOU default can leave
+        // valid positive dust while moving the posterior AssetsTotal to a much
+        // finer scale. The dust must be bounded by the former scale rather than
+        // compared with one unit at the posterior scale.
+        {
+            Env env{*this, all_ | featureLendingProtocolV1_1};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(1000), issuer, owner, borrower);
+            env.close();
+
+            PrettyAsset const iouAsset{issuer["IOU"]};
+            auto const brokerKeylet = createLoanBroker(owner, env, iouAsset);
+            auto const sleBrokerBase = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(sleBrokerBase))
+                return;
+            auto const vaultKeylet = keylet::vault(sleBrokerBase->at(sfVaultID));
+            env.close();
+
+            Number const assetsTotalBefore{1, 1};
+            Number const loanOwed{9'999'999'999'999'999LL, -15};
+            Number const assetsTotalAfter{1, -14};
+            auto const beforeScale = scale(assetsTotalBefore, iouAsset);
+            auto const afterScale = scale(assetsTotalAfter, iouAsset);
+            Number const residual = (assetsTotalAfter - assetsTotalBefore) - (-loanOwed);
+            Number const beforeTolerance{1, beforeScale};
+            Number const afterTolerance{1, afterScale};
+
+            BEAST_EXPECT(afterScale < beforeScale);
+            BEAST_EXPECT(residual > beast::kZero && residual < beforeTolerance);
+            BEAST_EXPECT(residual > afterTolerance);
+
+            OpenView ov{*env.current()};
+            {
+                auto const sleVaultRead = ov.read(vaultKeylet);
+                if (!BEAST_EXPECT(sleVaultRead))
+                    return;
+                auto sleVault = std::make_shared(*sleVaultRead);
+                sleVault->at(sfAssetsTotal) = assetsTotalBefore;
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                ov.rawReplace(sleVault);
+
+                auto const sharesKeylet = keylet::mptokenIssuance(sleVaultRead->at(sfShareMPTID));
+                auto const sleSharesRead = ov.read(sharesKeylet);
+                if (!BEAST_EXPECT(sleSharesRead))
+                    return;
+                auto sleShares = std::make_shared(*sleSharesRead);
+                sleShares->at(sfOutstandingAmount) = 1;
+                ov.rawReplace(sleShares);
+            }
+            {
+                auto const sleBrokerRead = ov.read(brokerKeylet);
+                if (!BEAST_EXPECT(sleBrokerRead))
+                    return;
+                auto sleBroker = std::make_shared(*sleBrokerRead);
+                sleBroker->at(sfDebtTotal) = loanOwed;
+                ov.rawReplace(sleBroker);
+            }
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+            {
+                auto sleLoan = makeLoanSle(brokerKeylet.key, 1, borrower.id());
+                sleLoan->at(sfPrincipalOutstanding) = loanOwed;
+                sleLoan->at(sfTotalValueOutstanding) = loanOwed;
+                sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                ov.rawInsert(sleLoan);
+            }
+
+            STTx const tx{
+                ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            {
+                auto sleVault = ac.view().peek(vaultKeylet);
+                if (!BEAST_EXPECT(sleVault))
+                    return;
+                sleVault->at(sfAssetsTotal) = assetsTotalAfter;
+                ac.view().update(sleVault);
+            }
+            {
+                auto sleBroker = ac.view().peek(brokerKeylet);
+                if (!BEAST_EXPECT(sleBroker))
+                    return;
+                sleBroker->at(sfDebtTotal) = Number(0);
+                ac.view().update(sleBroker);
+            }
+            {
+                auto sleLoan = ac.view().peek(loanKeylet);
+                if (!BEAST_EXPECT(sleLoan))
+                    return;
+                sleLoan->at(sfPrincipalOutstanding) = Number(0);
+                sleLoan->at(sfTotalValueOutstanding) = Number(0);
+                sleLoan->setFieldU32(sfPaymentRemaining, 0);
+                sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
+                ac.view().update(sleLoan);
+            }
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tesSUCCESS);
+        }
+
+        // A loan may only be deleted by a LoanDelete transaction, and only once
+        // it is fully paid off. Both branches are exercised by creating a real
+        // loan in the Preclose (so it exists in the base ledger with outstanding
+        // principal) and then erasing it in the Precheck.
+        {
+            Keylet loanKeylet = keylet::amendments();
+            auto const precloseLoan = [&loanKeylet, this](
+                                          Account const& a1, Account const& a2, Env& env) -> bool {
+                PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
+                auto const brokerSle = env.le(brokerKeylet);
+                if (!BEAST_EXPECT(brokerSle))
+                    return false;
+                auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
+                Vault const vault{env};
+                env(vault.deposit(
+                    {.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)}));
+                env.close(std::chrono::seconds{61});
+
+                loanKeylet = keylet::loan(
+                    brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+                env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()),
+                    loan::kCounterparty(a1),
+                    Sig(sfCounterpartySignature, a1),
+                    loan::kPaymentInterval(60),
+                    loan::kPaymentTotal(1),
+                    Fee(env.current()->fees().base * 2));
+                env.close();
+                return BEAST_EXPECT(env.le(loanKeylet));
+            };
+
+            auto const eraseLoan = [&loanKeylet](Account const&, Account const&, ApplyContext& ac) {
+                auto sle = ac.view().peek(loanKeylet);
+                if (!sle)
+                    return false;
+                ac.view().erase(sle);
+                return true;
+            };
+
+            // Deleting the loan under any transaction type other than LoanDelete
+            // (here the neutral ttACCOUNT_SET) is a violation, even while the
+            // loan still has outstanding obligations: the transaction-type check
+            // fires before the not-fully-paid-off check.
+            doInvariantCheck(
+                {"Loan deleted by a transaction other than LoanDelete"},
+                eraseLoan,
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                precloseLoan);
+        }
+
+        STTx const loanSetTx{
+            ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(0); }};
+
+        // Loan interest due (total value less principal and management fee) must
+        // never be negative. The loan below carries a total value short of its
+        // principal, while every individual field stays non-negative. A real
+        // broker over an XRP vault is created in the preclose, both so the
+        // earlier broker-existence checks pass and so the deficit is measured
+        // in an integral asset domain, where no rounding tolerance applies.
+        {
+            Keylet brokerKeylet = keylet::amendments();
+            auto const precloseBroker = [&brokerKeylet, this](
+                                            Account const& a1, Account const&, Env& env) -> bool {
+                PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                brokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
+                env.close();
+                return BEAST_EXPECT(env.le(brokerKeylet));
+            };
+
+            doInvariantCheck(
+                {"Loan interest due is negative"},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                    sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(90);
+                    sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                    ac.view().insert(sleLoan);
+                    return true;
+                },
+                XRPAmount{},
+                loanSetTx,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                precloseBroker);
+        }
+
+        // Each of these loan STNumber fields must never be negative. The loan
+        // is created directly with a single field set negative while the
+        // paid-off bookkeeping is kept consistent, so that only the "
+        // is negative" check trips.
+        for (auto const field : {
+                 &sfLoanServiceFee,
+                 &sfLatePaymentFee,
+                 &sfClosePaymentFee,
+                 &sfPrincipalOutstanding,
+                 &sfTotalValueOutstanding,
+                 &sfManagementFeeOutstanding,
+             })
+        {
+            // The outstanding-balance fields also feed the paid-off checks, so
+            // a loan carrying one must still have payments remaining; a loan
+            // with only a negative fee stays fully paid off (zero remaining).
+            bool const isOutstanding = *field == sfPrincipalOutstanding ||
+                *field == sfTotalValueOutstanding || *field == sfManagementFeeOutstanding;
+            doInvariantCheck(
+                {field->getName() + " is negative"},
+                [&, field](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                    sleLoan->at(*field) = Number(-10);
+                    sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0);
+                    ac.view().insert(sleLoan);
+                    return true;
+                },
+                XRPAmount{},
+                loanSetTx);
+        }
+
+        // Mirror of the loop above for the strictly-positive constraint: a
+        // loan's sfPeriodicPayment must always be > 0. Cover both boundary
+        // failure modes (zero and negative).
+        for (Number const& badValue : {Number(0), Number(-1)})
+        {
+            doInvariantCheck(
+                {std::string{sfPeriodicPayment.getName()} + " is zero or negative"},
+                [&, badValue](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
+                    auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                    sleLoan->at(sfPeriodicPayment) = badValue;
+                    ac.view().insert(sleLoan);
+                    return true;
+                },
+                XRPAmount{},
+                loanSetTx);
+        }
+
+        // A loan with sfPaymentRemaining == 0 must be fully paid off in every
+        // outstanding-balance dimension. Insert a bare loan that reports zero
+        // payments remaining but still carries a non-zero principal owed; the
+        // paid-off invariant must reject it before the later broker-existence
+        // check has a chance to run.
+        doInvariantCheck(
+            {"Loan with zero payments remaining has not been paid off"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
+                auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                sleLoan->at(sfTotalValueOutstanding) = Number(100);
+                sleLoan->at(sfPeriodicPayment) = Number(1);
+                sleLoan->setFieldU32(sfPaymentRemaining, 0);
+                ac.view().insert(sleLoan);
+                return true;
+            },
+            XRPAmount{},
+            loanSetTx);
+
+        // Converse: a loan whose outstanding balances are all zero has been
+        // fully paid off and must carry zero payments remaining. Insert a
+        // fully-zeroed loan with sfPaymentRemaining = 1 to trip the check.
+        doInvariantCheck(
+            {"Fully paid off Loan still has payments remaining"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
+                auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
+                sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                ac.view().insert(sleLoan);
+                return true;
+            },
+            XRPAmount{},
+            loanSetTx);
+
+        // A loan must reference a live loan broker. A bare loan SLE is
+        // inserted with every other loan-level field kept consistent so the
+        // earlier ValidLoan checks pass; sfLoanBrokerID defaults to zero,
+        // which resolves to no broker, and the broker-existence check trips.
+        doInvariantCheck(
+            {"Loan broker does not exist"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleLoan = makeLoanSle(uint256{}, 1, a2.id());
+                ac.view().insert(sleLoan);
+                return true;
+            },
+            XRPAmount{},
+            loanSetTx);
+
+        // A loan's broker must in turn reference a live vault. A real broker
+        // is created in the preclose so its sfVaultID points at an existing
+        // vault; the precheck then erases that vault and inserts a loan
+        // referencing the broker, so the broker-existence check passes and
+        // the broker-vault-existence check trips.
+        {
+            Keylet brokerKeylet = keylet::amendments();
+            auto const precloseBroker = [&brokerKeylet, this](
+                                            Account const& a1, Account const&, Env& env) -> bool {
+                PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                brokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
+                env.close();
+                return BEAST_EXPECT(env.le(brokerKeylet));
+            };
+
+            doInvariantCheck(
+                {"Loan broker vault does not exist"},
+                [&brokerKeylet](Account const&, Account const&, ApplyContext& ac) {
+                    auto sleBroker = ac.view().peek(brokerKeylet);
+                    if (!sleBroker)
+                        return false;
+                    auto sleVault = ac.view().peek(keylet::vault(sleBroker->at(sfVaultID)));
+                    if (!sleVault)
+                        return false;
+                    ac.view().erase(sleVault);
+
+                    auto const loanKeylet =
+                        keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+                    auto sleLoan = std::make_shared(loanKeylet);
+                    sleLoan->at(sfLoanBrokerID) = brokerKeylet.key;
+                    sleLoan->at(sfPrincipalOutstanding) = Number(0);
+                    sleLoan->at(sfTotalValueOutstanding) = Number(0);
+                    sleLoan->at(sfManagementFeeOutstanding) = Number(0);
+                    sleLoan->at(sfPeriodicPayment) = Number(1);
+                    sleLoan->setFieldU32(sfPaymentRemaining, 0);
+                    ac.view().insert(sleLoan);
+                    return true;
+                },
+                XRPAmount{},
+                loanSetTx,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                precloseBroker);
+        }
+
+        // ttVAULT_SET: owner is immutable (enforced by
+        // NoModifiedUnmodifiableFields under featureLendingProtocolV1_1.
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setAccountID(sfOwner, a2.id());
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        // ttVAULT_SET: withdrawal policy is immutable
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldU8(
+                    sfWithdrawalPolicy,
+                    static_cast(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        // ttVAULT_SET: scale is immutable
+        doInvariantCheck(
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldU8(
+                    sfScale, static_cast(sleVault->getFieldU8(sfScale) + 1));
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        // featureLendingProtocolV1_1 moves the vault immutability checks from VaultInvariant to
+        // InvariantCheck.
+        doInvariantCheck(
+            makeEnv(all_),
+            {"changed an unchangeable field"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldU8(
+                    sfWithdrawalPolicy,
+                    static_cast(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        testcase << "Vault create";
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "updated zero sized vault must have no assets outstanding",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsTotal] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "updated zero sized vault must have no assets available",
+                "assets available must not be greater than assets outstanding",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsAvailable] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "loss unrealized must not exceed the difference between assets "
+                "outstanding and available",
+                "vault transaction must not change loss unrealized",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfLossUnrealized] = 1;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "create operation must not have updated a vault",
+                "invalid OutstandingAmount balance 0 9 0",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().update(sleVault);
+                (*sleShares)[sfOutstandingAmount] = 9;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "assets maximum must not be negative",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsMaximum] = Number(-1);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"create operation must not have updated a vault",
+             "shares issuer and vault pseudo-account must be the same",
+             "shares issuer must be a pseudo-account",
+             "shares issuer pseudo-account must point back to the vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().update(sleVault);
+                (*sleShares)[sfIssuer] = a1.id();
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault created by a wrong transaction type", "account root created illegally"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // The code below will create a valid vault with (almost) all
+                // the invariants holding. Except one: it is created by the
+                // wrong transaction type.
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+                // Create pseudo-account.
+                auto sleAccount = std::make_shared(keylet::account(pseudoId));
+                sleAccount->setAccountID(sfAccount, pseudoId);
+                sleAccount->setFieldAmount(sfBalance, STAmount{});
+                std::uint32_t const seqno =                             //
+                    ac.view().rules().enabled(featureSingleAssetVault)  //
+                    ? 0                                                 //
+                    : sequence;
+                sleAccount->setFieldU32(sfSequence, seqno);
+                sleAccount->setFieldU32(
+                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+                ac.view().insert(sleAccount);
+
+                auto const sharesMptId = makeMptID(sequence, pseudoId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                sleShares->at(sfIssuer) = pseudoId;
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                sleVault->at(sfAccount) = pseudoId;
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"shares issuer and vault pseudo-account must be the same",
+             "shares issuer pseudo-account must point back to the vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+                // Create pseudo-account.
+                auto sleAccount = std::make_shared(keylet::account(pseudoId));
+                sleAccount->setAccountID(sfAccount, pseudoId);
+                sleAccount->setFieldAmount(sfBalance, STAmount{});
+                std::uint32_t const seqno =                             //
+                    ac.view().rules().enabled(featureSingleAssetVault)  //
+                    ? 0                                                 //
+                    : sequence;
+                sleAccount->setFieldU32(sfSequence, seqno);
+                sleAccount->setFieldU32(
+                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+                // Setting wrong vault key
+                sleAccount->setFieldH256(sfVaultID, uint256(42));
+                ac.view().insert(sleAccount);
+
+                auto const sharesMptId = makeMptID(sequence, pseudoId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                sleShares->at(sfIssuer) = pseudoId;
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                // sleVault->at(sfAccount) = pseudoId;
+                // Setting wrong pseudo account ID
+                sleVault->at(sfAccount) = a2.id();
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto const sharesMptId = makeMptID(sequence, a2.id());
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id()));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                // Setting wrong pseudo account ID
+                sleShares->at(sfIssuer) = AccountID(42);
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                sleVault->at(sfAccount) = a2.id();
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        testcase << "Vault deposit";
+        doInvariantCheck(
+            {"deposit must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"deposit assets outstanding must not exceed assets maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = 1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // This really convoluted unit tests makes the zero balance on the
+        // depositor, by sending them the same amount as the transaction fee.
+        // The operation makes no sense, but the defensive check in
+        // ValidVault::finalize is otherwise impossible to trigger.
+        doInvariantCheck(
+            {"deposit must increase vault balance", "deposit must change depositor balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = -100;
+                               }));
+            },
+            XRPAmount{100},
+            STTx{
+                ttVAULT_DEPOSIT,
+                [&](STObject& tx) {
+                    tx[sfFee] = XRPAmount(100);
+                    tx[sfAccount] = a3.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"deposit must increase vault balance",
+             "deposit must decrease depositor balance",
+             "deposit must change vault and depositor balance by equal amount",
+             "deposit and assets outstanding must add up",
+             "deposit and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A2 to A3 to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.vaultAssets = -20;
+                                   sample.accountAssets->amount = 10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change depositor balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A3 to vault to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change depositor shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must increase depositor shares",
+             "deposit must change depositor and vault shares by equal amount",
+             "deposit must not change vault balance by more than deposited "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = -5;
+                                   sample.sharesTotal = -10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit and assets outstanding must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
+                ac.view().update(sleA3);
+
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = 11;
+                               }));
+            },
+            XRPAmount{2000},
+            STTx{
+                ttVAULT_DEPOSIT,
+                [&](STObject& tx) {
+                    tx[sfAmount] = XRPAmount(10);
+                    tx[sfDelegate] = a3.id();
+                    tx[sfFee] = XRPAmount(2000);
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit and assets outstanding must add up",
+             "deposit and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = 7;
+                                   sample.assetsAvailable = 7;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        testcase << "Vault withdrawal";
+        doInvariantCheck(
+            {"withdrawal must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // Almost identical to the really convoluted test for deposit, where the
+        // depositor spends only the transaction fee. In case of withdrawal,
+        // this test is almost the same as normal withdrawal where the
+        // sfDestination would have been A4, but has been omitted.
+        doInvariantCheck(
+            {"withdrawal must change one destination balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = -100;
+                               }));
+            },
+            XRPAmount{100},
+            STTx{
+                ttVAULT_WITHDRAW,
+                [&](STObject& tx) {
+                    tx[sfFee] = XRPAmount(100);
+                    tx[sfAccount] = a3.id();
+                    // This commented out line causes the invariant violation.
+                    // tx[sfDestination] = A4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {
+                "withdrawal must change vault and destination balance by equal amount",
+                "withdrawal must decrease vault balance",
+                "withdrawal must increase destination balance",
+                "withdrawal and assets outstanding must add up",
+                "withdrawal and assets available must add up",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A2 to A3 to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.vaultAssets = 10;
+                                   sample.accountAssets->amount = -20;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change one destination balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                 *sample.vaultAssets -= 5;
+                             })))
+                    return false;
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5;
+                ac.view().update(sleA3);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change depositor shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must decrease depositor shares",
+             "withdrawal must change depositor and vault shares by equal "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = 5;
+                                   sample.sharesTotal = 10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal and assets outstanding must add up",
+             "withdrawal and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = -15;
+                                   sample.assetsAvailable = -15;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal and assets outstanding must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
+                ac.view().update(sleA3);
+
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = -7;
+                               }));
+            },
+            XRPAmount{2000},
+            STTx{
+                ttVAULT_WITHDRAW,
+                [&](STObject& tx) {
+                    tx[sfAmount] = XRPAmount(10);
+                    tx[sfDelegate] = a3.id();
+                    tx[sfFee] = XRPAmount(2000);
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+            env.fund(XRP(1000), a3, a4);
+
+            // Create MPT asset
+            {
+                json::Value jv;
+                jv[sfAccount] = a3.human();
+                jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
+                jv[sfFlags] = tfMPTCanTransfer;
+                env(jv);
+                env.close();
+            }
+
+            auto const mptID = makeMptID(env.seq(a3) - 1, a3);
+            Asset const asset = MPTIssue(mptID);
+            // Authorize A1 A2 A4
+            {
+                json::Value jv;
+                jv[sfAccount] = a1.human();
+                jv[sfTransactionType] = jss::MPTokenAuthorize;
+                jv[sfMPTokenIssuanceID] = to_string(mptID);
+                env(jv);
+                jv[sfAccount] = a2.human();
+                env(jv);
+                jv[sfAccount] = a4.human();
+                env(jv);
+
+                env.close();
+            }
+            // Send tokens to A1 A2 A4
+            {
+                env(pay(a3, a1, asset(1000)));
+                env(pay(a3, a2, asset(1000)));
+                env(pay(a3, a4, asset(1000)));
+                env.close();
+            }
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+            env(tx);
+            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)}));
+            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)}));
+            env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)}));
+            return true;
+        };
+
+        doInvariantCheck(
+            {"withdrawal must decrease depositor shares",
+             "withdrawal must change depositor and vault shares by equal "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = 5;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt,
+            TxAccount::A2);
+
+        testcase << "Vault clawback";
+        doInvariantCheck(
+            {"clawback must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        // Not the same as below check: attempt to clawback XRP
+        doInvariantCheck(
+            {"clawback may only be performed by the asset issuer"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // Not the same as above check: attempt to clawback MPT by bad account
+        doInvariantCheck(
+            {"clawback may only be performed by the asset issuer"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must decrease vault balance",
+             "clawback must decrease holder shares",
+             "clawback must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must change holder shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must change holder and vault shares by equal amount",
+             "clawback and assets outstanding must add up",
+             "clawback and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = -8;
+                                   sample.assetsTotal = -7;
+                                   sample.assetsAvailable = -7;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        // ─────────────────────────────────────────────────────────────
+        // Closed-ended vault invariants added in ValidVault::finalize (create must supply both
+        // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase,
+        // withdraw not in Investment, loan origination only in Investment.
+
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+
+        // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it
+        // from ac.view().seq(), which depends on how many env.close() calls preclose issued.
+        Keylet closedEndedKeylet = keylet::amendments();
+
+        // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with
+        // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and
+        // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub
+        // leaves the vault in Subscription.
+        auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) {
+            return [&, advanceBySub, doDeposit](
+                       Account const& a1, Account const& a2, Env& env) -> bool {
+                env.fund(XRP(1000), a3, a4);
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create(
+                    {.owner = a1,
+                     .asset = xrpIssue(),
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = red});
+                env(tx);
+                closedEndedKeylet = keylet;
+                if (doDeposit)
+                {
+                    env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+                }
+                if (advanceBySub >= 0)
+                    env.close(tp{d{sub + advanceBySub}});
+                return true;
+            };
+        };
+
+        // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance)
+        // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE
+        // states no legitimate transactor would produce.
+        auto const insertBareClosedEndedVault =
+            [closedEnded](
+                ApplyContext& ac,
+                Account const& owner,
+                std::optional subscriptionDate,
+                std::optional redemptionDate) -> bool {
+            auto const sequence = ac.view().seq();
+            auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence));
+            auto sleVault = std::make_shared(vaultKeylet);
+            auto const vaultPage = ac.view().dirInsert(
+                keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id()));
+            if (!vaultPage)
+                return false;
+            sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+            auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+            auto sleAccount = std::make_shared(keylet::account(pseudoId));
+            sleAccount->setAccountID(sfAccount, pseudoId);
+            sleAccount->setFieldAmount(sfBalance, STAmount{});
+            sleAccount->setFieldU32(sfSequence, 0);
+            sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+            sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+            ac.view().insert(sleAccount);
+
+            auto const sharesMptId = makeMptID(sequence, pseudoId);
+            auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+            auto sleShares = std::make_shared(sharesKeylet);
+            auto const sharesPage = ac.view().dirInsert(
+                keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+            if (!sharesPage)
+                return false;
+            sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+            sleShares->at(sfFlags) = 0;
+            sleShares->at(sfIssuer) = pseudoId;
+            sleShares->at(sfOutstandingAmount) = 0;
+            sleShares->at(sfSequence) = sequence;
+
+            sleVault->at(sfAccount) = pseudoId;
+            sleVault->at(sfFlags) = 0;
+            sleVault->at(sfSequence) = sequence;
+            sleVault->at(sfOwner) = owner.id();
+            sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}});
+            sleVault->at(sfAssetsTotal) = Number(0);
+            sleVault->at(sfAssetsAvailable) = Number(0);
+            sleVault->at(sfLossUnrealized) = Number(0);
+            sleVault->at(sfShareMPTID) = sharesMptId;
+            sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+            sleVault->at(sfVaultKind) = closedEnded;
+            if (subscriptionDate)
+                sleVault->at(sfSubscriptionDate) = *subscriptionDate;
+            if (redemptionDate)
+                sleVault->at(sfRedemptionDate) = *redemptionDate;
+
+            ac.view().insert(sleVault);
+            ac.view().insert(sleShares);
+            return true;
+        };
+
+        testcase << "Vault create closed-ended";
+
+        // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate.
+        doInvariantCheck(
+            {"closed-ended vault must have SubscriptionDate and RedemptionDate"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate;
+        // exercises the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMinInvestmentPeriod - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and
+        // is caught by the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right).
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMaxInvestmentPeriod;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        testcase << "Vault deposit closed-ended";
+
+        // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs
+        // simulates an otherwise valid deposit shape so only the phase invariant fires.
+        doInvariantCheck(
+            {"deposit only allowed in Subscription or NoPhase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault withdrawal closed-ended";
+
+        // A withdrawal from a closed-ended vault in the Investment phase.
+        doInvariantCheck(
+            {"withdrawal not allowed during Investment phase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault loan set";
+
+        // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires
+        // on any vault mutation; touching the vault SLE with no field change is sufficient.
+        doInvariantCheck(
+            {"loan origination only allowed in Investment phase"},
+            [&](Account const&, Account const&, ApplyContext& ac) {
+                auto sleVault = ac.view().peek(closedEndedKeylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false));
+
+        testcase << "Vault loan set - closed-ended final payment past "
+                    "RedemptionDate";
+
+        // A newly-created loan against a closed-ended vault must satisfy StartDate +
+        // PaymentInterval * PaymentRemaining + kLoanRedemptionBuffer <= RedemptionDate.
+        // LoanSet::preclaim enforces the same bound; this test synthesises a loan whose
+        // final payment is still before RedemptionDate (so the old unbuffered check would
+        // pass) but inside the buffer zone.
+        Keylet closedEndedBrokerKeylet = keylet::amendments();
+        std::uint32_t closedEndedRed = 0;
+        doInvariantCheck(
+            {"closed-ended loan final payment must precede RedemptionDate by at least "
+             "kLoanRedemptionBuffer"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                // Touch the vault so ValidVault::finalizeLoanSet sees an
+                // entry in afterVault_; the vault is in Investment, so
+                // 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);
+
+                // Final payment at RedemptionDate - (kLoanRedemptionBuffer - 1): still
+                // strictly before RedemptionDate, but inside the buffer.
+                auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id());
+                sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
+                sleLoan->at(sfLoanSequence) = loanSeq;
+                sleLoan->at(sfBorrower) = a1.id();
+                sleLoan->at(sfStartDate) = closedEndedRed - kLoanRedemptionBuffer;
+                sleLoan->at(sfPaymentInterval) = 1;
+                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;
+            });
+    }
+
+    // Minimal impaired-loan setup for testVaultLossExceedsGap.  Kept
+    // inline here so this file has no dependency on LoanTestBase.
+    Keylet
+    makeImpairedVault(
+        test::jtx::Account const& owner,
+        test::jtx::Account const& borrower,
+        test::jtx::Account const& issuer,
+        test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+
+        env.fund(XRP(1'000'000), issuer, borrower);
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}};
+        env(trust(owner, trustLimit));
+        env(trust(borrower, trustLimit));
+        env.close();
+
+        env(pay(issuer, owner, usd(100'000)));
+        env(pay(issuer, borrower, usd(1'000)));
+        env.close();
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults. The 10-year investment window
+        // covers this helper's 120 monthly payments so LoanSet's
+        // RedemptionDate bound is satisfied.
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner,
+             .asset = usd,
+             .subscriptionOffset = std::chrono::seconds{60},
+             .investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull}});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit(
+            {.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+
+        {
+            using namespace loan_broker;
+            env(set(owner, vaultKeylet.key),
+                kCoverRateMinimum(percentageToTenthBips(1)),
+                kCoverRateLiquidation(xrpl::lending::kMaxCoverRate),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            env(coverDeposit(owner, brokerKeylet.key, usd(10'000).value()),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+        }
+
+        // LoanSet is gated on Investment; advance out of Subscription.
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerSle = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(brokerSle))
+            return vaultKeylet;
+
+        auto const loanKeylet =
+            keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        {
+            using namespace loan;
+            env(set(borrower, brokerKeylet.key, usd(100).value()),
+                kCounterparty(owner),
+                kInterestRate(TenthBips32{1000}),
+                kPaymentTotal(120),
+                kPaymentInterval(86400u * 30u),
+                kGracePeriod(86400u * 30u),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 200));
+            env.close();
+
+            // Under fixCleanup3_4_0 impair requires the payment to already
+            // be late, so advance past the loan's due date first.
+            if (env.current()->rules().enabled(fixCleanup3_4_0))
+            {
+                auto const loanSle = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loanSle))
+                    return vaultKeylet;
+                std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+                env.close(
+                    NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+            }
+
+            env(manage(owner, loanKeylet.key, tfLoanImpair));
+            env.close();
+        }
+
+        return vaultKeylet;
+    }
+
+    // Regression test for the loss-vs-gap invariant relaxation introduced
+    // by fixCleanup3_4_0.  Even with the one-unit tolerance, a loss value
+    // exceeding (T - A) by more than one ULP must still fire.  Two
+    // mutations exercise this:
+    //   1. L = (T - A) * 2  — fires under both amendment settings.
+    //   2. L = (T - A) + 2 * oneUnit  — fires post-amendment, catching
+    //      any accidental widening of the tolerance beyond one unit.
+    void
+    testVaultLossExceedsGap()
+    {
+        testcase("vault loss exceeds gap (fixCleanup3_4_0 tolerance)");
+        using namespace test::jtx;
+
+        auto const kExpectedLog = std::vector{
+            "loss unrealized must not exceed the difference between assets "
+            "outstanding and available"};
+
+        for (auto const withFix : {false, true})
+        {
+            FeatureBitset amendments = all_;
+            if (!withFix)
+                amendments = amendments - fixCleanup3_4_0;
+
+            // Variant 1: L = (T - A) * 2. Fires under both settings.
+            {
+                Keylet vaultKeylet = keylet::vault(uint256{});
+                Account const issuer{"issuer_loss_gap"};
+                Account const borrower{"borrower_loss_gap"};
+
+                auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
+                    vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
+                    return BEAST_EXPECT(env.le(vaultKeylet));
+                };
+
+                doInvariantCheck(
+                    makeEnv(amendments),
+                    kExpectedLog,
+                    [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
+                        auto sle = ac.view().peek(vaultKeylet);
+                        if (!sle)
+                            return false;
+                        Number const total = sle->at(sfAssetsTotal);
+                        Number const available = sle->at(sfAssetsAvailable);
+                        (*sle)[sfLossUnrealized] = (total - available) * 2;
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{
+                        ttVAULT_DEPOSIT,
+                        [&vaultKeylet](STObject& tx) {
+                            tx.setFieldH256(sfVaultID, vaultKeylet.key);
+                        }},
+                    {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+                    preclose,
+                    TxAccount::A1);
+            }
+
+            // Variant 2: L = (T - A) + 2 * oneUnit at scale(T).  Must fire
+            // post-fix because the tolerance is exactly one unit.  A
+            // regression that widened it to two units would silently accept
+            // this state.
+            {
+                Keylet vaultKeylet = keylet::vault(uint256{});
+                Account const issuer{"issuer_loss_gap2"};
+                Account const borrower{"borrower_loss_gap2"};
+
+                auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
+                    vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
+                    return BEAST_EXPECT(env.le(vaultKeylet));
+                };
+
+                doInvariantCheck(
+                    makeEnv(amendments),
+                    kExpectedLog,
+                    [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
+                        auto sle = ac.view().peek(vaultKeylet);
+                        if (!sle)
+                            return false;
+                        Number const total = sle->at(sfAssetsTotal);
+                        Number const available = sle->at(sfAssetsAvailable);
+                        Asset const asset = sle->at(sfAsset);
+                        Number const oneUnit{1, scale(total, asset)};
+                        (*sle)[sfLossUnrealized] = (total - available) + oneUnit * 2;
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{
+                        ttVAULT_DEPOSIT,
+                        [&vaultKeylet](STObject& tx) {
+                            tx.setFieldH256(sfVaultID, vaultKeylet.key);
+                        }},
+                    {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+                    preclose,
+                    TxAccount::A1);
+            }
+        }
+    }
+
+    void
+    testVaultComputeCoarsestScale()
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        PrettyAsset const vaultAsset = issuer["IOU"];
+
+        struct TestCase
+        {
+            std::string name;
+            std::int32_t expectedMinScale;
+            std::vector values;
+        };
+
+        for (auto const mantissaScale : MantissaRange::getAllScales())
+        {
+            if (mantissaScale == MantissaRange::MantissaScale::Small)
+                continue;
+            NumberMantissaScaleGuard const g{mantissaScale};
+
+            auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
+                return {.delta = n, .scale = scale(n, vaultAsset.raw())};
+            };
+
+            auto const testCases = std::vector{
+                {
+                    .name = "No values",
+                    .expectedMinScale = 0,
+                    .values = {},
+                },
+                {
+                    .name = "Mixed integer and Number values",
+                    .expectedMinScale = -15,
+                    .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})},
+                },
+                {
+                    .name = "Mixed scales",
+                    .expectedMinScale = -17,
+                    .values =
+                        {makeDelta(Number{1, -2}),
+                         makeDelta(Number{5, -3}),
+                         makeDelta(Number{3, -2})},
+                },
+                {
+                    .name = "Equal scales",
+                    .expectedMinScale = -16,
+                    .values =
+                        {makeDelta(Number{1, -1}),
+                         makeDelta(Number{5, -1}),
+                         makeDelta(Number{1, -1})},
+                },
+                {
+                    .name = "Mixed mantissa sizes",
+                    .expectedMinScale = -12,
+                    .values =
+                        {makeDelta(Number{1}),
+                         makeDelta(Number{1234, -3}),
+                         makeDelta(Number{12345, -6}),
+                         makeDelta(Number{123, 1})},
+                },
+            };
+
+            for (auto const& tc : testCases)
+            {
+                testcase("vault computeCoarsestScale: " + tc.name);
+
+                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
+
+                BEAST_EXPECTS(
+                    actualScale == tc.expectedMinScale,
+                    "expected: " + std::to_string(tc.expectedMinScale) +
+                        ", actual: " + std::to_string(actualScale));
+                for (auto const& num : tc.values)
+                {
+                    // None of these scales are far enough apart that rounding the
+                    // values would lose information, so check that the rounded
+                    // value matches the original.
+                    auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                    BEAST_EXPECTS(
+                        actualRounded == num.delta,
+                        "number " + to_string(num.delta) + " rounded to scale " +
+                            std::to_string(actualScale) + " is " + to_string(actualRounded));
+                }
+            }
+
+            auto const testCases2 = std::vector{
+                {
+                    .name = "False equivalence",
+                    .expectedMinScale = -15,
+                    .values =
+                        {
+                            makeDelta(Number{1234567890123456789, -18}),
+                            makeDelta(Number{12345, -4}),
+                            makeDelta(Number{1}),
+                        },
+                },
+            };
+
+            // Unlike the first set of test cases, the values in these test could
+            // look equivalent if using the wrong scale.
+            for (auto const& tc : testCases2)
+            {
+                testcase("vault computeCoarsestScale: " + tc.name);
+
+                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
+
+                BEAST_EXPECTS(
+                    actualScale == tc.expectedMinScale,
+                    "expected: " + std::to_string(tc.expectedMinScale) +
+                        ", actual: " + std::to_string(actualScale));
+                std::optional first;
+                Number firstRounded;
+                for (auto const& num : tc.values)
+                {
+                    if (!first)
+                    {
+                        first = num.delta;
+                        firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                        continue;
+                    }
+                    auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                    BEAST_EXPECTS(
+                        numRounded != firstRounded,
+                        "at a scale of " + std::to_string(actualScale) + " " +
+                            to_string(num.delta) + " == " + to_string(*first));
+                }
+            }
+        }
+    }
+
+    void
+    run() override
+    {
+        testVault();
+        testVaultLossExceedsGap();
+        testVaultComputeCoarsestScale();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsVault, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
index 5d67cdc3c5..96adfd5254 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,100 @@ 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();
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a near-future
+        // SubscriptionDate, deposit while still in the Subscription phase,
+        // and advance past SubscriptionDate before creating the broker.
+        auto [vaultTx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
+        env(vaultTx);
+        env.close();
+        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
+        env.close();
+
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerKeylet =
+            keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
+        env(loan_broker::set(lender, vaultKeylet.key));
+        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 +2009,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 5efa65d506..3bcda42c7e 100644
--- a/src/test/app/lending/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -6,6 +6,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -58,6 +60,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -69,7 +72,13 @@ class LoanBroker_test : public beast::unit_test::Suite
 {
     // Ensure that all the features needed for Lending Protocol are included,
     // even if they are set to unsupported.
-    FeatureBitset const all_{jtx::testableAmendments()};
+    //
+    // featureLendingProtocolV1_1 is excluded from the default set: it adds
+    // the closed-ended vault gate on LoanBrokerSet::preclaim (see
+    // LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
+    // plain open-ended vaults. Tests that specifically exercise the
+    // amendment opt it back in explicitly and use closed-ended vaults.
+    FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
 
     void
     testDisabled()
@@ -869,7 +878,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1105,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             Account const issuer{"issuer"};
             auto const usd = alice["USD"];
-            Env env(*this);
+            Env env(*this, all_);
             env.fund(XRP(100'000), alice);
             env.close();
 
@@ -1208,7 +1217,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // This test is lifted directly from
         // https://bugs.immunefi.com/dashboard/submission/57808
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const alice{"alice"};
         env.fund(XRP(10000), alice);
@@ -1266,7 +1275,7 @@ class LoanBroker_test : public beast::unit_test::Suite
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1374,7 +1383,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1540,7 +1549,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const& broker = issuer;
 
         auto test = [&](auto&& getToken) {
-            Env env(*this);
+            Env env(*this, all_);
 
             env.fund(XRP(1'000), issuer, holder);
             env.close();
@@ -1613,7 +1622,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults";
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"};
         env.fund(XRP(20'000), issuer, lender, borrower);
@@ -1840,6 +1849,96 @@ class LoanBroker_test : public beast::unit_test::Suite
         BEAST_EXPECT(aliceBalanceAfter == aliceBalanceBefore);
     }
 
+    void
+    testLoanBrokerDeleteRequireAuthMPT(FeatureBitset features)
+    {
+        testcase << "LoanBrokerDelete - auth-required broker pseudo-account MPT "
+                 << (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix");
+        using namespace jtx;
+        using namespace loan_broker;
+
+        Account const issuer("issuer");
+        Account const alice("alice");
+
+        Env env(*this, features);
+        env.fund(XRP(100'000), issuer, alice);
+        env.close();
+
+        // Create an auth-required MPT and authorize alice as a holder. The
+        // broker pseudo-account's cover MPToken is auto-created later
+        // (addEmptyHolding -> authorizeMPToken) with lsfMPTAuthorized clear;
+        // the pseudo-account is implicitly authorized to hold any MPT
+        // regardless of that flag.
+        auto tester = MPTTester(
+            {.env = env,
+             .issuer = issuer,
+             .holders = {alice},
+             .pay = 20'000,
+             .flags = tfMPTRequireAuth | tfMPTCanTransfer,
+             .authHolder = true});
+
+        PrettyAsset const mpt{tester.issuanceID()};
+
+        // Create vault
+        Vault const vault{env};
+        auto [tx, vaultKeylet] = vault.create({.owner = alice, .asset = mpt});
+        env(tx);
+        env.close();
+
+        // Deposit into vault
+        env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(10'000)}));
+        env.close();
+
+        // Create loan broker
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+        env(set(alice, vaultKeylet.key));
+        env.close();
+
+        // Deposit cover
+        env(coverDeposit(alice, brokerKeylet.key, mpt(5'000).value()));
+        env.close();
+
+        // Verify cover is deposited
+        auto const broker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(broker))
+            return;
+        BEAST_EXPECT(broker->at(sfCoverAvailable) > 0);
+
+        // Get the broker pseudo-account
+        auto const brokerPseudoID = broker->at(sfAccount);
+
+        // Verify the broker pseudo-account has an MPToken, and that it was
+        // never explicitly authorized (issuer cannot authorize a
+        // pseudo-account holder; see MPTokenAuthorize::preclaim).
+        auto const pseudoMptKey = keylet::mptoken(tester.issuanceID(), brokerPseudoID);
+        auto const pseudoMpt = env.le(pseudoMptKey);
+        if (!BEAST_EXPECT(pseudoMpt))
+            return;
+        BEAST_EXPECT(!pseudoMpt->isFlag(lsfMPTAuthorized));
+
+        // Record alice's balance before deletion
+        auto const aliceBalanceBefore = env.balance(alice, mpt);
+
+        // LoanBrokerDelete sends the remaining cover out of the broker pseudo-account, deletes its
+        // now-empty MPToken, and erases the pseudo AccountRoot. Before the fix,
+        // ValidMPTTransfer::isAuthorized evaluates isPseudoAccount() on the post-transaction view
+        // (where the pseudo-account is already gone) and falls back to the MPToken's
+        // lsfMPTAuthorized flag, which was never set, so the invariant treats the broker as an
+        // unauthorized sender and the whole transaction fails once fixCleanup3_4_0 makes the check
+        // enforcing.
+        env(del(alice, brokerKeylet.key), Ter(tesSUCCESS));
+        env.close();
+
+        // Broker and its pseudo-account MPToken are gone
+        BEAST_EXPECT(env.le(brokerKeylet) == nullptr);
+        BEAST_EXPECT(env.le(pseudoMptKey) == nullptr);
+
+        // Alice received the cover
+        auto const aliceBalanceAfter = env.balance(alice, mpt);
+        BEAST_EXPECT(aliceBalanceAfter > aliceBalanceBefore);
+    }
+
     void
     testCoverDepositFreezes()
     {
@@ -1852,7 +1951,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverDeposit IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -1919,7 +2018,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverDeposit MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2002,7 +2101,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const issuer{"issuer"};
         Account const alice{"alice"};
         Account const dest{"dest"};
-        Env env{*this};
+        Env env{*this, all_};
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice, dest);
@@ -2068,7 +2167,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverWithdraw IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2180,7 +2279,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverWithdraw MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2301,7 +2400,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](TrustState trustState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 IOU with state: " << static_cast(trustState);
 
@@ -2426,7 +2525,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](MPTState mptState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 MPT with state: " << static_cast(mptState);
 
@@ -2532,6 +2631,132 @@ class LoanBroker_test : public beast::unit_test::Suite
         testRIPD4274MPT();
     }
 
+    void
+    testCoverWithdrawCredentialDepositPreauth(FeatureBitset features)
+    {
+        testcase(
+            std::string{"CoverWithdraw with credential-based deposit preauth "} +
+            (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"));
+        using namespace jtx;
+        using namespace std::chrono_literals;
+
+        bool const fix340Enabled = features[fixCleanup3_4_0];
+
+        Env env(*this, features);
+
+        Account const broker{"broker"};
+        Account const dest{"dest"};
+        Account const credIssuer{"credIssuer"};
+        char const credType[] = "abcde";
+
+        env.fund(XRP(10'000), broker, dest, credIssuer);
+        env(fset(dest, asfDepositAuth));
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1'000'000};
+
+        Vault const vault(env);
+        auto const [vaultTx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit({.depositor = broker, .id = vaultKeylet.key, .amount = asset(1'000)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
+        env(loan_broker::set(broker, vaultKeylet.key));
+        env.close();
+
+        env(loan_broker::coverDeposit(broker, brokerKeylet.key, asset(500)));
+        env.close();
+
+        auto coverWithdrawToDest = [&]() {
+            return loan_broker::coverWithdraw(broker, brokerKeylet.key, asset(10));
+        };
+
+        // Without any preauth, coverWithdraw to dest fails
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Issue and accept a credential for the broker (with expiration)
+        auto jv = credentials::create(broker, credIssuer, credType);
+        std::uint32_t const expiration =
+            env.current()->header().parentCloseTime.time_since_epoch().count() + 100;
+        jv[sfExpiration.jsonName] = expiration;
+        env(jv);
+        env(credentials::accept(broker, credIssuer, credType));
+        env.close();
+
+        auto const credKeylet = credentials::keylet(broker, credIssuer, credType);
+        auto const credIdx =
+            credentials::ledgerEntry(env, broker, credIssuer, credType)[jss::result][jss::index]
+                .asString();
+
+        // dest authorizes deposits from holders of credentials issued by credIssuer
+        env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}}));
+        env.close();
+
+        // Without supplying credentials, still fails
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION});
+        env.close();
+
+        if (!fix340Enabled)
+        {
+            // Pre-fix: sfCredentialIDs in LoanBrokerCoverWithdraw is disabled
+            env(coverWithdrawToDest(),
+                loan_broker::kDestination(dest),
+                credentials::Ids({credIdx}),
+                Ter{temDISABLED});
+            env.close();
+            return;
+        }
+
+        // With credentials, succeeds
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), credentials::Ids({credIdx}));
+        env.close();
+
+        // Bad credential id is rejected
+        std::string const invalidIdx =
+            "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034";
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({invalidIdx}),
+            Ter{tecBAD_CREDENTIALS});
+        env.close();
+
+        // Malformed credential array (duplicates) is rejected by checkFields
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx, credIdx}),
+            Ter{temMALFORMED});
+        env.close();
+
+        // Valid credential not authorized by dest hits authorizedDepositPreauth error path
+        char const credType2[] = "fghij";
+        env(credentials::create(broker, credIssuer, credType2));
+        env(credentials::accept(broker, credIssuer, credType2));
+        env.close();
+        auto const credIdx2 =
+            credentials::ledgerEntry(env, broker, credIssuer, credType2)[jss::result][jss::index]
+                .asString();
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx2}),
+            Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Advance time past expiration: credentials yield tecEXPIRED and are deleted
+        env.close(150s);
+        BEAST_EXPECT(env.le(credKeylet));
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx}),
+            Ter{tecEXPIRED});
+        env.close();
+        BEAST_EXPECT(!env.le(credKeylet));
+    }
+
     // Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw,
     // or clawback whose amount rounds to zero at sfCoverAvailable's precision
     // scale must be rejected with tecPRECISION_LOSS once the amendment is on,
@@ -2770,11 +2995,21 @@ public:
 
         testRIPD4274();
 
+        testCoverWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0);
+        testCoverWithdrawCredentialDepositPreauth(all_);
+
         testLoanBrokerDeleteLockedMPT(all_);
         testLoanBrokerDeleteLockedMPT(all_ - fixCleanup3_2_0);
 
         testLoanBrokerDeleteFrozenIOU(all_);
         testLoanBrokerDeleteFrozenIOU(all_ - fixCleanup3_2_0);
+
+        // featureMPTokensV2 independently makes ValidMPTTransfer enforcing,
+        // but it's Supported::No (never enabled on real networks); exclude
+        // it here so fixCleanup3_4_0 alone is the deciding amendment, as it
+        // would be on mainnet.
+        testLoanBrokerDeleteRequireAuthMPT(all_ - featureMPTokensV2);
+        testLoanBrokerDeleteRequireAuthMPT(all_ - featureMPTokensV2 - fixCleanup3_4_0);
         // TODO: Write clawback failure tests with an issuer / MPT that doesn't
         // have the right flags set.
     }
diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp
index 11053b6fd0..e238838306 100644
--- a/src/test/app/lending/LoanCashBasis_test.cpp
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -4,10 +4,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -25,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -42,8 +45,9 @@ class LoanCashBasis_test : public LoanTestBase
 {
 private:
     // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas,
-    // and the AssetsMaximum/DebtMaximum guards (which always check against
-    // principal + interestDue, regardless of the amendment).
+    // and the AssetsMaximum/DebtMaximum guards. Accrual AssetsMaximum still
+    // requires headroom for interestDue; cash-basis AssetsMaximum does not,
+    // because origination does not credit interest into AssetsTotal.
     void
     testCashBasisLoanSetOrigination()
     {
@@ -226,6 +230,10 @@ private:
             // Even far less headroom than interestDue still succeeds, since
             // cash-basis origination never adds interest to AssetsTotal.
             runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS);
+            // Fully subscribed: AssetsTotal == AssetsMaximum. Accrual preclaim
+            // used to refuse this; origination must still succeed because it
+            // does not change AssetsTotal.
+            runVaultGuard(all_ | featureLendingProtocolV1_1, Number{0}, tesSUCCESS);
         }
 
         // DebtMaximum guard: cash-basis projects principal-only DebtTotal;
@@ -489,6 +497,252 @@ private:
         }
     }
 
+    // VaultSet must still succeed when cash-basis LoanPay has already pushed
+    // AssetsTotal above a nonzero AssetsMaximum. Before fixCleanup3_4_0,
+    // ValidVault rejects that with tecINVARIANT_FAILED even though
+    // VaultSet::doApply and the product rule allow the over-cap state when
+    // the excess is interest.
+    void
+    testVaultSetWhileAssetsTotalExceedsMaximum()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+
+        auto run =
+            [&](FeatureBitset features, TER expectedOverCapSet, bool native, bool vaultPrivate) {
+                bool const fix340Enabled = features[fixCleanup3_4_0];
+                testcase(
+                    std::string("cash-basis: VaultSet while AssetsTotal exceeds AssetsMaximum") +
+                    (native ? " XRP" : " IOU") + (vaultPrivate ? " private" : "") +
+                    (fix340Enabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+                Account const issuer{"issuer"};
+                Account const lender{"lender"};
+                Account const borrower{"borrower"};
+                Env env(*this, features);
+
+                BrokerParameters params = brokerParams;
+                if (vaultPrivate)
+                    params.vaultFlags = tfVaultPrivate;
+
+                PrettyAsset vaultAsset = xrpAsset;
+                if (native)
+                {
+                    env.fund(XRP(10'000'000), lender, borrower);
+                    env.close();
+                }
+                else
+                {
+                    vaultAsset = createFundedIouAsset(env, issuer, lender, borrower);
+                }
+
+                BrokerInfo const broker{createVaultAndBroker(env, vaultAsset, lender, params)};
+                auto const vaultBefore = env.le(broker.vaultKeylet());
+                BEAST_EXPECT(vaultBefore);
+                // One unit at the vault's asset scale so the stored cap is
+                // strictly above AssetsTotal (a smaller ULP rounds away).
+                // Cash-basis origination does not credit interest, so LoanSet
+                // still succeeds.
+                Number const slack{1, -static_cast(vaultBefore->at(sfScale))};
+                Number const assetsMaximum = Number(vaultBefore->at(sfAssetsTotal)) + slack;
+
+                Vault const vault{env};
+                {
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfAssetsMaximum] = assetsMaximum;
+                    env(tx);
+                    env.close();
+                }
+
+                {
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfData] = "AA";
+                    env(tx, Ter(tesSUCCESS));
+                    env.close();
+                }
+
+                auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
+                BEAST_EXPECT(brokerBeforeLoan);
+                auto const loanKeylet = keylet::loan(
+                    broker.brokerID, SeqProxy::rawSequence(brokerBeforeLoan->at(sfLoanSequence)));
+
+                LoanParameters const loanParams{
+                    .account = borrower,
+                    .counter = lender,
+                    .principalRequest = 12'000,
+                    .interest = TenthBips32{percentageToTenthBips(12)},
+                    .payTotal = 4,
+                    .payInterval = 600,
+                    .gracePd = 300,
+                };
+                env(loanParams(env, broker));
+                env.close();
+
+                auto const vaultAfterLoan = env.le(broker.vaultKeylet());
+                BEAST_EXPECT(vaultAfterLoan);
+                BEAST_EXPECT(vaultAfterLoan->at(sfAssetsTotal) <= assetsMaximum);
+
+                LoanState const state = getCurrentState(env, broker, loanKeylet);
+                STAmount const payment{
+                    vaultAsset,
+                    roundPeriodicPayment(vaultAsset, state.periodicPayment, state.loanScale) *
+                        Number{3, -1} * 5};
+                env(pay(borrower, loanKeylet.key, payment), Ter(tesSUCCESS));
+                env.close();
+
+                auto const vaultAboveMaximum = env.le(broker.vaultKeylet());
+                BEAST_EXPECT(vaultAboveMaximum);
+                BEAST_EXPECT(vaultAboveMaximum->at(sfAssetsTotal) > assetsMaximum);
+                BEAST_EXPECT(vaultAboveMaximum->at(sfAssetsMaximum) == assetsMaximum);
+
+                {
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfData] = "BB";
+                    env(tx, Ter(expectedOverCapSet));
+                    env.close();
+                }
+
+                if (vaultPrivate)
+                {
+                    pdomain::Credentials const credentials{
+                        {.issuer = lender, .credType = "credential"}};
+                    env(pdomain::setTx(lender, credentials));
+                    auto const domainId = pdomain::getNewDomain(env.meta());
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfDomainID] = to_string(domainId);
+                    env(tx, Ter(expectedOverCapSet));
+                    env.close();
+                }
+
+                if (!fix340Enabled)
+                    return;
+
+                {
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfAssetsMaximum] = assetsMaximum;
+                    env(tx, Ter(tecLIMIT_EXCEEDED));
+                    env.close();
+                }
+
+                {
+                    auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                    tx[sfAssetsMaximum] = Number{0};
+                    env(tx, Ter(tesSUCCESS));
+                    env.close();
+                }
+            };
+
+        FeatureBitset const withFix = all_ | featureLendingProtocolV1_1;
+        FeatureBitset const withoutFix = withFix - fixCleanup3_4_0;
+
+        run(withFix, tesSUCCESS, true, true);
+        run(withoutFix, tecINVARIANT_FAILED, true, true);
+        run(withFix, tesSUCCESS, false, false);
+        run(withoutFix, tecINVARIANT_FAILED, false, false);
+    }
+
+    void
+    testCashBasisLoanSetAfterInterestExceedsCap()
+    {
+        testcase("cash-basis: LoanSet after interest pushes AssetsTotal past AssetsMaximum");
+
+        using namespace jtx;
+        using namespace loan;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+        Env env(*this, all_ | featureLendingProtocolV1_1);
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+        auto const vaultBefore = env.le(broker.vaultKeylet());
+        BEAST_EXPECT(vaultBefore);
+        Number const assetsMaximum = Number(vaultBefore->at(sfAssetsTotal));
+
+        Vault const vault{env};
+        {
+            auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+            tx[sfAssetsMaximum] = assetsMaximum;
+            env(tx);
+            env.close();
+        }
+
+        auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(brokerBeforeLoan);
+        auto const firstLoanKeylet = keylet::loan(
+            broker.brokerID, SeqProxy::rawSequence(brokerBeforeLoan->at(sfLoanSequence)));
+
+        Number const firstPrincipal = xrpAsset(12'000).value();
+        env(set(borrower, broker.brokerID, firstPrincipal),
+            kCounterparty(lender),
+            kInterestRate(TenthBips32{percentageToTenthBips(12)}),
+            kPaymentTotal(4),
+            kPaymentInterval(600),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterFirst = env.le(broker.vaultKeylet());
+        BEAST_EXPECT(vaultAfterFirst);
+        BEAST_EXPECT(vaultAfterFirst->at(sfAssetsTotal) == assetsMaximum);
+        BEAST_EXPECT(vaultAfterFirst->at(sfAssetsAvailable) == assetsMaximum - firstPrincipal);
+
+        LoanState const state = getCurrentState(env, broker, firstLoanKeylet);
+        STAmount const payment{
+            xrpAsset,
+            roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * Number{3, -1} *
+                5};
+        env(pay(borrower, firstLoanKeylet.key, payment), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterPay = env.le(broker.vaultKeylet());
+        BEAST_EXPECT(vaultAfterPay);
+        BEAST_EXPECT(vaultAfterPay->at(sfAssetsTotal) > assetsMaximum);
+        BEAST_EXPECT(vaultAfterPay->at(sfAssetsAvailable) > beast::kZero);
+
+        auto const brokerAfterPay = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(brokerAfterPay);
+        auto const secondLoanKeylet = keylet::loan(
+            broker.brokerID, SeqProxy::rawSequence(brokerAfterPay->at(sfLoanSequence)));
+
+        Number const secondPrincipal = xrpAsset(1'000).value();
+        env(set(borrower, broker.brokerID, secondPrincipal),
+            kCounterparty(lender),
+            kInterestRate(TenthBips32{percentageToTenthBips(12)}),
+            kPaymentTotal(4),
+            kPaymentInterval(600),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterSecond = env.le(broker.vaultKeylet());
+        auto const secondLoan = env.le(secondLoanKeylet);
+        BEAST_EXPECT(vaultAfterSecond && secondLoan);
+        BEAST_EXPECT(vaultAfterSecond->at(sfAssetsTotal) == vaultAfterPay->at(sfAssetsTotal));
+        BEAST_EXPECT(secondLoan->at(sfPrincipalOutstanding) == secondPrincipal);
+    }
+
     // 3. LoanManage: impair, unimpair, and default.
     void
     testCashBasisLoanManage()
@@ -562,6 +816,7 @@ private:
             BEAST_EXPECT(vaultBeforeImpair);
             Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -612,6 +867,7 @@ private:
                 ? principalOutstanding
                 : totalValueOutstanding - managementFeeOutstanding;
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -822,12 +1078,17 @@ private:
         Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding);
         Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair;
 
+        // With fixCleanup3_4_0, impairment is only allowed once the
+        // payment is late. After the earlier LoanPay the due date advanced by
+        // one interval, so use the current due date rather than startDate.
+        std::uint32_t const dueDateBeforeImpair = loanBeforeImpair->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} + 1s);
+
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
-        LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet);
         env.close(
-            stateAtImpair.startDate + std::chrono::seconds(paymentInterval) +
+            NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} +
             std::chrono::seconds(gracePeriod) + 60s);
 
         auto const vaultBeforeDefault = env.le(broker.vaultKeylet());
@@ -1006,6 +1267,8 @@ public:
     {
         testCashBasisLoanSetOrigination();
         testCashBasisLoanPay();
+        testVaultSetWhileAssetsTotalExceedsMaximum();
+        testCashBasisLoanSetAfterInterestExceedsCap();
         testCashBasisLoanManage();
         testLegacyVaultKeepsAccrualAfterAmendmentEnabled();
         testCashBasisEndToEndTrajectory();
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
index a9b3542c4e..f8bdb5a3d7 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 
@@ -236,6 +238,9 @@ private:
             Ter(tesSUCCESS));
         env.close();
 
+        // Under fixCleanup3_4_0 impair requires the payment to be late.
+        advancePastDueDate(env, loanKeylet);
+
         // Impair the loan to create unrealized loss
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
@@ -368,6 +373,187 @@ 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.
+        advancePastDueDate(env, loanKeylet);
+        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 +880,9 @@ private:
     runAmendmentIndependent()
     {
         testServiceFeeOnBrokerDeepFreeze();
+        testLoanDefaultBypassesFreeze();
+        testLoanDefaultBypassesDeepFreeze();
+        testLoanDefaultBypassesMptLockAfterImpair();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp
index 264dbdcd24..b3d4803aff 100644
--- a/src/test/app/lending/LoanInvariants_test.cpp
+++ b/src/test/app/lending/LoanInvariants_test.cpp
@@ -25,7 +25,9 @@
 #include 
 #include 
 #include 
+#include 
 
+#include 
 #include 
 #include 
 
@@ -383,6 +385,85 @@ private:
             isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
     }
 
+    // Verify an overpayment cannot reduce principal without covering and
+    // advancing at least one scheduled instalment: reject an extra-only amount,
+    // but accept an instalment plus extra. Enable V1_1 explicitly because
+    // LoanTestBase::all_ excludes it.
+    void
+    testLoanPayOverpaymentScheduleInvariant(FeatureBitset features)
+    {
+        testcase("LoanPay overpayment schedule advancement");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env{*this, features | featureLendingProtocolV1_1};
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+
+        BrokerInfo const broker = createVaultAndBroker(
+            env,
+            asset,
+            lender,
+            {
+                .vaultDeposit = asset(100'000).value(),
+                .managementFeeRate = TenthBips16(10'000),
+            });
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        // Principal 10,000 over 3 payments, overpayment enabled. One scheduled
+        // payment is ~3,333, so an amount well below that cannot cover one.
+        auto const loanKeylet = nextLoanKeylet(env, broker);
+        env(loan::set(borrower, broker.brokerID, asset(10'000).value(), tfLoanOverpayment),
+            Sig(sfCounterpartySignature, lender),
+            loan::kPaymentInterval(86400 * 30),
+            loan::kPaymentTotal(3),
+            loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
+            loanSetFee);
+        env.close();
+
+        auto const before = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(before.paymentRemaining == 3);
+
+        STAmount const belowOnePayment = asset(1'000).value();
+        BEAST_EXPECT((belowOnePayment < STAmount{asset, before.periodicPayment}));
+
+        auto const payFee = Fee(env.current()->fees().base * 2);
+
+        // The amount does not cover a scheduled payment, so makeRegularPayment makes zero scheduled
+        // payments and returns tecINSUFFICIENT_PAYMENT before the Extra branch runs. Were the
+        // payment to succeed while touching only principal, PaymentRemaining and NextPaymentDueDate
+        // would silently fail to advance.
+        env(pay(borrower, loanKeylet.key, belowOnePayment, tfLoanOverpayment),
+            payFee,
+            Ter(tecINSUFFICIENT_PAYMENT));
+        env.close();
+
+        auto const afterReject = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(afterReject.paymentRemaining == before.paymentRemaining);
+        BEAST_EXPECT(afterReject.principalOutstanding == before.principalOutstanding);
+        BEAST_EXPECT(afterReject.nextPaymentDate == before.nextPaymentDate);
+
+        // PaymentRemaining drops by one, NextPaymentDueDate advances by one interval, and
+        // PrincipalOutstanding strictly decreases (by more than a plain payment thanks to the
+        // extra).
+        STAmount const onePaymentPlusExtra = asset(5'000).value();
+        env(pay(borrower, loanKeylet.key, onePaymentPlusExtra, tfLoanOverpayment), payFee);
+        env.close();
+
+        auto const afterPay = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(afterPay.paymentRemaining == before.paymentRemaining - 1);
+        BEAST_EXPECT(afterPay.principalOutstanding < before.principalOutstanding);
+        BEAST_EXPECT(afterPay.nextPaymentDate == before.nextPaymentDate + before.paymentInterval);
+    }
+
     void
     testAccountSendMptMinAmountInvariant(FeatureBitset features)
     {
@@ -851,12 +932,175 @@ private:
             });
     }
 
+    void
+    testLoanSetRecipientScaleInvariant()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        auto const runCase = [&](bool coarseBorrower) {
+            testcase(
+                coarseBorrower ? "LoanSet borrower balance uses coarsest scale"
+                               : "LoanSet broker owner balance uses coarsest scale");
+
+            Env env(*this, all_ | featureLendingProtocolV1_1);
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            Number const coarseBalance{100'000'000'000LL};
+            Number const regularBalance{100'000'000};
+            PrettyAsset const asset = createFundedRippleIouAsset(
+                env,
+                issuer,
+                lender,
+                borrower,
+                coarseBorrower ? regularBalance : coarseBalance,
+                coarseBorrower ? coarseBalance : regularBalance);
+
+            BrokerParameters const brokerParams{
+                .vaultDeposit = 1'000'000,
+                .debtMax = 0,
+                .coverRateMin = TenthBips32{0},
+                .coverDeposit = 0,
+                .managementFeeRate = TenthBips16{0},
+                .coverRateLiquidation = TenthBips32{0}};
+            BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
+
+            Number const principal{1'012'345, -5};
+            Number const originationFee{123'456, -6};
+            Account const& recipient = coarseBorrower ? borrower : lender;
+            Number const expected = coarseBorrower ? principal : originationFee;
+            auto const before = env.balance(recipient, asset);
+
+            if (coarseBorrower)
+            {
+                env(set(borrower, broker.brokerID, principal),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+            }
+            else
+            {
+                env(set(borrower, broker.brokerID, principal),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    kLoanOriginationFee(originationFee),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+            }
+            env.close();
+
+            auto const after = env.balance(recipient, asset);
+            Number const received = after.number() - before.number();
+            auto const recipientScale =
+                std::max(before.value().exponent(), after.value().exponent());
+            auto const vaultScale = broker.vaultScale(env);
+            Number const tolerance{1, recipientScale};
+
+            BEAST_EXPECT(recipientScale > vaultScale);
+            BEAST_EXPECT(received != expected);
+            BEAST_EXPECT(
+                abs(roundToAsset(asset, received, recipientScale) -
+                    roundToAsset(asset, expected, recipientScale)) <= tolerance);
+        };
+
+        runCase(/*coarseBorrower=*/true);
+        runCase(/*coarseBorrower=*/false);
+    }
+
+    // Under featureLendingProtocolV1_1, ValidLoan::finalize enforces
+    //   TotalValueOutstanding >= PrincipalOutstanding + ManagementFeeOutstanding
+    // ("interest due is non-negative"). This test drives the transactor
+    // through a multi-payment scenario with a non-zero management fee and
+    // messy IOU-scale rounding; if any rounding path in LoanPay were to
+    // inflate PrincipalOutstanding or ManagementFeeOutstanding relative to
+    // TotalValueOutstanding by even one ULP, the invariant would fire and
+    // the LoanPay would return tecINVARIANT_FAILED instead of tesSUCCESS.
+    void
+    testLoanPayInterestDueNonNegativeInvariant()
+    {
+        testcase("LoanPay interest-due non-negative invariant");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_ | featureLendingProtocolV1_1);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
+
+        // Default broker params carry managementFeeRate = 100 tenth-bips
+        // (1%), which is what makes managementFeeOutstanding accumulate
+        // non-trivially through the payment schedule.
+        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        auto const loanKeylet = nextLoanKeylet(env, broker);
+
+        // Messy interest rate, non-trivial payment count. Values chosen so
+        // that periodicPayment and each roundedInterest/managementFee share
+        // are unlikely to be representable exactly at loanScale.
+        env(set(borrower, broker.brokerID, Number{1'000}),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32{24'346}),
+            kPaymentTotal(24),
+            kPaymentInterval(86400 * 30),
+            loanSetFee);
+        env.close();
+
+        auto const payFee = Fee(env.current()->fees().base * 2);
+        // Boundary check up front on the freshly-created loan.
+        {
+            auto const initial = getCurrentState(env, broker, loanKeylet);
+            BEAST_EXPECT(
+                initial.totalValue >=
+                initial.principalOutstanding + initial.managementFeeOutstanding);
+        }
+
+        // Six regular scheduled payments. If the invariant fires the
+        // Ter(tesSUCCESS) assertion below catches it; the identity check
+        // then re-asserts it in the test for a clearer failure message.
+        std::uint32_t prevPaymentRemaining = 24;
+        for (int i = 0; i < 6; ++i)
+        {
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            // Match the amount LoanPay expects for a scheduled payment:
+            // periodicPayment rounded at loanScale, plus the flat service
+            // fee (0 here by default, but included for robustness).
+            auto const payAmount = STAmount{
+                iouAsset,
+                roundPeriodicPayment(
+                    iouAsset, loanSle->at(sfPeriodicPayment), loanSle->at(sfLoanScale)) +
+                    loanSle->at(sfLoanServiceFee)};
+            env(pay(borrower, loanKeylet.key, payAmount), payFee, Ter(tesSUCCESS));
+            env.close();
+
+            auto const state = getCurrentState(env, broker, loanKeylet);
+            BEAST_EXPECT(
+                state.totalValue >= state.principalOutstanding + state.managementFeeOutstanding);
+            BEAST_EXPECT(state.paymentRemaining == prevPaymentRemaining - 1);
+            prevPaymentRemaining = state.paymentRemaining;
+        }
+    }
+
     // Tests run under each entry in amendmentCombinations().
     void
     runAmendmentSensitive(FeatureBitset features)
     {
         testLoanPayComputePeriodicPaymentInvariants(features);
         testLoanPayDebtDecreaseInvariant(features);
+        testLoanPayOverpaymentScheduleInvariant(features);
         testAccountSendMptMinAmountInvariant(features);
         testMinimumBrokerCoverConsistency(features);
     }
@@ -865,6 +1109,8 @@ public:
     void
     run() override
     {
+        testLoanSetRecipientScaleInvariant();
+        testLoanPayInterestDueNonNegativeInvariant();
         for (auto const& features : jtx::amendmentCombinations(
                  {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
             runAmendmentSensitive(features);
diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp
index 6cced5c97a..45420529c6 100644
--- a/src/test/app/lending/LoanLifecycle_test.cpp
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -205,8 +205,14 @@ private:
         if (!BEAST_EXPECT(!createJson.isMember(jss::Signers)))
             counterpartyJson[sfSigners] = createJson[sfSigners];
 
-        // The duplicated signature works
-        createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
+        // The duplicated signature does not work: the counterparty signs a
+        // different prefix than the account.
+        env(env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)),
+            Ter(telENV_RPC_FAILED));
+
+        // Signing the counterparty field itself works, even though the lender
+        // is both the borrower and the counterparty.
+        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
         env(createJson);
 
         env.close();
@@ -347,7 +353,11 @@ private:
             auto const& asset = debtMaximumRequest.asset();
             auto const initialVault = asset(debtMaximumRequest * 100);
 
-            auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim
+            // only accepts closed-ended vaults, so build one and advance
+            // past SubscriptionDate before creating broker/loan.
+            auto [tx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = broker, .asset = asset});
             env(tx, txFee);
             env.close();
 
@@ -356,6 +366,8 @@ private:
                 txFee);
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
 
diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp
index 2cb4f38ecf..7c4db3e2bf 100644
--- a/src/test/app/lending/LoanMisc_test.cpp
+++ b/src/test/app/lending/LoanMisc_test.cpp
@@ -113,21 +113,26 @@ private:
             txJson[sfTransactionType] = "AccountSet";
             txJson[sfAccount] = borrower.human();
 
-            auto const borrowerSignParams = [&]() {
-                json::Value params{json::ValueType::Object};
-                params[jss::passphrase] = borrowerPass;
-                params[jss::key_type] = "ed25519";
-                params[jss::signature_target] = "Destination";
-                params[jss::tx_json] = txJson;
-                return params;
-            }();
-            auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
-            BEAST_EXPECT(
-                jSignBorrower.isMember(jss::result) &&
-                jSignBorrower[jss::result].isMember(jss::error) &&
-                jSignBorrower[jss::result][jss::error] == "invalidParams" &&
-                jSignBorrower[jss::result].isMember(jss::error_message) &&
-                jSignBorrower[jss::result][jss::error_message] == "Destination");
+            // "Destination" is not an inner object at all. "Book" is one, but
+            // it holds no transaction signature, so it is not a target either.
+            for (char const* target : {"Destination", "Book", "Signer"})
+            {
+                auto const borrowerSignParams = [&]() {
+                    json::Value params{json::ValueType::Object};
+                    params[jss::passphrase] = borrowerPass;
+                    params[jss::key_type] = "ed25519";
+                    params[jss::signature_target] = target;
+                    params[jss::tx_json] = txJson;
+                    return params;
+                }();
+                auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
+                BEAST_EXPECT(
+                    jSignBorrower.isMember(jss::result) &&
+                    jSignBorrower[jss::result].isMember(jss::error) &&
+                    jSignBorrower[jss::result][jss::error] == "invalidParams" &&
+                    jSignBorrower[jss::result].isMember(jss::error_message) &&
+                    jSignBorrower[jss::result][jss::error_message] == target);
+            }
         }
         {
             testcase("RPC LoanSet - sign and submit borrower initiated");
@@ -473,14 +478,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..7cd31b7988 100644
--- a/src/test/app/lending/LoanPay_test.cpp
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -4,18 +4,26 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -28,6 +36,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -728,10 +738,762 @@ private:
         }
     }
 
+    // Which pseudo-account is left holding an unauthorized trust line when the
+    // repayment lands.
+    enum class UnauthorizedPayee {
+        // The vault's own line, as VaultCreate leaves it.
+        Vault,
+        // Same vault, but the issuer authorized the line by hand first.
+        VaultAuthorized,
+        // Vault line authorized, broker owner unable to take the fee, so the
+        // fee goes to the loan broker's pseudo-account instead.
+        Broker,
+    };
+
+    // A vault holding an IOU whose issuer requires authorization ends up with
+    // its own trust line unauthorized: VaultCreate opens the line without the
+    // auth flag, and the pseudo-account has no key to sign a TrustSet for
+    // itself. Neither deposits nor loan origination look at that line, so the
+    // vault appears to work right up to the first repayment, which is the only
+    // step that has to credit the vault back.
+    //
+    // The loan broker's pseudo-account has the same defect for the same reason,
+    // and LoanPay reaches it whenever the broker owner cannot take the fee.
+    //
+    // The issuer can still repair either line by hand, because TrustSet accepts
+    // a line that already exists even when its owner is a pseudo-account.
+    void
+    testRepayIntoUnauthorizedVault()
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        auto runTestCases = [&](FeatureBitset features, UnauthorizedPayee payee) {
+            bool const pseudoExempt = features[fixCleanup3_4_0];
+            // With the vault's line repaired by the issuer, the only remaining
+            // unauthorized payee is the broker's pseudo-account.
+            bool const expectSuccess = pseudoExempt || payee == UnauthorizedPayee::VaultAuthorized;
+
+            auto const payeeLabel = [payee]() -> char const* {
+                switch (payee)
+                {
+                    case UnauthorizedPayee::Vault:
+                        return "vault";
+                    case UnauthorizedPayee::VaultAuthorized:
+                        return "vault authorized by the issuer";
+                    case UnauthorizedPayee::Broker:
+                        return "loan broker";
+                }
+                return "";  // LCOV_EXCL_LINE
+            }();
+
+            testcase << "LoanPay crediting an unauthorized " << payeeLabel << ": pseudo-account "
+                     << (pseudoExempt ? "exempt" : "not exempt");
+
+            Env env{*this, features};
+
+            env.fund(XRP(1'000'000), issuer, lender, borrower);
+            env.close();
+
+            env(fset(issuer, asfRequireAuth));
+            env.close();
+
+            PrettyAsset const asset = issuer[iouCurrency_];
+            env(trust(lender, asset(100'000'000)));
+            env(trust(borrower, asset(100'000'000)));
+            env.close();
+
+            // Authorize the two participants. Nothing asks the issuer to also
+            // authorize the vault, which is the whole point of this test.
+            env(trust(issuer, asset(0), lender, tfSetfAuth));
+            env(trust(issuer, asset(0), borrower, tfSetfAuth));
+            env.close();
+
+            env(pay(issuer, lender, asset(10'000'000)));
+            env(pay(issuer, borrower, asset(10'000)));
+            env.close();
+
+            // Creating the vault and funding it with deposits succeeds even
+            // though the vault cannot be authorized to hold the asset.
+            BrokerInfo const broker{createVaultAndBroker(env, asset, lender)};
+
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            if (!BEAST_EXPECT(vaultSle && brokerSle))
+                return;
+
+            Account const vaultPseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+            Account const brokerPseudo{"broker pseudo-account", brokerSle->at(sfAccount)};
+
+            auto const lineIsAuthorized = [&](Account const& holder) -> bool {
+                auto const line = env.le(keylet::trustLine(holder, asset.raw().get()));
+                if (!BEAST_EXPECT(line))
+                    return false;
+                return line->isFlag(holder.id() > issuer.id() ? lsfLowAuth : lsfHighAuth);
+            };
+
+            BEAST_EXPECT(!lineIsAuthorized(vaultPseudo));
+            BEAST_EXPECT(!lineIsAuthorized(brokerPseudo));
+
+            if (payee != UnauthorizedPayee::Vault)
+            {
+                env(trust(issuer, asset(0), vaultPseudo, tfSetfAuth));
+                env.close();
+                BEAST_EXPECT(lineIsAuthorized(vaultPseudo));
+            }
+
+            using namespace loan;
+
+            // The service fee guarantees the broker is owed something on the
+            // first payment, so the broker leg of the transfer is exercised.
+            Number const serviceFee = asset(2).value();
+            auto const loanKeylet = nextLoanKeylet(env, broker);
+            env(set(borrower, broker.brokerID, asset(1'000).value()),
+                Sig(sfCounterpartySignature, lender),
+                kLoanServiceFee(serviceFee),
+                kInterestRate(percentageToTenthBips(12)),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            // Paying the principal out of the vault never needed authorization.
+            BEAST_EXPECT(env.le(loanKeylet));
+
+            if (payee == UnauthorizedPayee::Broker)
+            {
+                // A deep-frozen owner cannot take the fee, so LoanPay pays it
+                // into the broker's pseudo-account instead.
+                env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze));
+                env.close();
+            }
+
+            auto const state = getCurrentState(env, broker, loanKeylet);
+            STAmount const payment{
+                broker.asset,
+                roundPeriodicPayment(
+                    broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
+
+            // Repayment turns an outstanding loan back into cash the vault can
+            // lend again, so AssetsAvailable is what moves. AssetsTotal already
+            // counted the loan.
+            auto const assetsAvailable = [&]() -> Number {
+                auto const sle = env.le(broker.vaultKeylet());
+                if (!BEAST_EXPECT(sle))
+                    return Number{};
+                return sle->at(sfAssetsAvailable);
+            };
+
+            auto const borrowerBefore = env.balance(borrower, asset).number();
+            auto const vaultBefore = env.balance(vaultPseudo, asset).number();
+            auto const brokerBefore = env.balance(brokerPseudo, asset).number();
+            auto const assetsAvailableBefore = assetsAvailable();
+
+            env(pay(borrower, loanKeylet.key, payment),
+                Ter(expectSuccess ? TER{tesSUCCESS} : TER{tecNO_AUTH}));
+            env.close();
+
+            if (expectSuccess)
+            {
+                BEAST_EXPECT(env.balance(borrower, asset).number() < borrowerBefore);
+                BEAST_EXPECT(env.balance(vaultPseudo, asset).number() > vaultBefore);
+                BEAST_EXPECT(assetsAvailable() > assetsAvailableBefore);
+                // Confirms the broker variant really did route the fee to the
+                // pseudo-account rather than to the owner.
+                BEAST_EXPECT(
+                    (env.balance(brokerPseudo, asset).number() > brokerBefore) ==
+                    (payee == UnauthorizedPayee::Broker));
+
+                // The payee is skipped by the check, not authorized by it: the line that just
+                // took the credit is still missing its auth flag.
+                if (payee == UnauthorizedPayee::Vault)
+                    BEAST_EXPECT(!lineIsAuthorized(vaultPseudo));
+                if (payee == UnauthorizedPayee::Broker)
+                    BEAST_EXPECT(!lineIsAuthorized(brokerPseudo));
+            }
+            else
+            {
+                // A rejected repayment must leave every balance untouched.
+                BEAST_EXPECT(env.balance(borrower, asset).number() == borrowerBefore);
+                BEAST_EXPECT(env.balance(vaultPseudo, asset).number() == vaultBefore);
+                BEAST_EXPECT(env.balance(brokerPseudo, asset).number() == brokerBefore);
+                BEAST_EXPECT(assetsAvailable() == assetsAvailableBefore);
+            }
+        };
+
+        for (auto const& features : {all_, all_ - fixCleanup3_4_0})
+        {
+            runTestCases(features, UnauthorizedPayee::Vault);
+            runTestCases(features, UnauthorizedPayee::VaultAuthorized);
+            runTestCases(features, UnauthorizedPayee::Broker);
+        }
+    }
+
+    void
+    testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features)
+    {
+        // 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);
+    }
+
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate so that it matches the
+    // *current* (already fixed) parentCloseTime of the open ledger, without
+    // closing again. This exercises the same comparison
+    // (parentCloseTime vs. NextPaymentDueDate) at the exact boundary that
+    // env.close() cannot reliably reach.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
+    // With fixCleanup3_4_0, isPaymentLate() uses a strict (Exclusive)
+    // comparison: a payment due exactly "now" is not yet late. A plain
+    // (non-late) LoanPay submitted at the exact NextPaymentDueDate instant
+    // must therefore succeed, advance the due date by exactly one
+    // PaymentInterval, and charge only the regular periodic payment amount
+    // (no late interest / late fee).
+    void
+    testLoanPayAtExactDueDateSucceedsPostAmendment()
+    {
+        testcase("LoanPay at exact due date succeeds with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        // Set a large, non-zero late interest rate and late fee so that if
+        // the late-payment path were incorrectly taken, the extra charge
+        // would be large and easy to detect (far more than any rounding
+        // slack in the regular periodic payment amount).
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        STAmount const payFee{env.current()->fees().base};
+        auto const borrowerBefore = env.balance(borrower, asset).number();
+
+        // A plain payment (no tfLoanLatePayment) for exactly the regular
+        // periodic amount must succeed: at this instant the payment is not
+        // yet late.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le()/env.balance()
+        // do) reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Fee(payFee), Ter(tesSUCCESS));
+
+        auto const borrowerAfter = env.balance(borrower, asset).number();
+
+        // No more than the regular periodic amount (plus the transaction
+        // fee) was charged: if the late-payment path had wrongly been
+        // taken, the (large, non-zero) late interest and late fee set above
+        // would have pushed the charge well past this bound.
+        Number const charged = borrowerBefore - borrowerAfter - Number{payFee};
+        BEAST_EXPECT(charged > Number{});
+        BEAST_EXPECT(charged <= Number{roundedPeriodicPayment});
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - 1);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate + stateBefore.paymentInterval);
+    }
+
+    // Pins the amendment gate itself (as opposed to
+    // testLoanPayAtExactDueDateSucceedsPostAmendment, which pins the
+    // comparison operator): without fixCleanup3_4_0, isPaymentLate() keeps
+    // using the pre-amendment Inclusive comparison, so a payment due exactly
+    // "now" is already considered late, and a plain (non-late) LoanPay is
+    // rejected.
+    void
+    testLoanPayAtExactDueDateFailsPreAmendment()
+    {
+        testcase("LoanPay at exact due date fails without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Without the amendment, the due date is already considered late at
+        // this exact instant, so a plain payment must be rejected.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le() does)
+        // reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // computeLatePayment() must agree with isPaymentLate() at the exact
+    // due-date boundary: once fixCleanup3_4_0 is enabled, a payment due
+    // exactly "now" is not yet late, so a tfLoanLatePayment submitted at
+    // that same instant must be rejected with tecTOO_SOON rather than being
+    // admitted and charged the late interest/fee.
+    void
+    testLoanLatePaymentAtExactDueDateRejectedPostAmendment()
+    {
+        testcase("LoanPay(tfLoanLatePayment) at exact due date rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        // Overpay generously so that, if the late-payment path were
+        // incorrectly admitted, funds would not be the limiting factor;
+        // we want to isolate the timing check itself.
+        STAmount const generousAmount{
+            asset,
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) * 2};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // At this exact instant the loan is not yet late (Exclusive
+        // comparison), so even an explicit late payment must be rejected
+        // as premature, matching the plain-payment path.
+        //
+        // Note: deliberately not calling env.close() after this, for the
+        // same reason given in testLoanPayAtExactDueDateSucceedsPostAmendment
+        // above: closing would discard the direct NextPaymentDueDate
+        // override made via rawReplace().
+        env(pay(borrower, loanKeylet.key, generousAmount, tfLoanLatePayment), Ter(tecTOO_SOON));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // calculateBaseFee must use isPaymentLate(), not a raw inclusive
+    // hasExpired(): once fixCleanup3_4_0 is enabled, a plain catch-up at
+    // exactly NextPaymentDueDate succeeds and can process many payments, so
+    // the fee has to scale with that work. Charging a single base fee here
+    // would disagree with apply (and with the fixCleanup3_1_3 cap).
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePostAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+        BEAST_EXPECT(stateBefore.paymentRemaining > kLoanPaymentsPerFeeIncrement);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+        XRPAmount const escalatedFee{baseFee * (payCount / kLoanPaymentsPerFeeIncrement)};
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Under-fee: apply would process `payCount` payments, so a single
+        // base fee is not enough.
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(telINSUF_FEE_P));
+
+        // Same catch-up with the scaled fee must succeed at this instant.
+        // Do not env.close() after the SLE override (see
+        // testLoanPayAtExactDueDateSucceedsPostAmendment).
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(escalatedFee), Ter(tesSUCCESS));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - payCount);
+    }
+
+    // Without the amendment, inclusive hasExpired still treats the exact
+    // due-date instant as late, so calculateBaseFee correctly charges a
+    // single base fee and apply rejects a plain LoanPay with tecEXPIRED.
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePreAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // LoanPay does not call canAddHolding. addEmptyHolding recreates the
+    // broker-owner holding when the borrower is also the broker owner. After
+    // fixCleanup3_4_0 an existing line is a no-op even if DefaultRipple is
+    // off; pre-fix that path dies with tecINTERNAL.
+    void
+    testLoanPaySelfBrokerExistingLineDefaultRipple()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        auto run = [this](FeatureBitset features, TER expected) {
+            testcase(
+                std::string(
+                    "LoanPay broker-owner borrower existing line after "
+                    "issuer clears asfDefaultRipple (") +
+                (features[fixCleanup3_4_0] ? "post" : "pre") + "-fixCleanup3_4_0)");
+
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(10'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(alice, usd(10'000'000)));
+            env.close();
+            env(pay(issuer, alice, usd(2'000'000)));
+            env.close();
+
+            auto const broker = createVaultAndBroker(env, usd, alice);
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+            Number const serviceFee = usd(2).value();
+            env(set(alice, broker.brokerID, usd(1'000).value()),
+                Sig(sfCounterpartySignature, alice),
+                kLoanServiceFee(serviceFee),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usd.raw().get())));
+
+            auto const state = getCurrentState(env, broker, loanKeylet);
+            STAmount const payment{
+                usd,
+                roundPeriodicPayment(usd, state.periodicPayment + serviceFee, state.loanScale)};
+
+            env(pay(alice, loanKeylet.key, payment), Ter(expected));
+            env.close();
+        };
+
+        run(all_ - fixCleanup3_4_0, tecINTERNAL);
+        run(all_, tesSUCCESS);
+    }
+
     void
     runAmendmentIndependent()
     {
         testLoanSetNearZeroInterestRateSucceeds();
+        testLoanPayAtExactDueDateSucceedsPostAmendment();
+        testLoanPayAtExactDueDateFailsPreAmendment();
+        testLoanLatePaymentAtExactDueDateRejectedPostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePreAmendment();
+        testRepayIntoUnauthorizedVault();
+        testLoanPaySelfBrokerExistingLineDefaultRipple();
     }
 
     // Tests run under each entry in amendmentCombinations().
@@ -741,6 +1503,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..ded1c816a2 100644
--- a/src/test/app/lending/LoanRounding_test.cpp
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -889,6 +889,261 @@ 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.
+        advancePastDueDate(env, loanKeylet);
+        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();
+
+            advancePastDueDate(env, iouLoanKeylet);
+            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 +1221,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/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index 21772d0617..54c972b505 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -5,27 +5,37 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -33,6 +43,30 @@ namespace xrpl::test {
 class LoanSecurity_test : public LoanTestBase
 {
 private:
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate instead, without closing the
+    // ledger again.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
     void
     testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
     {
@@ -411,13 +445,17 @@ private:
         Account const depositor{"depositor"};
         auto const txFee = Fee(XRP(100));
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one and advance past
+        // SubscriptionDate before creating the broker and the loan.
         Env env(*this);
         Vault const vault(env);
 
         env.fund(XRP(10'000), lender, issuer, borrower, depositor);
         env.close();
 
-        auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        auto [tx, vaultKeyLet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(tx, txFee);
         env.close();
 
@@ -425,6 +463,10 @@ private:
             txFee);
         env.close();
 
+        // Move into the Investment phase before creating the broker and
+        // the loan.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeyLet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
@@ -508,10 +550,622 @@ private:
             PaymentParameters{.showStepBalances = true});
     }
 
+    // Verify that with fixCleanup3_4_0:
+    // 1. A loan cannot be impaired before its payment is late.
+    // 2. Impairing a late loan does not change sfNextPaymentDueDate.
+    // 3. The unimpair operation does not change sfNextPaymentDueDate.
+    void
+    testImpairmentPaymentDateUnchanged()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impairment does not change payment due date");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // 1. Impairment must fail when payment is not yet late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 1b. Impairment must still fail at the exact due date instant: a
+        // payment due "now" is not yet late (strict/Exclusive comparison).
+        // Temporarily set NextPaymentDueDate to exactly the current
+        // parentCloseTime (without closing the ledger again), exercise the
+        // check, then restore the original due date.
+        {
+            std::uint32_t const exactNow =
+                env.current()->parentCloseTime().time_since_epoch().count();
+            setLoanNextPaymentDueDate(env, loanKeylet, exactNow);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+            setLoanNextPaymentDueDate(env, loanKeylet, originalNextDueDate);
+        }
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        // 2. Impairment succeeds when payment is late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 3. Unimpair also does not change sfNextPaymentDueDate
+        env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+    }
+
+    // Verify that without fixCleanup3_4_0, the pre-amendment
+    // impair/unimpair behaviour is preserved:
+    // 1. Impairing a loan before its payment is late moves
+    //    sfNextPaymentDueDate to "now".
+    // 2a. Unimpair within the original payment interval restores
+    //     sfNextPaymentDueDate to StartDate + PaymentInterval.
+    // 2b. Unimpair after the original due date sets
+    //     sfNextPaymentDueDate to now + PaymentInterval.
+    void
+    testImpairmentPaymentDatePreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Pre-amendment impair/unimpair date restoration");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        Number const principalRequest{1, 3};
+        auto createNewLoan = [&]() {
+            auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(sleBroker))
+                return keylet::loan(uint256{});
+            auto const lk =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+            env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+                Sig(sfCounterpartySignature, lender),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+            return lk;
+        };
+
+        // Default + delete a loan and replenish first-loss capital so the
+        // broker is ready for the next loan.
+        auto cleanupLoan = [&](Keylet const& loanKeylet, std::uint32_t dueDate) {
+            env.close(NetClock::time_point{NetClock::duration{dueDate + 60}} + 1s);
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+            env.close();
+
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+            auto const coverNeeded =
+                broker.asset(broker.params.coverDeposit).value() - brokerSle->at(sfCoverAvailable);
+            if (coverNeeded > 0)
+            {
+                env(loan_broker::coverDeposit(
+                    lender, broker.brokerID, STAmount{broker.asset, coverNeeded}));
+                env.close();
+            }
+            env(del(lender, loanKeylet.key));
+            env.close();
+        };
+
+        // ---- Case A: impair before late, unimpair within original interval ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            // Payment is not late yet - impair succeeds and moves due date
+            // to now (pre-amendment allows immediate impairment)
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const movedDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(movedDueDate != originalNextDueDate);
+                BEAST_EXPECT(movedDueDate < originalNextDueDate);
+            }
+
+            // Unimpair while still within the original payment interval. The
+            // normal due date (startDate + 600) has not yet expired, so it
+            // should be restored.
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+            }
+
+            cleanupLoan(loanKeylet, originalNextDueDate);
+        }
+
+        // ---- Case B: impair before late, unimpair after original due date ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 10s);
+
+            auto const timeBeforeUnimpair =
+                env.current()->header().parentCloseTime.time_since_epoch().count();
+
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const newDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(newDueDate > originalNextDueDate);
+                BEAST_EXPECT(newDueDate == timeBeforeUnimpair + 600);
+            }
+        }
+    }
+
+    // FN-68: a borrower must not be able to bypass late-payment charges by
+    // paying an impaired, overdue loan with a plain LoanPay. Under
+    // fixCleanup3_4_0 impairment no longer moves the due date, so
+    // the payment logic sees the real (overdue) date: a regular payment is
+    // rejected with tecEXPIRED, and only a tfLoanLatePayment (which charges
+    // the late fee + late interest) is accepted.
+    void
+    testImpairedOverdueLoanPayRequiresLateFlag()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay requires late-payment flag");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        // Loan with non-zero late-payment terms, so the late path carries a
+        // real penalty that the exploit would otherwise avoid.
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        std::uint32_t const paymentsBefore = loanSle->at(sfPaymentRemaining);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // Advance past the due date so the loan is overdue, then impair it
+        // (impairment is only allowed once the payment is late).
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue
+        // loan must be rejected. Before FN-9 the auto-unimpair pushed the due
+        // date into the future and this returned tesSUCCESS, letting the
+        // borrower skip the late fee and late interest.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
+        }
+
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+        }
+    }
+
+    // FN-68 (pre-amendment): documents the original vulnerability. Without
+    // fixCleanup3_4_0, impairing moves the due date and LoanPay
+    // auto-unimpair pushes it into the future before the late check, so a
+    // plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
+    // on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
+    // This is what testImpairedOverdueLoanPayRequiresLateFlag closes once the
+    // amendment is enabled.
+    void
+    testImpairedOverdueLoanPayBypassPreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay bypass (pre-amendment)");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The bug: a plain LoanPay is accepted as on-time and clears the
+        // loan's impaired flag, so the late fee / late interest are never
+        // charged.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+        }
+    }
+
+    // Default uses NextPaymentDueDate + GracePeriod. Once fixCleanup3_4_0
+    // is enabled, that gate is Exclusive, matching impair/isPaymentLate:
+    // default is allowed only after grace has passed, not at the instant
+    // it expires.
+    void
+    testLoanDefaultAtExactGraceExpiryRejectedPostAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Advance far enough that parentCloseTime > GracePeriod, so
+        // (now - grace) cannot underflow when pinning the exact expiry.
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace + 1);
+
+        // parentCloseTime == NextPaymentDueDate + GracePeriod: grace expires
+        // this instant, so default must still be too soon.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanDefault));
+        }
+
+        // One second after grace expires, default succeeds.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace - 1);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
+    void
+    testLoanDefaultAtExactGraceExpirySucceedsPreAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry succeeds without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace);
+
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
+    // Every signature on a transaction covered the same bytes before
+    // fixCleanup3_4_0, so a signature could be moved from the role that made
+    // it into another role. Here the lender signs a LoanSet as the
+    // counterparty, and the borrower copies that signature into the
+    // SponsorSignature, making the lender pay the fee without the lender ever
+    // agreeing to sponsor it.
+    void
+    testSignatureCopiedBetweenRoles(bool fixEnabled)
+    {
+        testcase(
+            std::string("Counterparty signature copied into the sponsor slot") +
+            (fixEnabled ? "" : " (pre-amendment)"));
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, fixEnabled ? all_ : all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0) == fixEnabled);
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const feeAmt = XRP(1);
+
+        // The lender agrees to the loan by signing the Counterparty slot of a
+        // LoanSet that names the lender as the fee sponsor. The lender signs
+        // nothing else.
+        auto loanSet = env.json(
+            set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            sponsor::As(lender, spfSponsorFee),
+            Sig(sfCounterpartySignature, lender),
+            Fee(feeAmt));
+
+        // The borrower copies the lender's signature into the sponsor slot.
+        loanSet[sfSponsorSignature.jsonName] = loanSet[sfCounterpartySignature.jsonName];
+
+        auto const lenderBalance = env.balance(lender);
+        auto const borrowerBalance = env.balance(borrower);
+
+        env(loanSet, Ter(fixEnabled ? TER{telENV_RPC_FAILED} : TER{tesSUCCESS}));
+        env.close();
+
+        if (fixEnabled)
+        {
+            // The copied signature does not verify in the sponsor slot, so
+            // nothing happens at all.
+            BEAST_EXPECT(env.balance(lender) == lenderBalance);
+            BEAST_EXPECT(env.balance(borrower) == borrowerBalance);
+        }
+        else
+        {
+            // The lender paid the fee, and the borrower got the loan.
+            BEAST_EXPECT(env.balance(lender) == lenderBalance - feeAmt);
+            BEAST_EXPECT(env.balance(borrower).value() > borrowerBalance.value());
+        }
+    }
+
     void
     runAmendmentIndependent()
     {
+        testSignatureCopiedBetweenRoles(true);
+        testSignatureCopiedBetweenRoles(false);
         testRIPD3901();
+        testImpairmentPaymentDateUnchanged();
+        testImpairmentPaymentDatePreAmendment();
+        testImpairedOverdueLoanPayRequiresLateFlag();
+        testImpairedOverdueLoanPayBypassPreAmendment();
+        testLoanDefaultAtExactGraceExpiryRejectedPostAmendment();
+        testLoanDefaultAtExactGraceExpirySucceedsPreAmendment();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp
index 85528ee9a0..5eea6f83fe 100644
--- a/src/test/app/lending/LoanSet_test.cpp
+++ b/src/test/app/lending/LoanSet_test.cpp
@@ -13,20 +13,25 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 
@@ -592,6 +597,237 @@ 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;
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        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 fewer than
+        // kLoanRedemptionBuffer seconds before RedemptionDate. Use a tight redemptionOffset and a
+        // schedule whose final payment is well past that boundary.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u;
+            auto const broker = createVaultAndBroker(
+                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: a finalPayment exactly kLoanRedemptionBuffer seconds before
+        // RedemptionDate is accepted; one second later is rejected. Uses payTotal = 1 so
+        // finalPayment = startDate + interval.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+
+            auto const startDate = env.now().time_since_epoch().count();
+            auto const acceptInterval = *broker.redemptionDate - kLoanRedemptionBuffer - 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 - (kLoanRedemptionBuffer - 1) -
+                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();
+        });
+
+        // 6. A vault whose Investment window is exactly kMinInvestmentPeriod can originate a
+        // minimum-interval, single-payment loan at the start of Investment, and rejects the same
+        // schedule once StartDate no longer leaves kLoanRedemptionBuffer before RedemptionDate.
+        // Do not pin an unrounded wall-clock instant: Env::close rounds to the close-time
+        // resolution. Read env.now() (the same clock LoanSet::preclaim uses) and assert the
+        // buffer relationship before each LoanSet.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env,
+                asset,
+                lender,
+                BrokerParameters{
+                    .vaultKind = VaultKind::ClosedEnded,
+                    .subscriptionOffset = 300u,
+                    .redemptionOffset = kMinInvestmentPeriod,
+                    .skipPhaseAdvance = true});
+            BEAST_EXPECT(broker.subscriptionDate.has_value());
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+
+            auto const red = *broker.redemptionDate;
+            auto const startDate = [&]() { return env.now().time_since_epoch().count(); };
+            auto const minLoan = [&](TER expected) {
+                env(set(lender, broker.brokerID, broker.asset(100).value()),
+                    kCounterparty(borrower),
+                    Sig(sfCounterpartySignature, borrower),
+                    Fee(env.current()->fees().base * 5),
+                    kPaymentTotal(1u),
+                    kPaymentInterval(LoanSet::kMinPaymentInterval),
+                    Ter(expected));
+                env.close();
+            };
+
+            // First Investment ledger: the minimum schedule still clears the buffer.
+            env.close(tp{d{*broker.subscriptionDate + 1}});
+            BEAST_EXPECT(startDate() > *broker.subscriptionDate);
+            BEAST_EXPECT(startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red);
+            minLoan(tesSUCCESS);
+
+            // Still Investment, but the minimum schedule no longer clears the buffer.
+            while (startDate() + LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer <= red)
+                env.close();
+            BEAST_EXPECT(startDate() < red);
+            minLoan(tecNO_PERMISSION);
+        });
+    }
+
+    // LoanSet used to call canAddHolding unconditionally, so an existing
+    // borrower line still failed with terNO_RIPPLE after the issuer cleared
+    // DefaultRipple. After fixCleanup3_4_0, skip that gate when the holding
+    // already exists.
+    void
+    testLoanSetExistingLineAfterIssuerClearsDefaultRipple()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        auto run = [this](FeatureBitset features, TER expected) {
+            testcase(
+                std::string(
+                    "LoanSet existing borrower line after issuer "
+                    "clears asfDefaultRipple (") +
+                (features[fixCleanup3_4_0] ? "post" : "pre") + "-fixCleanup3_4_0)");
+
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(10'000), issuer, lender, borrower);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(lender, usd(10'000'000)));
+            env(trust(borrower, usd(10'000'000)));
+            env.close();
+            env(pay(issuer, lender, usd(2'000'000)));
+            env(pay(issuer, borrower, usd(1'000)));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::trustLine(borrower.id(), usd.raw().get())));
+
+            auto const broker = createVaultAndBroker(env, usd, lender);
+
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+
+            Number const destBefore = env.balance(borrower, usd.raw()).number();
+            env(set(borrower, broker.brokerID, usd(100).value()),
+                Sig(sfCounterpartySignature, lender),
+                Fee(env.current()->fees().base * 2),
+                Ter(expected));
+            env.close();
+
+            Number const destAfter = env.balance(borrower, usd.raw()).number();
+            if (isTesSuccess(expected))
+            {
+                BEAST_EXPECT(destAfter == destBefore + Number{100});
+            }
+            else
+            {
+                BEAST_EXPECT(destAfter == destBefore);
+            }
+        };
+
+        run(all_ - fixCleanup3_4_0, terNO_RIPPLE);
+        run(all_, tesSUCCESS);
+    }
+
 public:
     void
     run() override
@@ -599,6 +835,9 @@ public:
         for (auto const& features : jtx::amendmentCombinations(
                  {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
             testLoanSet(features);
+
+        testLoanSetClosedEnded();
+        testLoanSetExistingLineAfterIssuerClearsDefaultRipple();
     }
 };
 
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index dabdfc9bed..13dffb6b9e 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:
@@ -91,10 +101,31 @@ protected:
         TenthBips32 coverRateLiquidation = percentageToTenthBips(25);
         std::string data = {};  // NOLINT(readability-redundant-member-init)
         std::uint32_t flags = 0;
+        // VaultCreate flags (e.g. tfVaultPrivate). Distinct from `flags`,
+        // which are passed to LoanBrokerSet.
+        std::optional vaultFlags =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
         // If set, the vault is created with this sfScale value. Useful for
         // tests that need finer loanScale to exercise rounding edge cases.
         std::optional vaultScale =
             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 precede RedemptionDate by at least kLoanRedemptionBuffer). Default
+        // sized to comfortably exceed any schedule realistic tests are likely to configure.
+        std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u;
+        // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate.
+        // Useful for tests that need to observe the vault while it is still in the Subscription
+        // phase. Ignored for open-ended vaults.
+        bool skipPhaseAdvance = false;
 
         [[nodiscard]] Number
         maxCoveredLoanValue(Number const& currentDebt) const
@@ -122,15 +153,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 +500,42 @@ protected:
 
         auto const coverRateMinValue = params.coverRateMin;
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults. Many callers of this
+        // helper leave vaultKind at the OpenEnded default and don't care
+        // about the vault kind per se — they just need a broker on a
+        // vault. When LP V1.1 is enabled, transparently promote to
+        // ClosedEnded so those tests keep working without threading
+        // vaultKind through every call site. Callers that explicitly
+        // asked for ClosedEnded are left untouched. Tests that want to
+        // exercise the open-ended rejection under LP V1.1 build their own
+        // vault directly instead of going through this helper, since it
+        // always promotes OpenEnded once the amendment is enabled.
+        auto effectiveVaultKind = params.vaultKind;
+        if (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+            effectiveVaultKind == VaultKind::OpenEnded)
+        {
+            effectiveVaultKind = VaultKind::ClosedEnded;
+        }
+
+        std::optional subscriptionDate;
+        std::optional redemptionDate;
+        if (effectiveVaultKind == 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,
+             .flags = params.vaultFlags,
+             .vaultKind = effectiveVaultKind == VaultKind::OpenEnded
+                 ? std::optional{}
+                 : std::optional{std::to_underlying(effectiveVaultKind)},
+             .subscriptionDate = subscriptionDate,
+             .redemptionDate = redemptionDate});
         if (params.vaultScale)
             tx[sfScale] = *params.vaultScale;
         env(tx);
@@ -475,6 +549,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 +573,7 @@ protected:
 
         env.close();
 
-        return {asset, keylet, vaultKeylet, params};
+        return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate};
     }
 
     /**
@@ -596,6 +679,23 @@ protected:
         return true;
     }
 
+    // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with tecTOO_SOON
+    // unless the loan payment is already late. Advance the ledger past the
+    // loan's sfNextPaymentDueDate so shared lifecycle flows still exercise
+    // the tesSUCCESS branch when the amendment is active. No-op when the
+    // amendment is disabled.
+    void
+    advancePastDueDate(jtx::Env& env, Keylet const& loanKeylet)
+    {
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+            return;
+        auto const loan = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loan))
+            return;
+        std::uint32_t const dueDate = loan->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+    }
+
     enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
 
     // Specify the accounts as params to allow other accounts to be used
@@ -1514,12 +1614,30 @@ protected:
 
         // Check the vault
         bool const canImpair = canImpairLoan(env, broker, state);
-        // Impair the loan, if possible
-        env(manage(lender, keylet.key, tfLoanImpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-        // Unimpair the loan
-        env(manage(lender, keylet.key, tfLoanUnimpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        // Under fixCleanup3_4_0, impair rejects a not-yet-late loan with
+        // tecTOO_SOON. Advancing time to satisfy the gate here would push
+        // the loan into a "late" state and break the toEndOfLife flows
+        // (singlePayment/fullPayment) that expect a fresh loan without the
+        // tfLoanLatePayment flag. The tesSUCCESS/tecLIMIT_EXCEEDED impair
+        // path is already covered under fixCleanup3_4_0 by dedicated tests
+        // in LoanSecurity_test.cpp and LoanCashBasis_test.cpp.
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            // Impair the loan, if possible
+            env(manage(lender, keylet.key, tfLoanImpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
+            // Unimpair the loan
+            env(manage(lender, keylet.key, tfLoanUnimpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        }
+        else
+        {
+            // With the fix on, a not-yet-late loan can never be impaired
+            // (tecTOO_SOON) and the follow-up unimpair on an unimpaired
+            // loan is still tecNO_PERMISSION.
+            env(manage(lender, keylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+            env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+        }
 
         auto const nextDueDate = startDate + *loanParams.payInterval;
 
@@ -2110,6 +2228,11 @@ protected:
                 {
                     // Check the vault
                     bool const canImpair = canImpairLoan(env, broker, state);
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. Advance past the loan's next due
+                    // date so this exercises the tesSUCCESS branch. No-op
+                    // when the fix is disabled.
+                    advancePastDueDate(env, loanKeylet);
                     // Impair the loan, if possible
                     env(manage(lender, loanKeylet.key, tfLoanImpair),
                         canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
@@ -2117,7 +2240,11 @@ protected:
                     if (canImpair)
                     {
                         state.flags |= tfLoanImpair;
-                        state.nextPaymentDate = env.now().time_since_epoch().count();
+                        // Prior to fixCleanup3_4_0 impair rewrote
+                        // sfNextPaymentDueDate to parentCloseTime. Under the
+                        // fix, the due date is preserved.
+                        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+                            state.nextPaymentDate = env.now().time_since_epoch().count();
 
                         // Once the loan is impaired, it can't be impaired again
                         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
@@ -2737,7 +2864,17 @@ protected:
 
                     auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
 
-                    if (canImpairLoan(env, broker, state))
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. This periodic-payment loop stays
+                    // within each payment interval, so the loan is never
+                    // late here; skip the impair rather than perturb the
+                    // payment schedule.
+                    auto const loanSle = env.le(loanKeylet);
+                    bool const impairAllowed = BEAST_EXPECT(loanSle) &&
+                        canImpairLoan(env, broker, state) &&
+                        (!env.current()->rules().enabled(fixCleanup3_4_0) ||
+                         isPaymentLate(*env.current(), loanSle));
+                    if (impairAllowed)
                     {
                         // Making a payment will unimpair the loan
                         env(manage(lender, loanKeylet.key, tfLoanImpair));
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index 884384db55..566633b690 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -6,13 +6,13 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -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();
@@ -340,7 +344,12 @@ private:
         env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
         env.close();
 
-        // The payment is late by this point
+        // The payment is late by this point. With fixCleanup3_4_0,
+        // isPaymentLate() uses a strict (Exclusive) comparison, so advance
+        // one more ledger close to be sure the due date instant itself has
+        // passed, not merely reached.
+        env.close();
+
         env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
         env.close();
         env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
@@ -512,30 +521,85 @@ private:
         auto const loanSetFee = Fee(env.current()->fees().base * 2);
         Number const principalRequest{1, 3};
 
-        auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee));
-
-        json::Value counterpartyJson{json::ValueType::Object};
-        counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature];
-        counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey];
-        if (!BEAST_EXPECT(!createJson.isMember(jss::Signers)))
-            counterpartyJson[sfSigners] = createJson[sfSigners];
-
-        createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
+        // The lender is both the borrower and the counterparty here, but the
+        // two roles sign different bytes, so each signature must be made for
+        // the field it goes into.
+        auto const createJson = env.json(
+            set(lender, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            Fee(loanSetFee));
         env(createJson);
 
         env.close();
     }
 
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+    // attaching a broker to an open-ended vault. VaultCreate itself is
+    // not gated by the amendment, so the same open-ended vault can be
+    // built under either feature set; only the broker create is
+    // amendment-sensitive. Cover both branches: LP V1.1 disabled lets
+    // the broker create succeed, LP V1.1 enabled rejects it. The gate
+    // only fires on the create path; existing brokers keep working.
+    void
+    testLoanBrokerRequiresClosedEndedVault()
+    {
+        testcase("LoanBrokerSet requires closed-ended vault under LP V1.1");
+        using namespace jtx;
+
+        Account const owner{"lp11_owner"};
+
+        auto const build = [&](FeatureBitset features,
+                               TER expected,
+                               std::optional updateExpected = std::nullopt) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = XRP(100)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(loan_broker::set(owner, vaultKeylet.key), Ter(expected));
+            env.close();
+
+            // The create-path gate is the only new check; updates to an
+            // existing broker on the same open-ended vault are not
+            // affected. Only exercise the update path when the create
+            // succeeded (so there is a broker to update).
+            if (updateExpected && expected == tesSUCCESS)
+            {
+                env(loan_broker::set(owner, vaultKeylet.key),
+                    loan_broker::kLoanBrokerId(brokerKeylet.key),
+                    loan_broker::kDebtMaximum(XRP(1'000).value()),
+                    Ter(*updateExpected));
+                env.close();
+            }
+        };
+
+        // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds.
+        build(all_, tesSUCCESS, tesSUCCESS);
+
+        // LP V1.1 enabled -> open-ended vault + broker rejected on create.
+        build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION);
+    }
+
     void
     runAmendmentIndependent()
     {
         testDisabled();
-        testInvalidLoanSet();
+        for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded})
+            testInvalidLoanSet(kind);
         testInvalidLoanDelete();
         testInvalidLoanManage();
         testInvalidLoanPay();
         testRequireAuth();
         testLimitExceeded();
+        testLoanBrokerRequiresClosedEndedVault();
     }
 
     // Tests run under each entry in amendmentCombinations().
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/tx/apply_test.cpp b/src/test/app/tx/apply_test.cpp
index 8f71d47fcf..39289a6264 100644
--- a/src/test/app/tx/apply_test.cpp
+++ b/src/test/app/tx/apply_test.cpp
@@ -1,12 +1,23 @@
 // Copyright (c) 2020 Dev Null Productions
 
+#include 
 #include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -22,6 +33,136 @@ public:
     {
         testcase("Require Fully Canonical Signature");
         testFullyCanonicalSigs();
+        testRoleSignatureCacheIsEraSpecific();
+        testForcedValidityIgnoresPrefixEra();
+    }
+
+    // forceValidity means the caller verified nothing and wants the result
+    // trusted, so it has to hold in both prefix eras. If it marked only the
+    // ordinary slot, a role-signature transaction would still be verified
+    // under pre-fix rules, defeating the cluster path and the configurations
+    // that turn signature checks off.
+    void
+    testForcedValidityIgnoresPrefixEra()
+    {
+        testcase("Forced validity ignores the prefix era");
+
+        using namespace test::jtx;
+
+        Env preFix{*this, testableAmendments() - fixCleanup3_4_0};
+        Env postFix{*this, testableAmendments()};
+        auto const preFixRules = preFix.current()->rules();
+
+        Account const alice{"alice"};
+        Account const sponsor{"sponsor"};
+        postFix.fund(XRP(10'000), alice, sponsor);
+        postFix.close();
+
+        // Signed under the post-fix rules, so this signature does not verify
+        // under the pre-fix prefix. Only the forced verdict can make the check
+        // below pass.
+        auto const jt = postFix.jt(
+            noop(alice),
+            Fee(XRP(1)),
+            sponsor::As(sponsor, spfSponsorFee),
+            Sig(sfSponsorSignature, sponsor));
+        if (!BEAST_EXPECT(jt.stx))
+            return;
+
+        // A router that has never seen this transaction, so the only cached
+        // state is what forceValidity writes.
+        auto& router = preFix.app().getHashRouter();
+        forceValidity(router, jt.stx->getTransactionID(), Validity::SigGoodOnly);
+        BEAST_EXPECT(checkValidity(router, *jt.stx, preFixRules).first != Validity::SigBad);
+    }
+
+    // A signature verdict reached under one prefix era must not be honored in
+    // the other, because the two eras require the sponsor signature to cover
+    // different bytes. Each direction below uses one HashRouter and differs
+    // only in the rules, which is what the flag ledger looks like in practice:
+    // relay and submit verify against the validated rules, which lag the open
+    // ledger rules that preflight2 verifies against, so one transaction gets
+    // checked under both prefixes at the same time.
+    void
+    testRoleSignatureCacheIsEraSpecific()
+    {
+        testcase("Role signature cache is era specific");
+
+        using namespace test::jtx;
+
+        Env preFix{*this, testableAmendments() - fixCleanup3_4_0};
+        Env postFix{*this, testableAmendments()};
+        auto const preFixRules = preFix.current()->rules();
+        auto const postFixRules = postFix.current()->rules();
+
+        Account const alice{"alice"};
+        Account const sponsor{"sponsor"};
+        Account const counterparty{"counterparty"};
+        for (auto* env : {&preFix, &postFix})
+        {
+            env->fund(XRP(10'000), alice, sponsor, counterparty);
+            env->close();
+        }
+
+        // Both directions for a transaction whose role signature sits in the
+        // field that makeTx signs. makeTx builds the transaction in the Env it
+        // is given, so the role signature carries that era's prefix.
+        auto checkBothDirections = [&](std::function const& makeTx) {
+            // Direction 1: a good verdict under the old prefix must not let a
+            // signature moved between roles survive the amendment.
+            {
+                auto const jt = makeTx(preFix);
+                if (!BEAST_EXPECT(jt.stx))
+                    return;
+
+                auto& router = preFix.app().getHashRouter();
+                BEAST_EXPECT(checkValidity(router, *jt.stx, preFixRules).first == Validity::Valid);
+
+                // Same router, asked again under the post-fix rules. The Valid
+                // verdict above was reached under the old prefix and must not
+                // be reused, or a signature moved between roles would survive
+                // the amendment.
+                BEAST_EXPECT(
+                    checkValidity(router, *jt.stx, postFixRules).first == Validity::SigBad);
+            }
+
+            // Direction 2: a bad verdict under the old prefix must not condemn
+            // a transaction that the new prefixes accept. A node whose
+            // validated rules still lag the open ledger will run this check
+            // pre-fix first and reject a correctly new-prefix-signed
+            // transaction; the post-fix check must then verify it afresh
+            // instead of reusing the pre-fix verdict.
+            {
+                auto const jt = makeTx(postFix);
+                if (!BEAST_EXPECT(jt.stx))
+                    return;
+
+                auto& router = postFix.app().getHashRouter();
+                BEAST_EXPECT(checkValidity(router, *jt.stx, preFixRules).first == Validity::SigBad);
+                BEAST_EXPECT(checkValidity(router, *jt.stx, postFixRules).first == Validity::Valid);
+            }
+        };
+
+        // sfSponsorSignature, which uses the SPN and SPM prefixes.
+        checkBothDirections([&](Env& env) {
+            return env.jt(
+                noop(alice),
+                Fee(XRP(1)),
+                sponsor::As(sponsor, spfSponsorFee),
+                Sig(sfSponsorSignature, sponsor));
+        });
+
+        // sfCounterpartySignature, which uses its own prefixes, CPT and CPM,
+        // and only appears on a LoanSet. The transaction does not have to be
+        // applicable: checkValidity verifies signatures without consulting the
+        // ledger, so a placeholder LoanBrokerID is enough.
+        checkBothDirections([&](Env& env) {
+            return env.jt(
+                loan::set(alice, uint256{1}, Number{1}),
+                loan::kCounterparty(counterparty),
+                Fee(XRP(1)),
+                Sig(sfCounterpartySignature, counterparty));
+        });
     }
 
     void
diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
new file mode 100644
index 0000000000..cc30bd6091
--- /dev/null
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -0,0 +1,2776 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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();
+        };
+
+        {
+            // fixCleanup3_4_0 has to be off as well: its depositor-side check
+            // rejects alice's deposit for the same reason, so the invariant is
+            // only reachable with neither guard in place.
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(pre-fixCleanup3_2_0)");
+            // Also remove fixCleanup3_4_0 so the VaultDeposit clamp
+            // introduced by that amendment does not short-circuit this
+            // pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS.
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // A deposit does not transfer the requested amount. It transfers the
+    // request truncated to a whole number of shares and converted back, which
+    // can be strictly smaller. When that smaller value is below half a ULP at
+    // the depositor's own trust-line scale, the debit rounds away to nothing:
+    // the depositor pays nothing, while the vault books the assets and mints
+    // shares. ValidVault catches the desync at finalize time.
+    //
+    // Only a non-power-of-ten assets-to-shares ratio is needed, and that
+    // happens through ordinary use: LoanPay books accrued interest into
+    // sfAssetsTotal without minting shares.
+    //
+    // The fixCleanup3_2_0 guard in preclaim does not help, because it tests the
+    // raw requested amount, which is large enough to survive the rounding.
+    // Post-fixCleanup3_4_0 the post-truncation value is checked as well and the
+    // deposit is rejected with tecPRECISION_LOSS before anything moves.
+    void
+    testBugDepositShareTruncationSubUlp()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        // How bob's trust line is set up before he deposits. Holding is the plain case: a large
+        // positive balance whose ULP swallows the debit. InDebt is the case where the stored
+        // balance and the spendable amount diverge: bob owes the issuer 1e16, and the issuer's
+        // limit on the same line lets him spend 1000 anyway. Reading the spendable amount there
+        // reports a small, finely scaled number, while the rounding of the debit is still governed
+        // by the 1e16 he actually holds.
+        enum class Line { Holding, InDebt };
+
+        auto runScenario = [this](FeatureBitset features, Line line, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const carol{"carol"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, carol, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            PrettyAsset const bobUsd{bob["USD"]};
+            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
+            // Bob's balance sits exactly on a multiple-of-10 boundary at the
+            // 1e16 IOU precision cusp, where one ULP is 10.
+            STAmount const bobEdge{usd.raw(), Number{10'000'000'000'000'010LL}};
+            STAmount const bobDebt{bobUsd.raw(), Number{10'000'000'000'000'000LL}};
+            STAmount const oppositeLimit{bobUsd.raw(), Number{10'000'000'000'001'000LL}};
+
+            env(trust(alice, trustLimit));
+            env(trust(carol, trustLimit));
+            env(trust(bob, trustLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            env(pay(issuer, carol, usd(1'000)));
+            if (line == Line::Holding)
+            {
+                env(pay(issuer, bob, bobEdge));
+            }
+            else
+            {
+                // The issuer trusts bob's own USD, so bob can issue 1e16 back and still have
+                // 1000 of spendable room left on the same line.
+                env(trust(issuer, oppositeLimit));
+                env.close();
+                env(pay(bob, issuer, bobDebt));
+            }
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // Alice deposits 1000 USD, minting 1000 shares 1:1.
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // A loan broker on the vault, then a bullet loan at 24% interest:
+            // a single payment, one year out.
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+            env(set(carol, brokerKeylet.key, usd(1'000).value()),
+                loan::kInterestRate(percentageToTenthBips(24)),
+                kGracePeriod(60),
+                kPaymentInterval(365 * 24 * 60 * 60),
+                kPaymentTotal(1),
+                Sig(sfCounterpartySignature, alice),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Advance to just before the single payment falls due and let carol
+            // repay principal plus interest. LoanPay is what books the accrued
+            // interest into sfAssetsTotal; under cash-basis accounting LoanSet
+            // alone does not. Share supply stays at 1000, so
+            // assetsTotal/sharesTotal becomes 1240/1000.
+            env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
+            env(pay(carol, loanKeylet.key, usd(2'000).value()), Ter(tesSUCCESS));
+            env.close();
+
+            // Pin the ratio the rest of the scenario reasons about, so the test cannot quietly
+            // stop exercising the bug if the setup drifts.
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault && sleVault->at(sfAssetsTotal) == Number{1'240});
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            BEAST_EXPECT(sleIssuance && sleIssuance->at(sfOutstandingAmount) == 1'000);
+
+            // Bob deposits 6 USD, which rounds to 10 at his own trust-line
+            // scale and so clears the fixCleanup3_2_0 guard. But
+            // floor(1000 * 6 / 1240) is 4 shares, worth 4 * 1240 / 1000 = 4.96,
+            // and that is below half a ULP of his balance, so it rounds away to
+            // nothing when subtracted.
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(6)}),
+                Ter(expected));
+            env.close();
+        };
+
+        // Strip featureLendingProtocolV1_1: this scenario runs an
+        // open-ended vault through deposit/broker/loan/repay/deposit,
+        // which spans both Subscription and post-loan lifetime — a phase
+        // pattern that only makes sense on open-ended vaults. The gate
+        // added by LP V1.1 is unrelated to the truncation bug asserted
+        // here.
+        auto const legacy = testableAmendments() - featureLendingProtocolV1_1;
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(legacy - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)");
+            runScenario(
+                legacy - fixCleanup3_2_0 - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(legacy, Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)");
+            runScenario(legacy - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(legacy - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(legacy, Line::InDebt, tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: ValidVault::visitEntry computes destinationDelta.scale as
+    // max(before_exponent, after_exponent) for RippleState entries.  When a
+    // withdrawal credits a destination whose IOU balance sits just below a
+    // 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);
+        }
+    }
+
+    // Scale 15 seed + deposit 5: pre-fix credited > paid; post-fix credited <= paid.
+    // fixCleanup3_2_0 is off so roundToVaultScale does not shrink the deposit first.
+    void
+    testBugVaultDepositOvercreditsAcrossScaleBoundary()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool expectOvercredit) {
+            Env env(*this, features);
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(1'000'000), owner, issuer, depositor);
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Number const seed{9'999'999'999'999'999LL, -15};
+            Number const deposit{5};
+
+            env(trust(depositor, usd(1'000'000'000)));
+            env.close();
+            env(pay(issuer, depositor, usd(deposit)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            tx[sfScale] = 15;
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = issuer, .id = keylet.key, .amount = usd(seed)}));
+            env.close();
+
+            Number const totalBefore = env.le(keylet)->at(sfAssetsTotal);
+            Number const depositorBefore = env.balance(depositor, usd.raw()).number();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(deposit)}));
+            env.close();
+
+            Number const totalAfter = env.le(keylet)->at(sfAssetsTotal);
+            Number const depositorAfter = env.balance(depositor, usd.raw()).number();
+            Number const paid = depositorBefore - depositorAfter;
+            Number const credited = totalAfter - totalBefore;
+
+            if (expectOvercredit)
+            {
+                BEAST_EXPECTS(
+                    credited > paid,
+                    "AssetsTotal credited " + to_string(credited) + " for a payment of " +
+                        to_string(paid) + ", expected an overcredit");
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    credited <= paid,
+                    "AssetsTotal credited " + to_string(credited) + " for a payment of " +
+                        to_string(paid));
+            }
+        };
+
+        testcase(
+            "bug: VaultDeposit overcredits across an IOU scale boundary "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, true);
+
+        testcase(
+            "bug: VaultDeposit no longer overcredits across an IOU scale boundary "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, false);
+    }
+
+    // 1e17 IOU at scale 0. Withdraw all-but-one, then the last share:
+    // pre-fix tecINVARIANT_FAILED, post-fix tesSUCCESS.
+    void
+    testBugVaultLockedByPartialWithdraw()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            Env env(*this, features);
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            env.fund(XRP(1'000'000), owner, issuer, holder);
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(holder, usd(Number{1, 18})));
+            env.close();
+            env(pay(issuer, holder, usd(Number{1, 17})));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            tx[sfScale] = 0;
+            env(tx);
+            env.close();
+            env(vault.deposit(
+                {.depositor = holder, .id = keylet.key, .amount = usd(Number{1, 17})}));
+            env.close();
+
+            MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
+            std::int64_t const allButOne = 100'000'000'000'000'000LL - 1;
+            env(vault.withdraw(
+                {.depositor = holder, .id = keylet.key, .amount = STAmount{share, allButOne}}));
+            env.close();
+
+            env(vault.withdraw(
+                    {.depositor = holder, .id = keylet.key, .amount = STAmount{share, 1}}),
+                Ter(expected));
+            env.close();
+        };
+
+        testcase(
+            "bug: VaultWithdraw permanently locks a large IOU vault "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+        testcase(
+            "bug: VaultWithdraw no longer locks a large IOU vault "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS);
+    }
+
+    // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
+    // shFULL_BALANCE), which for an IOU asset adds the counterparty's
+    // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
+    // 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);
+                }
+            }
+        }
+    }
+
+    // Shared setup for testBugClawbackRoundTripOvershoot and
+    // testBugWithdrawRoundTripOvershoot, which both need a vault at
+    // assetsTotal=7, sharesTotal=5 and differ only in what they do once
+    // that state is reached.
+    //
+    // The (7, 5) state is reached through ordinary transactions: a 5 USD
+    // deposit mints 5 shares 1:1, then a loan broker on the vault issues a
+    // single-payment bullet loan for the full 5 USD at 40% interest. When
+    // the borrower repays a year later, LoanPay books the 2 USD of accrued
+    // interest into sfAssetsTotal without minting shares, leaving
+    // assetsTotal=7 against sharesTotal=5 (see
+    // testBugDepositShareTruncationSubUlp for the same technique in more
+    // detail).
+    struct RoundTripOvershootVault
+    {
+        test::jtx::Account issuer;
+        test::jtx::Account holder;
+        PrettyAsset usd;
+        test::jtx::Vault vault;
+        Keylet vaultKeylet;
+        Number initialAssetsTotal;
+        Number initialAssetsAvailable;
+    };
+
+    std::optional
+    makeRoundTripOvershootVault(test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const holder{"holder"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000), issuer, owner, holder, borrower);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        env.trust(usd(1'000), owner);
+        env.trust(usd(1'000), holder);
+        env.trust(usd(1'000), borrower);
+        env.close();
+
+        env(pay(issuer, holder, usd(100)));
+        env(pay(issuer, borrower, usd(100)));
+        env.close();
+
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+        vaultTx[sfScale] = 0;
+        env(vaultTx);
+        env.close();
+
+        // Holder deposits 5 USD, minting 5 shares 1:1.
+        env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)}));
+        env.close();
+
+        // A loan broker on the vault, then a single bullet loan for the
+        // entire deposit at 40% interest, one payment, one year out.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(set(owner, vaultKeylet.key));
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+        env(set(borrower, brokerKeylet.key, usd(5).value()),
+            loan::kInterestRate(percentageToTenthBips(40)),
+            kGracePeriod(60),
+            kPaymentInterval(365 * 24 * 60 * 60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Advance to just before the single payment falls due and let the
+        // borrower repay principal plus interest. Share supply stays at 5,
+        // so assetsTotal/sharesTotal becomes 7/5.
+        env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
+        env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return std::nullopt;
+        auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
+
+        Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal);
+        Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable);
+        BEAST_EXPECT(initialAssetsTotal == usd(7).number());
+        BEAST_EXPECT(initialAssetsAvailable == usd(7).number());
+        {
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID));
+            if (!BEAST_EXPECT(sleIssuance))
+                return std::nullopt;
+            BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5);
+        }
+
+        return RoundTripOvershootVault{
+            .issuer = issuer,
+            .holder = holder,
+            .usd = usd,
+            .vault = vault,
+            .vaultKeylet = vaultKeylet,
+            .initialAssetsTotal = initialAssetsTotal,
+            .initialAssetsAvailable = initialAssetsAvailable};
+    }
+
+    // VaultClawback::assetsToClawback converts clawbackAmount to shares
+    // with round-to-nearest, then round-trips back to assets. When shares
+    // round up, assetsRecovered can exceed clawbackAmount.
+    //
+    // Repro: assetsTotal=7, sharesTotal=5, request 4:
+    //   shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
+    //
+    // Post-fixCleanup3_4_0: truncate shares so assetsRecovered <=
+    // clawbackAmount by construction.
+    void
+    testBugClawbackRoundTripOvershoot()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool withFix) {
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then claw back shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
+
+            auto const setup = makeRoundTripOvershootVault(env);
+            if (!BEAST_EXPECT(setup))
+                return;
+
+            auto const clawbackAmount = setup->usd(4);
+            env(setup->vault.clawback(
+                {.issuer = setup->issuer,
+                 .id = setup->vaultKeylet.key,
+                 .holder = setup->holder,
+                 .amount = clawbackAmount.value()}));
+
+            auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
+            if (!BEAST_EXPECT(vaultSleAfter))
+                return;
+            Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
+            Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal;
+            Number const clawbackNum = clawbackAmount.number();
+
+            Number const expectedPost{28LL, -1};
+            Number const expectedPre{42LL, -1};
+            if (withFix)
+            {
+                BEAST_EXPECT(assetsRecovered <= clawbackNum);
+                BEAST_EXPECT(assetsRecovered == expectedPost);
+            }
+            else
+            {
+                BEAST_EXPECT(assetsRecovered > clawbackNum);
+                BEAST_EXPECT(assetsRecovered == expectedPre);
+            }
+        };
+
+        {
+            testcase(
+                "bug: VaultClawback round-trip overshoot lets issuer recover "
+                "more than requested (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, false);
+        }
+        {
+            testcase(
+                "bug: VaultClawback round-trip overshoot is clamped so "
+                "assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), true);
+        }
+    }
+
+    // Same root cause as testBugClawbackRoundTripOvershoot on the
+    // withdraw path. Also bypasses the preclaim canWithdraw check, which
+    // validates destination limits against the requested amount only.
+    //
+    // Repro: assetsTotal=7, sharesTotal=5, request 4:
+    //   pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
+    //   post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4.
+    void
+    testBugWithdrawRoundTripOvershoot()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool withFix) {
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then withdraw shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
+
+            auto const setup = makeRoundTripOvershootVault(env);
+            if (!BEAST_EXPECT(setup))
+                return;
+
+            auto const requested = setup->usd(4);
+            env(setup->vault.withdraw(
+                {.depositor = setup->holder,
+                 .id = setup->vaultKeylet.key,
+                 .amount = requested.value()}));
+
+            auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
+            if (!BEAST_EXPECT(vaultSleAfter))
+                return;
+            Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
+            Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal;
+            Number const requestedNum = requested.number();
+
+            Number const expectedPost{28LL, -1};
+            Number const expectedPre{42LL, -1};
+            if (withFix)
+            {
+                BEAST_EXPECT(assetsWithdrawn <= requestedNum);
+                BEAST_EXPECT(assetsWithdrawn == expectedPost);
+            }
+            else
+            {
+                BEAST_EXPECT(assetsWithdrawn > requestedNum);
+                BEAST_EXPECT(assetsWithdrawn == expectedPre);
+            }
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw round-trip overshoot delivers more than "
+                "requested (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, false);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw round-trip overshoot is clamped so "
+                "assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), true);
+        }
+    }
+
+    struct ImpairedLoanVault
+    {
+        test::jtx::Account issuer;
+        test::jtx::Account holder;
+        PrettyAsset usd;
+        test::jtx::Vault vault;
+        Keylet vaultKeylet;
+        MPTID shareId;
+    };
+
+    // Impairing a 1,000 loan in a 10,000 vault leaves AssetsAvailable=9,000
+    // and AssetsTotal=10,000. otherDeposit > 0 splits the shares, 0 leaves
+    // holder as the sole shareholder.
+    std::optional
+    makeImpairedLoanVault(test::jtx::Env& env, int otherDeposit)
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const holder{"holder"};
+        Account const other{"other"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000), issuer, owner, holder, other, borrower);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        env.trust(usd(100'000), owner);
+        env.trust(usd(100'000), holder);
+        env.trust(usd(100'000), other);
+        env.trust(usd(100'000), borrower);
+        env.close();
+
+        int const holderDeposit = 10'000 - otherDeposit;
+        env(pay(issuer, holder, usd(holderDeposit)));
+        if (otherDeposit != 0)
+        {
+            env(pay(issuer, other, usd(otherDeposit)));
+        }
+        env.close();
+
+        Vault const vault{env};
+        auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
+        env(createTx);
+        env.close();
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return std::nullopt;
+        MPTID const shareId = vaultSle->at(sfShareMPTID);
+
+        env(vault.deposit(
+            {.depositor = holder, .id = vaultKeylet.key, .amount = usd(holderDeposit)}));
+        if (otherDeposit != 0)
+        {
+            env(vault.deposit(
+                {.depositor = other, .id = vaultKeylet.key, .amount = usd(otherDeposit)}));
+        }
+        env.close();
+
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(set(owner, vaultKeylet.key));
+        env.close();
+
+        auto const sleBroker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(sleBroker))
+            return std::nullopt;
+        auto const loanKeylet =
+            keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, brokerKeylet.key, usd(1'000).value()),
+            loan::kInterestRate(percentageToTenthBips(0)),
+            kGracePeriod(60),
+            kPaymentInterval(120),
+            kPaymentTotal(10),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with
+        // tecTOO_SOON unless the payment is already late; advance the ledger
+        // past sfNextPaymentDueDate so impairment succeeds. No-op otherwise.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            auto const loanBefore = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanBefore))
+                return std::nullopt;
+            std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfter))
+            return std::nullopt;
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == usd(9'000).value());
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == usd(1'000).value());
+
+        return ImpairedLoanVault{
+            .issuer = issuer,
+            .holder = holder,
+            .usd = usd,
+            .vault = vault,
+            .vaultKeylet = vaultKeylet,
+            .shareId = shareId};
+    }
+
+    // Legacy clawback pricing burns every share; fixCleanup3_4_0 leaves 10%
+    // outstanding, backed by the impaired receivable.
+    void
+    testBugClawbackAfterLoanImpair()
+    {
+        using namespace test::jtx;
+
+        auto clawbackHolder = [](ImpairedLoanVault const& setup, STAmount const& amount) {
+            return setup.vault.clawback(
+                {.issuer = setup.issuer,
+                 .id = setup.vaultKeylet.key,
+                 .holder = setup.holder,
+                 .amount = amount});
+        };
+
+        auto runSole = [this, &clawbackHolder](FeatureBitset features, TER expected) {
+            testcase(
+                features[fixCleanup3_4_0]
+                    ? "VaultClawback after impaired loan (post-fixCleanup3_4_0)"
+                    : "VaultClawback after impaired loan (pre-fixCleanup3_4_0)");
+
+            Env env(*this, features);
+            auto const maybeSetup = makeImpairedLoanVault(env, 0);
+            if (!maybeSetup)
+            {
+                BEAST_EXPECT(false);
+                return;
+            }
+            ImpairedLoanVault const& setup = *maybeSetup;
+
+            auto const tokenBefore = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
+            auto const vaultBefore = env.le(setup.vaultKeylet);
+            auto const issuanceBefore = env.le(keylet::mptokenIssuance(setup.shareId));
+            if (!BEAST_EXPECT(tokenBefore) || !BEAST_EXPECT(vaultBefore) ||
+                !BEAST_EXPECT(issuanceBefore))
+                return;
+            std::uint64_t const sharesBefore = tokenBefore->getFieldU64(sfMPTAmount);
+
+            // The clawback of 19,000 exceeds AssetsAvailable (9,000), so
+            // VaultClawback clamps sharesDestroyed to whatever redeems
+            // exactly AssetsAvailable; compute that expected value using the
+            // same conversion helper VaultClawback itself uses, rather than
+            // assuming an exact 90/10 split holds under truncation.
+            auto const maybeSharesDestroyed = assetsToSharesWithdraw(
+                vaultBefore,
+                issuanceBefore,
+                setup.usd(9'000).value(),
+                TruncateShares::Yes,
+                WaiveUnrealizedLoss::Yes);
+            if (!BEAST_EXPECT(maybeSharesDestroyed))
+                return;
+            std::uint64_t const expectedSharesAfter =
+                sharesBefore - maybeSharesDestroyed->mpt().value();
+
+            env(clawbackHolder(setup, setup.usd(19'000).value()), Ter(expected));
+            env.close();
+            if (expected != tesSUCCESS)
+                return;
+
+            auto const vaultAfter = env.le(setup.vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == setup.usd(0).value());
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == setup.usd(1'000).value());
+            BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == setup.usd(1'000).value());
+            auto const tokenAfter = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
+            if (!BEAST_EXPECT(tokenAfter))
+                return;
+            BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter);
+        };
+
+        runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+        runSole(all_, tesSUCCESS);
+
+        testcase("VaultClawback after impaired loan, non-sole holder");
+        {
+            Env env(*this, all_);
+            auto const maybeSetup = makeImpairedLoanVault(env, 1'000);
+            if (!maybeSetup)
+            {
+                BEAST_EXPECT(false);
+                return;
+            }
+            ImpairedLoanVault const& setup = *maybeSetup;
+            // The waiver does not apply, so the holder's 9,000 shares are
+            // still priced at the discounted rate and cannot cover 9,000.
+            env(clawbackHolder(setup, setup.usd(9'000).value()), Ter(tecINSUFFICIENT_FUNDS));
+        }
+    }
+
+    // Bug: a fully impaired vault may pay zero assets for a share burn.
+    // Sending zero MPT is a no-op, so the vault pseudo-account's asset
+    // MPToken is never written and ValidVault, which only records deltas for
+    // created, modified or deleted entries, sees no vault delta at all.
+    //
+    // Pre-fixCleanup3_4_0 that alone makes the withdrawal impossible:
+    // zeroDeltaIsLegitimate is gated on the amendment, so the absent vault
+    // delta fails "withdrawal must change vault balance". Every pre-amendment
+    // arm below dies there, before any destination-side check runs.
+    //
+    // The destination side differs per arm, and only the vault-delta return
+    // hides that pre-amendment. With Alice's asset MPToken already present
+    // nothing touches it, so she has no delta either. With it missing,
+    // doWithdraw still called addEmptyHolding for a self-destination on a
+    // zero payout and created her MPToken at amount 0; a created MPToken is
+    // recorded even at zero, so she arrives with a present-and-zero delta,
+    // which for an integral MPT asset the destination check would reject if
+    // it were reached.
+    //
+    // ValidMPTIssuance is a separate checker and still runs. It only trips on
+    // the one arm that both creates and deletes an MPToken: Alice's last
+    // share with the asset MPToken missing, where addEmptyHolding creates the
+    // asset token while her share token is deleted (created + deleted > 1).
+    // Leftover shares with the token missing is create-only, and a last share
+    // with the token present is delete-only; neither exceeds one. Bob still
+    // owns shares throughout, so this is never the vault's final outstanding
+    // share.
+    //
+    // Post-fixCleanup3_4_0, doWithdraw skips addEmptyHolding on a zero
+    // payout and zeroDeltaIsLegitimate lets the vault-delta and
+    // missing-recipient-delta checks accept the transfer. A present
+    // destination delta of zero is still rejected.
+    void
+    testBugMptZeroWithdrawMissingHolding()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        auto runScenario = [this](
+                               FeatureBitset features,
+                               bool removeAssetToken,
+                               bool withdrawAllAliceShares,
+                               TER expected) {
+            testcase(
+                std::string{"bug: MPT vault zero-value withdraw "} +
+                (removeAssetToken ? "without asset MPToken" : "with asset MPToken") +
+                (withdrawAllAliceShares ? ", Alice's last share" : ", Alice has leftover shares") +
+                (features[fixCleanup3_4_0] ? " (post-fixCleanup3_4_0)" : " (pre-fixCleanup3_4_0)"));
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(100'000), issuer, owner, alice, bob, borrower);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = alice});
+            mptt.authorize({.account = bob});
+            mptt.authorize({.account = borrower});
+            env.close();
+
+            env(pay(issuer, alice, asset(2)));
+            env(pay(issuer, bob, asset(8)));
+            env.close();
+
+            Vault const vault{env};
+            auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = 60s});
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)}));
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)}));
+            env.close();
+
+            vault.closePastSubscription(subscriptionDate);
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(set(owner, vaultKeylet.key));
+            env.close();
+
+            auto const sleBroker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return;
+            auto const loanKeylet = keylet::loan(
+                brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+            env(set(borrower, brokerKeylet.key, asset(10).value()),
+                kInterestRate(percentageToTenthBips(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const loanBefore = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanBefore))
+                return;
+            std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
+            env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultImpaired = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultImpaired))
+                return;
+            BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value());
+            BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized));
+            Number const totalBefore = vaultImpaired->at(sfAssetsTotal);
+            Number const lossBefore = vaultImpaired->at(sfLossUnrealized);
+
+            MPTID const shareId = vaultImpaired->at(sfShareMPTID);
+            auto const issuanceBefore = env.le(keylet::mptokenIssuance(shareId));
+            if (!BEAST_EXPECT(issuanceBefore))
+                return;
+            std::uint64_t const outstandingBefore =
+                issuanceBefore->getFieldU64(sfOutstandingAmount);
+
+            auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id()));
+            if (!BEAST_EXPECT(tokenAlice))
+                return;
+            std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount);
+            BEAST_EXPECT(sharesBefore == 2);
+            std::uint64_t const sharesToRedeem = withdrawAllAliceShares ? sharesBefore : 1;
+            STAmount const redeemShares{MPTIssue{shareId}, Number(sharesToRedeem)};
+
+            auto const assetTokenKeylet = keylet::mptoken(mptt.issuanceID(), alice.id());
+            if (removeAssetToken)
+            {
+                mptt.authorize({.account = alice, .flags = tfMPTUnauthorize});
+                env.close();
+                BEAST_EXPECT(!env.le(assetTokenKeylet));
+            }
+            else
+            {
+                auto const existing = env.le(assetTokenKeylet);
+                if (!BEAST_EXPECT(existing))
+                    return;
+                BEAST_EXPECT(existing->getFieldU64(sfMPTAmount) == 0);
+            }
+
+            std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate);
+            env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s);
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}),
+                Ter(expected));
+            env.close();
+            if (expected != tesSUCCESS)
+                return;
+
+            if (removeAssetToken)
+            {
+                BEAST_EXPECT(!env.le(assetTokenKeylet));
+            }
+            else
+            {
+                auto const assetAfter = env.le(assetTokenKeylet);
+                if (!BEAST_EXPECT(assetAfter))
+                    return;
+                BEAST_EXPECT(assetAfter->getFieldU64(sfMPTAmount) == 0);
+            }
+
+            auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id()));
+            if (withdrawAllAliceShares)
+            {
+                BEAST_EXPECT(!shareAfter);
+            }
+            else if (BEAST_EXPECT(shareAfter))
+            {
+                BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - sharesToRedeem);
+            }
+
+            auto const vaultAfter = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+            BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+            BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value());
+
+            auto const issuanceAfter = env.le(keylet::mptokenIssuance(shareId));
+            if (!BEAST_EXPECT(issuanceAfter))
+                return;
+            BEAST_EXPECT(
+                issuanceAfter->getFieldU64(sfOutstandingAmount) ==
+                outstandingBefore - sharesToRedeem);
+        };
+
+        runScenario(
+            all_, false /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS);
+        runScenario(
+            all_, false /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS);
+        runScenario(
+            all_, true /* removeAssetToken */, false /* withdrawAllAliceShares */, tesSUCCESS);
+        runScenario(
+            all_, true /* removeAssetToken */, true /* withdrawAllAliceShares */, tesSUCCESS);
+        runScenario(
+            all_ - fixCleanup3_4_0,
+            false /* removeAssetToken */,
+            false /* withdrawAllAliceShares */,
+            tecINVARIANT_FAILED);
+        runScenario(
+            all_ - fixCleanup3_4_0,
+            false /* removeAssetToken */,
+            true /* withdrawAllAliceShares */,
+            tecINVARIANT_FAILED);
+        runScenario(
+            all_ - fixCleanup3_4_0,
+            true /* removeAssetToken */,
+            false /* withdrawAllAliceShares */,
+            tecINVARIANT_FAILED);
+        runScenario(
+            all_ - fixCleanup3_4_0,
+            true /* removeAssetToken */,
+            true /* withdrawAllAliceShares */,
+            tecINVARIANT_FAILED);
+    }
+
+    // IOU analogue of the missing-MPToken case above. Alice removes her
+    // zero-balance trust line after depositing, then burns one unit from her
+    // scaled share balance after the vault is fully impaired. Bob's share
+    // balance keeps this out of the sole-shareholder loss-waiver and
+    // final-outstanding-share paths. A zero payout must not recreate Alice's
+    // unsolicited trust line.
+    void
+    testBugIouZeroWithdrawMissingTrustLine()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000), issuer, owner, alice, bob, borrower);
+        env.close();
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const asset = issuer["USD"];
+        env.trust(asset(100), owner);
+        env.trust(asset(100), alice);
+        env.trust(asset(100), bob);
+        env.trust(asset(100), borrower);
+        env.close();
+
+        env(pay(issuer, alice, asset(2)));
+        env(pay(issuer, bob, asset(8)));
+        env.close();
+
+        Vault const vault{env};
+        auto const [createTx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = owner, .asset = asset, .subscriptionOffset = 60s});
+        env(createTx);
+        env.close();
+
+        env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)}));
+        env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)}));
+        env.close();
+
+        auto const assetLine = keylet::trustLine(alice, asset.raw().get());
+        if (!BEAST_EXPECT(env.le(assetLine)))
+            return;
+        env.trust(asset(0), alice);
+        env.close();
+        BEAST_EXPECT(!env.le(assetLine));
+
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(set(owner, vaultKeylet.key));
+        env.close();
+
+        auto const sleBroker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, brokerKeylet.key, asset(10).value()),
+            kInterestRate(percentageToTenthBips(0)),
+            kGracePeriod(60),
+            kPaymentInterval(120),
+            kPaymentTotal(10),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const loanBefore = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanBefore))
+            return;
+        std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
+        env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultImpaired = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultImpaired))
+            return;
+        BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value());
+        BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized));
+        Number const totalBefore = vaultImpaired->at(sfAssetsTotal);
+        Number const lossBefore = vaultImpaired->at(sfLossUnrealized);
+
+        MPTID const shareId = vaultImpaired->at(sfShareMPTID);
+        auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id()));
+        if (!BEAST_EXPECT(tokenAlice))
+            return;
+        std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount);
+        // Default IOU vault scale is 6, so 2 USD mints 2e6 shares. Redeem one
+        // leftover share; do not require 1:1 like the MPT case.
+        BEAST_EXPECT(sharesBefore > 1);
+        STAmount const redeemShares{MPTIssue{shareId}, Number(1)};
+
+        std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate);
+        env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s);
+
+        env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // A regression in the View guard would recreate this line even though
+        // no asset value was paid.
+        BEAST_EXPECT(!env.le(assetLine));
+
+        auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id()));
+        if (!BEAST_EXPECT(shareAfter))
+            return;
+        BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - 1);
+
+        auto const vaultAfter = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value());
+    }
+
+    // Same zero-payout withdrawal as testBugMptZeroWithdrawMissingHolding, but
+    // the vault asset is XRP. addEmptyHolding is a no-op for native assets.
+    // Sequence processing still touches the sender AccountRoot; a sponsored
+    // fee leaves that XRP balance economically unchanged. After the
+    // sponsored-withdraw fee-payer fix, deltaAssetsForParty collapses that
+    // economically-zero XRP delta to absence, so tesSUCCESS takes the
+    // missing-recipient-delta arm gated by zeroDeltaIsLegitimate. This test
+    // covers that live SUCCESS path. Pre-fixCleanup3_4_0 still fails the
+    // invariant.
+    void
+    testBugXrpZeroWithdrawSponsoredFee()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            testcase(
+                std::string{"bug: XRP vault zero-value withdraw with sponsored fee"} +
+                (features[fixCleanup3_4_0] ? " (post-fixCleanup3_4_0)" : " (pre-fixCleanup3_4_0)"));
+
+            Env env(*this, features);
+
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const borrower{"borrower"};
+            Account const sponsor{"sponsor"};
+
+            env.fund(XRP(100'000), owner, alice, bob, borrower, sponsor);
+            env.close();
+
+            PrettyAsset const asset{xrpIssue()};
+            Vault const vault{env};
+            auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = 60s});
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(2)}));
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = asset(8)}));
+            env.close();
+
+            vault.closePastSubscription(subscriptionDate);
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(set(owner, vaultKeylet.key));
+            env.close();
+
+            auto const sleBroker = env.le(brokerKeylet);
+            if (!BEAST_EXPECT(sleBroker))
+                return;
+            auto const loanKeylet = keylet::loan(
+                brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+            env(set(borrower, brokerKeylet.key, asset(10).value()),
+                kInterestRate(percentageToTenthBips(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const loanBefore = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanBefore))
+                return;
+            std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
+            env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultImpaired = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultImpaired))
+                return;
+            BEAST_EXPECT(vaultImpaired->at(sfAssetsAvailable) == asset(0).value());
+            BEAST_EXPECT(vaultImpaired->at(sfAssetsTotal) == vaultImpaired->at(sfLossUnrealized));
+            Number const totalBefore = vaultImpaired->at(sfAssetsTotal);
+            Number const lossBefore = vaultImpaired->at(sfLossUnrealized);
+
+            MPTID const shareId = vaultImpaired->at(sfShareMPTID);
+            auto const tokenAlice = env.le(keylet::mptoken(shareId, alice.id()));
+            if (!BEAST_EXPECT(tokenAlice))
+                return;
+            std::uint64_t const sharesBefore = tokenAlice->getFieldU64(sfMPTAmount);
+            BEAST_EXPECT(sharesBefore == 2);
+            STAmount const redeemShares{MPTIssue{shareId}, Number(1)};
+
+            std::uint32_t const redemptionDate = vaultImpaired->at(sfRedemptionDate);
+            env.close(NetClock::time_point{NetClock::duration{redemptionDate}} + 1s);
+
+            auto const aliceBalanceBefore = env.balance(alice);
+            auto const sponsorBalanceBefore = env.balance(sponsor);
+            auto const fee = env.current()->fees().base;
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = redeemShares}),
+                Fee(fee),
+                sponsor::As(sponsor, spfSponsorFee),
+                Sig(sfSponsorSignature, sponsor),
+                Ter(expected));
+            env.close();
+
+            BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee);
+            BEAST_EXPECT(env.balance(alice) == aliceBalanceBefore);
+
+            if (expected != tesSUCCESS)
+                return;
+
+            auto const shareAfter = env.le(keylet::mptoken(shareId, alice.id()));
+            if (!BEAST_EXPECT(shareAfter))
+                return;
+            BEAST_EXPECT(shareAfter->getFieldU64(sfMPTAmount) == sharesBefore - 1);
+
+            auto const vaultAfter = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+            BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+            BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == asset(0).value());
+        };
+
+        runScenario(all_, tesSUCCESS);
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+    }
+
+    // addEmptyHolding() used to check isGlobalFrozen(issuer) and
+    // !lsfDefaultRipple before the "line already exists" tecDUPLICATE
+    // short circuit. doWithdraw() calls addEmptyHolding() for a
+    // self-destination payout and only tolerates tecDUPLICATE, so
+    // tecINTERNAL from a missing DefaultRipple flag aborted the
+    // withdrawal. fixCleanup3_4_0 checks existence first and maps the
+    // create-path DefaultRipple miss to terNO_RIPPLE. Global freeze on an
+    // existing line is still rejected later by checkWithdrawFreeze.
+    void
+    testBugSelfWithdrawAfterIssuerClearsDefaultRipple()
+    {
+        using namespace test::jtx;
+
+        auto runExistingLine = [this](
+                                   FeatureBitset features,
+                                   TER selfExpected,
+                                   bool issuerGlobalFreeze = false) {
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Issue const usdIssue = usd.raw().get();
+            env(trust(alice, usd(10'000)));
+            env(trust(bob, usd(10'000)));
+            env.close();
+            env(pay(issuer, alice, usd(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)}));
+            env.close();
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}));
+            env.close();
+
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+            if (issuerGlobalFreeze)
+            {
+                env(fset(issuer, asfGlobalFreeze));
+                env.close();
+            }
+
+            BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usdIssue)));
+
+            // Alice's USD line is unchanged; a later deposit still succeeds
+            // unless the issuer is globally frozen.
+            if (!issuerGlobalFreeze)
+            {
+                env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(10)}));
+                env.close();
+            }
+
+            Number const destBefore = env.balance(alice, usd.raw()).number();
+            Number const vaultBefore = env.le(vaultKeylet)->at(sfAssetsTotal);
+            Number const withdrawAmt{50};
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}),
+                Ter(selfExpected));
+            env.close();
+
+            Number const destAfter = env.balance(alice, usd.raw()).number();
+            Number const vaultAfter = env.le(vaultKeylet)->at(sfAssetsTotal);
+            if (isTesSuccess(selfExpected))
+            {
+                BEAST_EXPECT(destAfter == destBefore + withdrawAmt);
+                BEAST_EXPECT(vaultAfter == vaultBefore - withdrawAmt);
+            }
+            else
+            {
+                BEAST_EXPECT(destAfter == destBefore);
+                BEAST_EXPECT(vaultAfter == vaultBefore);
+            }
+
+            if (!issuerGlobalFreeze)
+            {
+                auto destTx =
+                    vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)});
+                destTx[sfDestination] = bob.human();
+                env(destTx);
+                env.close();
+            }
+        };
+
+        auto runDeletedLine = [this](FeatureBitset features, TER selfExpected) {
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(10'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Issue const usdIssue = usd.raw().get();
+            env(trust(alice, usd(10'000)));
+            env.close();
+            env(pay(issuer, alice, usd(500)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)}));
+            env.close();
+
+            env(trust(alice, usd(0)));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::trustLine(alice.id(), usdIssue)));
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}),
+                Ter(selfExpected));
+            env.close();
+        };
+
+        auto runCoverWithdraw = [this](FeatureBitset features, TER selfExpected) {
+            using namespace loan_broker;
+
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(10'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Issue const usdIssue = usd.raw().get();
+            env(trust(alice, usd(10'000)));
+            env.close();
+            env(pay(issuer, alice, usd(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = alice, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
+            (void)subscriptionDate;
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+            env(coverDeposit(alice, brokerKeylet.key, usd(100).value()));
+            env.close();
+
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::trustLine(alice.id(), usdIssue)));
+
+            Number const destBefore = env.balance(alice, usd.raw()).number();
+            Number const coverBefore = env.le(brokerKeylet)->at(sfCoverAvailable);
+            Number const withdrawAmt{50};
+
+            env(coverWithdraw(alice, brokerKeylet.key, usd(50).value()), Ter(selfExpected));
+            env.close();
+
+            Number const destAfter = env.balance(alice, usd.raw()).number();
+            Number const coverAfter = env.le(brokerKeylet)->at(sfCoverAvailable);
+            if (isTesSuccess(selfExpected))
+            {
+                BEAST_EXPECT(destAfter == destBefore + withdrawAmt);
+                BEAST_EXPECT(coverAfter == coverBefore - withdrawAmt);
+            }
+            else
+            {
+                BEAST_EXPECT(destAfter == destBefore);
+                BEAST_EXPECT(coverAfter == coverBefore);
+            }
+        };
+
+        auto runDeletedCoverWithdraw = [this](FeatureBitset features, TER selfExpected) {
+            using namespace loan_broker;
+
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(10'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Issue const usdIssue = usd.raw().get();
+            env(trust(alice, usd(10'000)));
+            env.close();
+            env(pay(issuer, alice, usd(600)));
+            env.close();
+
+            Vault const vault{env};
+            auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = alice, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
+            (void)subscriptionDate;
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+            env(coverDeposit(alice, brokerKeylet.key, usd(100).value()));
+            env.close();
+
+            env(trust(alice, usd(0)));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::trustLine(alice.id(), usdIssue)));
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+
+            env(coverWithdraw(alice, brokerKeylet.key, usd(50).value()), Ter(selfExpected));
+            env.close();
+        };
+
+        auto runPrivateVault = [this](FeatureBitset features, TER selfExpected) {
+            Env env(*this, features);
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const pdOwner{"pdOwner"};
+            Account const credIssuer{"credIssuer"};
+            std::string const credType = "credential";
+
+            env.fund(XRP(10'000), issuer, alice, pdOwner, credIssuer);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(alice, usd(10'000)));
+            env.close();
+            env(pay(issuer, alice, usd(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] =
+                vault.create({.owner = alice, .asset = usd, .flags = tfVaultPrivate});
+            env(vaultTx);
+            env.close();
+
+            pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
+            env(pdomain::setTx(pdOwner, credentials));
+            auto const domainId = pdomain::getNewDomain(env.meta());
+            {
+                auto domainTx = vault.set({.owner = alice, .id = vaultKeylet.key});
+                domainTx[sfDomainID] = to_string(domainId);
+                env(domainTx);
+                env.close();
+            }
+
+            env(credentials::create(alice, credIssuer, credType));
+            env(credentials::accept(alice, credIssuer, credType));
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(500)}));
+            env.close();
+
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(50)}),
+                Ter(selfExpected));
+            env.close();
+        };
+
+        testcase(
+            "bug: VaultWithdraw to self fails with tecINTERNAL after issuer "
+            "clears asfDefaultRipple even though the trust line exists "
+            "(pre-fixCleanup3_4_0)");
+        runExistingLine(all_ - fixCleanup3_4_0, tecINTERNAL);
+
+        testcase(
+            "bug: VaultWithdraw to self succeeds after issuer clears "
+            "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
+        runExistingLine(all_, tesSUCCESS);
+
+        testcase(
+            "bug: VaultWithdraw to self with an existing line still gets "
+            "tecFROZEN under asfGlobalFreeze (post-fixCleanup3_4_0)");
+        runExistingLine(all_, tecFROZEN, true);
+
+        testcase(
+            "bug: VaultWithdraw to self fails with tecINTERNAL after issuer "
+            "clears asfDefaultRipple and the trust line was deleted "
+            "(pre-fixCleanup3_4_0)");
+        runDeletedLine(all_ - fixCleanup3_4_0, tecINTERNAL);
+
+        testcase(
+            "bug: VaultWithdraw to self fails with terNO_RIPPLE after issuer "
+            "clears asfDefaultRipple and the trust line was deleted "
+            "(post-fixCleanup3_4_0)");
+        runDeletedLine(all_, terNO_RIPPLE);
+
+        testcase(
+            "bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after "
+            "issuer clears asfDefaultRipple even though the trust line exists "
+            "(pre-fixCleanup3_4_0)");
+        runCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL);
+
+        testcase(
+            "bug: LoanBrokerCoverWithdraw to self succeeds after issuer clears "
+            "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
+        runCoverWithdraw(all_, tesSUCCESS);
+
+        testcase(
+            "bug: LoanBrokerCoverWithdraw to self fails with tecINTERNAL after "
+            "issuer clears asfDefaultRipple and the trust line was deleted "
+            "(pre-fixCleanup3_4_0)");
+        runDeletedCoverWithdraw(all_ - fixCleanup3_4_0, tecINTERNAL);
+
+        testcase(
+            "bug: LoanBrokerCoverWithdraw to self fails with terNO_RIPPLE after "
+            "issuer clears asfDefaultRipple and the trust line was deleted "
+            "(post-fixCleanup3_4_0)");
+        runDeletedCoverWithdraw(all_, terNO_RIPPLE);
+
+        testcase(
+            "bug: private VaultWithdraw to self fails with tecINTERNAL after "
+            "issuer clears asfDefaultRipple even though the trust line exists "
+            "(pre-fixCleanup3_4_0)");
+        runPrivateVault(all_ - fixCleanup3_4_0, tecINTERNAL);
+
+        testcase(
+            "bug: private VaultWithdraw to self succeeds after issuer clears "
+            "asfDefaultRipple when the trust line exists (post-fixCleanup3_4_0)");
+        runPrivateVault(all_, tesSUCCESS);
+    }
+
+    // Bug 1: a sponsored XRP VaultWithdraw to a distinct destination is
+    // rejected because the vault invariant treats the holder's touched
+    // but economically unchanged AccountRoot as a second payout
+    // recipient. Sequence/ticket processing still touches the holder
+    // while the sponsor pays the fee, so the holder's XRP delta is
+    // present-zero and is not normalized away. If this happens on the
+    // last Subscription ledger of a closed-ended vault, the holder
+    // cannot retry until Redemption (tecTOO_SOON during Investment).
+    //
+    // Fixed by ValidVault::deltaAssetsForParty always collapsing an
+    // economically-zero XRP delta to absence, regardless of who paid the
+    // fee.
+    void
+    testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            Env env{*this, features};
+            Account const owner{"owner"};
+            Account const holder{"holder"};
+            Account const destination{"destination"};
+            Account const sponsor{"sponsor"};
+            env.fund(XRP(10'000), owner, holder, destination, sponsor);
+            env.close();
+
+            constexpr std::uint32_t investmentPeriod = 14u * 24u * 60u * 60u;
+            auto const [vault, vaultKeylet, subscriptionDate, redemptionDate] =
+                makeClosedEndedVault(env, owner, xrpIssue(), 120u, investmentPeriod);
+            BEAST_EXPECT(redemptionDate - subscriptionDate == investmentPeriod);
+
+            env(vault.deposit(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}));
+            env.close();
+
+            // Inclusive SubscriptionDate boundary: still Subscription, so an
+            // ordinary withdrawal is allowed.
+            closeToTime(env, tp{d{subscriptionDate}});
+
+            auto const vaultBefore = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultBefore))
+                return;
+            auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            auto const holderBalanceBefore = env.balance(holder);
+            auto const destinationBalanceBefore = env.balance(destination);
+            auto const sponsorBalanceBefore = env.balance(sponsor);
+            auto const fee = env.current()->fees().base;
+
+            auto withdraw = vault.withdraw(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()});
+            withdraw[sfDestination] = destination.human();
+            env(withdraw,
+                Fee(fee),
+                sponsor::As(sponsor, spfSponsorFee),
+                Sig(sfSponsorSignature, sponsor),
+                Ter(expected));
+            env.close();
+
+            auto const vaultAfter = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee);
+
+            if (expected == tesSUCCESS)
+            {
+                BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value());
+                BEAST_EXPECT(env.balance(holder) == holderBalanceBefore);
+                BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100));
+                return;
+            }
+
+            // Invariant rollback: the payout and share burn are undone, but
+            // sequence processing and the sponsored fee charge remain.
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
+            BEAST_EXPECT(env.balance(holder) == holderBalanceBefore);
+            BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore);
+
+            // Once the ledger advances into Investment, the same holder
+            // cannot retry until Redemption.
+            auto retry = vault.withdraw(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()});
+            retry[sfDestination] = destination.human();
+            env(retry, Ter(tecTOO_SOON));
+        };
+
+        testcase(
+            "bug: sponsored XRP withdrawal to a distinct destination misreads a "
+            "touched-but-zero sender delta as a second recipient "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+
+        testcase(
+            "bug: sponsored XRP withdrawal to a distinct destination succeeds "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS);
+    }
+
+    // Bug 2: a co-signed fee sponsor named as the withdrawal's own
+    // destination pays its fee from the same AccountRoot it is paid into,
+    // so its net XRP delta is (payout - fee). The invariant never fee-
+    // corrected the destination side at all, so this always failed the
+    // equal-amount check against the vault's outflow (payout).
+    //
+    // Fixed by ValidVault::deltaAssetsForParty adding the fee back onto
+    // whichever inspected party's AccountRoot actually paid it -- the
+    // sender, or a distinct destination -- not just the sender.
+    void
+    testBugSponsorAsDestinationFeeMisappliedToPayout()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            Env env{*this, features};
+            Account const owner{"owner"};
+            Account const holder{"holder"};
+            Account const sponsor{"sponsor"};
+            env.fund(XRP(10'000), owner, holder, sponsor);
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}));
+            env.close();
+
+            auto const vaultBefore = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultBefore))
+                return;
+            auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            auto const sponsorBalanceBefore = env.balance(sponsor);
+            auto const fee = env.current()->fees().base;
+
+            // The sponsor both receives the withdrawal (as sfDestination)
+            // and pays its own fee (co-signed) from the same AccountRoot.
+            auto withdraw = vault.withdraw(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()});
+            withdraw[sfDestination] = sponsor.human();
+            env(withdraw,
+                Fee(fee),
+                sponsor::As(sponsor, spfSponsorFee),
+                Sig(sfSponsorSignature, sponsor),
+                Ter(expected));
+            env.close();
+
+            auto const vaultAfter = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+
+            if (expected == tesSUCCESS)
+            {
+                BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value());
+                // Paid the withdrawal, then separately debited for the fee
+                // it chose to cover; net effect is payout minus fee.
+                BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore + XRP(100) - fee);
+                return;
+            }
+
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
+            BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore - fee);
+        };
+
+        testcase(
+            "bug: co-signed sponsor named as withdrawal destination has its "
+            "own fee debit misread as breaking the payout equality "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+
+        testcase(
+            "bug: co-signed sponsor named as withdrawal destination succeeds "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS);
+    }
+
+    // Pre-funded fee sponsorship draws the fee from ltSponsorship.sfFeeAmount,
+    // so feePayerAccountRoot must return nullopt rather than the sponsor's
+    // AccountRoot. A bystander sponsor leaves that branch unexercised: the
+    // result is only consulted by deltaAssetsForParty via `payer && *payer ==
+    // id`. Naming the sponsor as sfDestination makes the early return
+    // load-bearing -- returning the sponsor's id instead of nullopt would add
+    // the fee back onto a balance that never paid it, and the equal-amount
+    // check against the vault outflow would fail.
+    //
+    // Contrast testBugSponsorAsDestinationFeeMisappliedToPayout, where the
+    // sponsor co-signs and so really does pay from its own AccountRoot.
+    void
+    testPrefundedFeeWithdraw()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](
+                               FeatureBitset features,
+                               TER expected,
+                               bool const sponsorIsDestination) {
+            Env env{*this, features};
+            Account const owner{"owner"};
+            Account const holder{"holder"};
+            Account const destination{"destination"};
+            Account const sponsor{"sponsor"};
+            env.fund(XRP(10'000), owner, holder, destination, sponsor);
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}));
+            env.close();
+
+            auto const fee = env.current()->fees().base;
+            env(sponsor::set_fee(sponsor, 0, fee), sponsor::SponseeAcc(holder));
+            env.close();
+
+            auto const vaultBefore = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultBefore))
+                return;
+            auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            auto const holderBalanceBefore = env.balance(holder);
+            auto const destinationBalanceBefore = env.balance(destination);
+            auto const sponsorBalanceBefore = env.balance(sponsor);
+
+            Account const& recipient = sponsorIsDestination ? sponsor : destination;
+            auto withdraw = vault.withdraw(
+                {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()});
+            withdraw[sfDestination] = recipient.human();
+            env(withdraw, Fee(fee), sponsor::As(sponsor, spfSponsorFee), Ter(expected));
+            env.close();
+
+            auto const vaultAfter = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            // Holder is economically unchanged (sequence only); the fee is
+            // taken from the sponsorship object, not any AccountRoot.
+            BEAST_EXPECT(env.balance(holder) == holderBalanceBefore);
+
+            if (expected == tesSUCCESS)
+            {
+                BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value());
+                if (sponsorIsDestination)
+                {
+                    // The sponsor receives the payout and is not debited for
+                    // the fee. The sponsor has to BE the destination for
+                    // FeePayerType::SponsorPreFunded to matter.
+                    BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore + XRP(100));
+                }
+                else
+                {
+                    BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100));
+                    BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore);
+                }
+                auto const sponsorship = env.le(keylet::sponsorship(sponsor, holder));
+                if (!BEAST_EXPECT(sponsorship))
+                    return;
+                BEAST_EXPECT(!sponsorship->isFieldPresent(sfFeeAmount));
+                return;
+            }
+
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
+            BEAST_EXPECT(env.balance(sponsor) == sponsorBalanceBefore);
+            if (!sponsorIsDestination)
+                BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore);
+        };
+
+        testcase(
+            "pre-funded fee XRP withdrawal to a distinct destination succeeds "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS, false);
+
+        testcase(
+            "bug: pre-funded sponsor named as withdrawal destination misreads "
+            "the sender's touched-but-zero delta as a second recipient "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED, true);
+
+        testcase(
+            "bug: pre-funded sponsor named as withdrawal destination receives "
+            "the full payout (post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS, true);
+    }
+
+    // Unsponsored third-party XRP withdrawal: the sender's AccountRoot moves
+    // by exactly -fee. Pre-amendment, the sender-only fee correction then
+    // collapses that to absence so the dual-recipient guard does not fire.
+    void
+    testUnsponsoredWithdrawToDistinctDestinationPreAmendment()
+    {
+        using namespace test::jtx;
+
+        testcase(
+            "unsponsored XRP withdrawal to a distinct destination succeeds "
+            "(pre-fixCleanup3_4_0)");
+
+        Env env{*this, all_ - fixCleanup3_4_0};
+        Account const owner{"owner"};
+        Account const holder{"holder"};
+        Account const destination{"destination"};
+        env.fund(XRP(10'000), owner, holder, destination);
+        env.close();
+
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit(
+            {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        auto const vaultBefore = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        auto const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+        auto const holderBalanceBefore = env.balance(holder);
+        auto const destinationBalanceBefore = env.balance(destination);
+        auto const fee = env.current()->fees().base;
+
+        auto withdraw = vault.withdraw(
+            {.depositor = holder, .id = vaultKeylet.key, .amount = XRP(100).value()});
+        withdraw[sfDestination] = destination.human();
+        env(withdraw, Fee(fee), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore - XRP(100).value());
+        BEAST_EXPECT(env.balance(holder) == holderBalanceBefore - fee);
+        BEAST_EXPECT(env.balance(destination) == destinationBalanceBefore + XRP(100));
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultWithdrawEqualityEnforced();
+        testBugIssuerVaultDepositAtEdge();
+        testBugMakeDeltaPosteriorScale();
+        testBugMakeDeltaAnteriorScale();
+        testVaultDepositCanonicalizeToZero();
+        testBugDepositShareTruncationSubUlp();
+        testVaultWithdrawCanonicalizeToZero();
+        testBugVaultDustDebitCanonicalizesToNoOp();
+        testBugVaultDepositOvercreditsAcrossScaleBoundary();
+        testBugVaultLockedByPartialWithdraw();
+        testVaultDepositNegativeBalanceFromOppositeLimit();
+        testBug6LimitBypassWithShares();
+        testBugClawbackRoundTripOvershoot();
+        testBugWithdrawRoundTripOvershoot();
+        testBugClawbackAfterLoanImpair();
+        testBugMptZeroWithdrawMissingHolding();
+        testBugIouZeroWithdrawMissingTrustLine();
+        testBugXrpZeroWithdrawSponsoredFee();
+        testBugSelfWithdrawAfterIssuerClearsDefaultRipple();
+        testBugSponsoredWithdrawZeroDeltaMisclassifiedAsSecondRecipient();
+        testBugSponsorAsDestinationFeeMisappliedToPayout();
+        testPrefundedFeeWithdraw();
+        testUnsponsoredWithdrawToDistinctDestinationPreAmendment();
+    }
+};
+
+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..0290b67047
--- /dev/null
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -0,0 +1,1227 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#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);
+        };
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build vaults in this suite as
+        // closed-ended and advance past SubscriptionDate before creating
+        // brokers/loans. VaultClawback itself is not phase-gated. The
+        // subscription offset must be large enough that the deposit
+        // ledger close does not accidentally push us past SubscriptionDate
+        // (which would land the deposit in Investment phase and fail).
+        auto const setupVault = [&](PrettyAsset const& asset,
+                                    Account const& owner,
+                                    Account const& depositor) -> std::pair {
+            Vault const vault{env};
+
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
+            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();
+
+            // Move past SubscriptionDate so LoanBrokerSet/LoanSet run in
+            // the Investment phase.
+            vault.closePastSubscription(subscriptionDate);
+
+            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
+            BEAST_EXPECT(availablePreDefault == totalPreDefault);
+            BEAST_EXPECT(availablePreDefault == asset(100).value());
+
+            // 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);
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults; some tests using this helper later
+        // attach loan brokers to the vault. Build it as closed-ended and
+        // advance past SubscriptionDate so subsequent broker/loan setup
+        // runs in the Investment phase. VaultClawback itself is not
+        // phase-gated. See the other setupVault (share tests) for why the
+        // subscription offset must be generous.
+        auto const setupVault = [&](PrettyAsset const& asset,
+                                    Account const& owner,
+                                    Account const& depositor,
+                                    Account const& issuer) -> std::pair {
+            Vault const vault{env};
+
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
+            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();
+
+            vault.closePastSubscription(subscriptionDate);
+
+            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));
+            }
+        }
+    }
+
+    // The vault's pseudo-account issues the shares, so it never holds any, and naming it as Holder
+    // asks for a clawback that cannot move anything. Before the rule an implicit amount resolved to
+    // zero shares and ended in tecPRECISION_LOSS, while an explicit one debited the vault first and
+    // was caught by the invariant that shares must move.
+    void
+    testClawbackPseudoAccountHolder()
+    {
+        using namespace test::jtx;
+
+        auto const runScenario = [this](FeatureBitset features, std::string const& prefix) {
+            bool const guarded = features[fixCleanup3_4_0];
+            Env env{*this, features};
+
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+
+            env.fund(XRP(1'000), owner, depositor, issuer);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000), owner);
+            env.trust(asset(1'000), depositor);
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const vaultSle = env.le(keylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+            env.memoize(pseudo);
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto const assetsBefore = [&]() -> Number {
+                auto const sle = env.le(keylet);
+                if (!BEAST_EXPECT(sle))
+                    return Number{};
+                return sle->at(sfAssetsTotal);
+            }();
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, implicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecPRECISION_LOSS}));
+                env.close();
+            }
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, explicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                        .amount = asset(10).value(),
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecINVARIANT_FAILED}));
+                env.close();
+            }
+
+            // Neither attempt may touch the vault, whichever way it was refused.
+            auto const sleAfter = env.le(keylet);
+            BEAST_EXPECT(sleAfter && sleAfter->at(sfAssetsTotal) == assetsBefore);
+        };
+
+        runScenario(all_, "post-rule");
+        runScenario(all_ - fixCleanup3_4_0, "pre-rule");
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultClawbackBurnShares();
+        testVaultClawbackAssets();
+        testClawbackPseudoAccountHolder();
+        testVaultEscrowedMPT();
+    }
+};
+
+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..5ed242f8a4
--- /dev/null
+++ b/src/test/app/vault/VaultClosedEnded_test.cpp
@@ -0,0 +1,1009 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#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
+         * kMinInvestmentPeriod and well below kMaxInvestmentPeriod).
+         */
+        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 kMinInvestmentPeriod => temMALFORMED. Includes the SubscriptionDate >=
+         * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
+         * sub case, the latter yielding a negative signed int64 gap that is caught by the
+         * sub-minimum branch of the gap check.
+         */
+        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 kMinInvestmentPeriod is accepted (lower bound is
+        // inclusive). A min-gap vault can originate a minimum-interval loan; see
+        // LoanSet_test::testLoanSetClosedEnded.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + minPeriod;
+            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 60s plus kLoanRedemptionBuffer) 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
+        // 60s plus kLoanRedemptionBuffer) 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..5e058a13a8
--- /dev/null
+++ b/src/test/app/vault/VaultDomain_test.cpp
@@ -0,0 +1,887 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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});
+        }
+    }
+
+    // Withdrawing out of a private vault to a third party requires both the
+    // submitter and the destination to be members of the vault's permissioned
+    // domain. Withdrawal to self is exempt: revoking vault access must not
+    // trap already deposited funds. The asset issuer is exempt as a
+    // destination, so that frozen assets can always be returned.
+    void
+    testVaultWithdrawPrivateDestinationDomain(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_4_0];
+        testcase(
+            std::string{"VaultWithdraw private vault destination domain check"} +
+            (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const beneficiary{"beneficiary"};
+        Account const outsider{"outsider"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+
+        Env env{*this, features};
+        Vault const vault{env};
+
+        env.fund(
+            XRP(100'000), issuer, owner, depositor, beneficiary, outsider, pdOwner, credIssuer);
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        // Everyone holds Layer 1 (asset) permission, so anything blocked below
+        // is blocked by the Layer 2 (vault) check alone.
+        for (auto const& account : {owner, depositor, beneficiary, outsider})
+        {
+            env.trust(asset(1'000'000), account);
+            env(pay(issuer, account, asset(10'000)));
+        }
+        env.close();
+
+        auto const domainId = [&]() {
+            pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
+            env(pdomain::setTx(pdOwner, credentials));
+            env.close();
+            return pdomain::getNewDomain(env.meta());
+        }();
+
+        auto const joinDomain = [&](Account const& account) {
+            env(credentials::create(account, credIssuer, credType));
+            env(credentials::accept(account, credIssuer, credType));
+            env.close();
+        };
+        joinDomain(depositor);
+        joinDomain(beneficiary);
+
+        auto [createTx, keylet] =
+            vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(createTx);
+        env.close();
+
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = to_string(domainId);
+            env(tx);
+            env.close();
+        }
+
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+        env.close();
+
+        auto const withdrawTo = [&, keylet = keylet](Account const& destination) {
+            auto tx =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
+            tx[sfDestination] = destination.human();
+            return tx;
+        };
+
+        {
+            // Destination holds both layers of permission.
+            env(withdrawTo(beneficiary));
+            env.close();
+        }
+
+        {
+            // Destination may hold the asset but was never let into the vault.
+            env(withdrawTo(outsider), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+        }
+
+        {
+            // The asset issuer can always receive, to keep the recovery path
+            // for frozen assets open.
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            // The vault owner gets no special treatment as a destination: it
+            // is a third party like any other and needs domain membership.
+            env(withdrawTo(owner), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+        }
+
+        {
+            // Withdrawal to self needs no Destination and stays unaffected.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+        }
+
+        {
+            // Naming yourself as the Destination is still a withdrawal to self.
+            env(withdrawTo(depositor));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw private vault submitter lost vault access"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
+            env.close();
+
+            // The exit of last resort: the submitter lost vault access but
+            // must still be able to redeem its own shares.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+
+            // Moving funds to anyone else is not allowed any more, even to a
+            // destination that is itself a domain member.
+            env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+
+            // Returning assets to the issuer stays open regardless.
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw private vault with no domain set"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            // Give the submitter its vault access back first, so that the
+            // vault having no domain is the only reason left to refuse.
+            env(credentials::create(depositor, credIssuer, credType));
+            env(credentials::accept(depositor, credIssuer, credType));
+            env.close();
+
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = "0";
+            env(tx);
+            env.close();
+
+            // Clearing the domain leaves the vault with nobody it considers
+            // authorized, so a third-party destination cannot qualify even
+            // though both ends of the payout hold a credential.
+            env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+
+            // The two exempt paths survive the domain going away.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw public vault destination unaffected"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            auto [publicTx, publicKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(publicTx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = publicKeylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto tx =
+                vault.withdraw({.depositor = owner, .id = publicKeylet.key, .amount = asset(1)});
+            tx[sfDestination] = outsider.human();
+            env(tx);
+            env.close();
+        }
+    }
+
+    void
+    testWithdrawCredentialDepositPreauth(FeatureBitset features)
+    {
+        testcase(
+            "withdraw with credential-based deposit preauth " +
+            std::string{features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"});
+        using namespace test::jtx;
+        using namespace std::chrono_literals;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        Env env{*this, features};
+
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const dest{"dest"};
+        Account const credIssuer{"credIssuer"};
+        char const credType[] = "abcde";
+
+        env.fund(XRP(1000), owner, depositor, dest, credIssuer);
+        env(fset(dest, asfDepositAuth));
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1'000'000};
+        Vault vault{env};
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        auto withdrawToDest = [&]() {
+            auto wtx =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+            wtx[sfDestination] = dest.human();
+            return wtx;
+        };
+
+        // Without any preauth, withdraw to dest fails
+        env(withdrawToDest(), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Issue and accept a credential for the depositor (with expiration)
+        auto jv = credentials::create(depositor, credIssuer, credType);
+        std::uint32_t const expiration =
+            env.current()->header().parentCloseTime.time_since_epoch().count() + 100;
+        jv[sfExpiration.jsonName] = expiration;
+        env(jv);
+        env(credentials::accept(depositor, credIssuer, credType));
+        env.close();
+
+        auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
+        auto const credIdx =
+            credentials::ledgerEntry(env, depositor, credIssuer, credType)[jss::result][jss::index]
+                .asString();
+
+        // dest authorizes deposits from holders of credentials issued by credIssuer
+        env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}}));
+        env.close();
+
+        // Withdraw without supplying credentials still fails
+        env(withdrawToDest(), Ter{tecNO_PERMISSION});
+        env.close();
+
+        if (!fixEnabled)
+        {
+            // Pre-fix: sfCredentialIDs in VaultWithdraw is rejected as disabled
+            env(withdrawToDest(), credentials::Ids({credIdx}), Ter{temDISABLED});
+            env.close();
+            return;
+        }
+
+        // Withdraw with credentials succeeds
+        env(withdrawToDest(), credentials::Ids({credIdx}));
+        env.close();
+
+        // Bad credential id is rejected
+        std::string const invalidIdx =
+            "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034";
+        env(withdrawToDest(), credentials::Ids({invalidIdx}), Ter{tecBAD_CREDENTIALS});
+        env.close();
+
+        // Malformed credential array (duplicates) is rejected by checkFields
+        env(withdrawToDest(), credentials::Ids({credIdx, credIdx}), Ter{temMALFORMED});
+        env.close();
+
+        // Valid credential not authorized by dest hits authorizedDepositPreauth error path
+        char const credType2[] = "fghij";
+        env(credentials::create(depositor, credIssuer, credType2));
+        env(credentials::accept(depositor, credIssuer, credType2));
+        env.close();
+        auto const credIdx2 =
+            credentials::ledgerEntry(env, depositor, credIssuer, credType2)[jss::result][jss::index]
+                .asString();
+        env(withdrawToDest(), credentials::Ids({credIdx2}), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Advance time past expiration: credentials yield tecEXPIRED and are deleted
+        env.close(150s);
+        BEAST_EXPECT(env.le(credKeylet));
+        env(withdrawToDest(), credentials::Ids({credIdx}), Ter{tecEXPIRED});
+        env.close();
+        BEAST_EXPECT(!env.le(credKeylet));
+    }
+
+public:
+    void
+    run() override
+    {
+        testWithDomainCheck();
+        testDomainLossAfterAcquisition();
+        testDomainCheckBuyerSideOffer();
+        testWithDomainChecXRP();
+        testVaultWithdrawPrivateDestinationDomain(all_ - fixCleanup3_4_0);
+        testVaultWithdrawPrivateDestinationDomain(all_);
+        testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0);
+        testWithdrawCredentialDepositPreauth(all_);
+    }
+};
+
+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/VaultHelpers_test.cpp b/src/test/app/vault/VaultHelpers_test.cpp
new file mode 100644
index 0000000000..d52b732a60
--- /dev/null
+++ b/src/test/app/vault/VaultHelpers_test.cpp
@@ -0,0 +1,484 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+// True unit test of `clampToAssetsTotalScale`. The function under test only
+// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a
+// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is
+// no jtx::Env and no transaction submitted anywhere in this file.
+//
+// Number regime: this suite relies on the default thread_local Number
+// mantissa range, which src/libxrpl/basics/Number.cpp initializes to
+// Large330 (19-digit mantissa, post-fixCleanup3_3_0 cusp-rounding behavior):
+//
+//   thread_local std::reference_wrapper Number::kRange =
+//       MantissaRange::Access::mantissaRange(MantissaRange::MantissaScale::Large330);
+//
+// Unlike transaction processing, this test never constructs a ledger `Rules`
+// object, so `STAmount::operator=(Number const&)` always takes its
+// `!getCurrentTransactionRules()` branch and calls `fromNumber`, independent
+// of amendment state. testProbeLarge330Regime() below asserts directly on a
+// value that only round-trips exactly under Large330, pinning the regime
+// rather than merely asserting it by comment.
+class VaultHelpers_test : public beast::unit_test::Suite
+{
+private:
+    // A single row of the clampToAssetsTotalScale table. `assetsTotal` and
+    // `delta` must already be genuine, on-grid STAmount values for `asset`.
+    struct Case
+    {
+        char const* name = nullptr;
+        Number assetsTotal;
+        Number delta;
+        std::optional expected;  // nullopt means tecPRECISION_LOSS
+    };
+
+    // Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set,
+    // mirroring what a transactor does: set the STNumber field, then call
+    // associateAsset() so it is quantized to the asset's STAmount grid, the
+    // same way VaultDeposit::doApply does for a real vault (see
+    // src/libxrpl/tx/transactors/vault/VaultDeposit.cpp).
+    static std::shared_ptr
+    makeVault(Asset const& asset, Number const& assetsTotal)
+    {
+        auto vault = std::make_shared(keylet::vault(uint256(1)));
+        vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
+        vault->at(sfAssetsTotal) = assetsTotal;
+        associateAsset(*vault, asset);
+        return vault;
+    }
+
+    // Runs every case in `cases` against `asset`, once per ambient rounding
+    // mode. The function must give the same answer under all four modes,
+    // and its answer must match the hand-derived `expected` value.
+    template 
+    void
+    runCases(Asset const& asset, std::array const& cases)
+    {
+        std::array const modes{
+            Number::RoundingMode::ToNearest,
+            Number::RoundingMode::Downward,
+            Number::RoundingMode::Upward,
+            Number::RoundingMode::TowardsZero};
+
+        for (auto const& c : cases)
+        {
+            testcase(c.name);
+
+            auto const vault = makeVault(asset, c.assetsTotal);
+            BEAST_EXPECTS(
+                Number(vault->at(sfAssetsTotal)) == c.assetsTotal,
+                std::string(c.name) +
+                    ": assetsTotal is not a genuine on-grid STAmount value (associateAsset "
+                    "changed it)");
+
+            STAmount const delta{asset, c.delta};
+            BEAST_EXPECTS(
+                Number(delta) == c.delta,
+                std::string(c.name) + ": delta is not a genuine on-grid STAmount value");
+
+            std::optional> reference;
+            for (auto const mode : modes)
+            {
+                NumberRoundModeGuard const rg(mode);
+                auto const result = clampToAssetsTotalScale(vault, delta);
+
+                // The function must be insensitive to the caller's ambient
+                // rounding mode: every mode must agree with the first one
+                // tried.
+                if (!reference)
+                {
+                    reference = result;
+                }
+                else
+                {
+                    BEAST_EXPECTS(
+                        result.has_value() == reference->has_value(),
+                        std::string(c.name) + ": result depends on ambient rounding mode");
+                    if (result.has_value() && reference->has_value())
+                    {
+                        BEAST_EXPECTS(
+                            *result == **reference,
+                            std::string(c.name) + ": value depends on ambient rounding mode");
+                    }
+                    else if (!result.has_value() && !reference->has_value())
+                    {
+                        BEAST_EXPECTS(
+                            result.error() == reference->error(),
+                            std::string(c.name) + ": error depends on ambient rounding mode");
+                    }
+                }
+
+                if (!c.expected)
+                {
+                    BEAST_EXPECTS(
+                        !result.has_value(),
+                        std::string(c.name) + ": expected tecPRECISION_LOSS, got success value " +
+                            (result.has_value() ? result->getText() : std::string()));
+                    if (!result.has_value())
+                    {
+                        BEAST_EXPECTS(
+                            result.error() == tecPRECISION_LOSS,
+                            std::string(c.name) + ": expected tecPRECISION_LOSS, got " +
+                                transToken(result.error()));
+                    }
+                    continue;
+                }
+
+                STAmount const expected{asset, *c.expected};
+                if (!BEAST_EXPECTS(
+                        result.has_value(),
+                        std::string(c.name) + ": expected success (" + expected.getText() +
+                            "), got " + transToken(result.error())))
+                {
+                    continue;
+                }
+
+                BEAST_EXPECTS(
+                    *result == expected,
+                    std::string(c.name) + ": expected " + expected.getText() + ", got " +
+                        result->getText());
+
+                // The result must always be positive...
+                BEAST_EXPECT(Number(*result) > Number{0});
+
+                // ...and never larger in magnitude than the requested delta.
+                BEAST_EXPECT(abs(Number(*result)) <= abs(c.delta));
+
+                // For IOU rows, re-flooring the result on the posterior grid
+                // must be a no-op: the result is already exactly
+                // representable at that scale.
+                //
+                // For debits this holds directly at postScale, because the
+                // result IS `roundToScale(magnitude, postScale, Downward)` by
+                // construction. For credits the result is
+                // `roundedPosterior - assetsTotal`, where roundedPosterior
+                // sits exactly on the postScale grid but assetsTotal sits on
+                // its own (possibly finer) natural grid; the difference of a
+                // multiple of 10^postScale and a multiple of 10^assetsScale
+                // is only guaranteed exact at the FINER of the two scales.
+                // Row 7 below ("overcredit fix across a scale boundary") is
+                // exactly this case: assetsTotal's own scale (-15) is finer
+                // than postScale (-14), so checking exactness at postScale
+                // alone fails even though the implementation is correct.
+                if (!asset.integral())
+                {
+                    bool const isDebit = c.delta.mantissa() < 0;
+                    Number const posterior =
+                        isDebit ? c.assetsTotal - Number(*result) : c.assetsTotal + Number(*result);
+                    int const postScale = scale(posterior, asset);
+                    int const checkScale =
+                        isDebit ? postScale : std::min(postScale, scale(c.assetsTotal, asset));
+                    STAmount const reFloored =
+                        roundToScale(*result, checkScale, Number::RoundingMode::Downward);
+                    BEAST_EXPECTS(
+                        reFloored == *result,
+                        std::string(c.name) + ": result " + result->getText() +
+                            " is not exact on the posterior grid (scale " +
+                            std::to_string(checkScale) + ")");
+                }
+            }
+        }
+    }
+
+    // Pins the Number mantissa regime this suite relies on. Under Large330,
+    // a 19-digit mantissa (max 10^19-1) is exact where a legacy 16-digit
+    // ("Small", max 10^16-1) regime would have to round it down to 16
+    // significant digits, changing both mantissa and exponent.
+    void
+    testProbeLarge330Regime()
+    {
+        testcase("probe: default Number regime is Large330 (19-digit mantissa)");
+
+        BEAST_EXPECT(Number::getMantissaScale() == MantissaRange::MantissaScale::Large330);
+
+        // std::numeric_limits::max(), 19 significant digits.
+        // This is already inside Large330's [10^18, 10^19-1] range, so
+        // constructing it is a no-op; under "Small" it would have to lose
+        // its low 3 digits.
+        Number const probe{9'223'372'036'854'775'807LL, 0};
+        BEAST_EXPECT(probe.mantissa() == 9'223'372'036'854'775'807LL);
+        BEAST_EXPECT(probe.exponent() == 0);
+    }
+
+    // -------------------------------------------------------------------
+    // IOU debits (delta negative).
+    // -------------------------------------------------------------------
+    void
+    testIouDebits(Asset const& iou)
+    {
+        std::array const cases{
+            Case{
+                // T = 1000000.000000005, delta = -1e-9.
+                // Posterior = 1000000.000000004, still 16 significant
+                // digits at exponent -9 (no rounding, no decade change).
+                // postScale = -9. magnitude 1e-9 has its own exponent -24
+                // (finer than -9), so it must be actually floored: 1e-9 is
+                // exactly 1 ULP at scale -9, so flooring is a no-op.
+                .name = "IOU debit: on-grid, same decade",
+                .assetsTotal = Number{1'000'000'000'000'005LL, -9},
+                .delta = Number{-1, -9},
+                .expected = Number{1, -9},
+            },
+            Case{
+                // T = 1000000, delta = -7.3e-10.
+                // Posterior = 999999.99999999927 exactly (17 significant
+                // digits: 15 nines, then "27"). Rounding to 16 digits
+                // (ToNearest) rounds the trailing "...92.7" up to
+                // "...93", giving mantissa 9999999999999993 at exponent
+                // -10 -- postScale = -10, ONE DIGIT FINER than the naive
+                // "posterior stays in T's decade at -9" guess, because
+                // subtracting anything positive from an exact power-of-ten
+                // total necessarily drops into the next lower decade
+                // (1000000 has 7 integer digits, 999999.x has 6).
+                // At scale -10 the ULP is 1e-10, and floor(7.3) = 7, so
+                // the debit is NOT sub-ULP: it floors to 7e-10, not to
+                // zero. See discrepancy note in the report.
+                .name = "IOU debit: sub-ULP at the naive scale, but not at the true postScale",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-73, -11},
+                .expected = Number{7, -10},
+            },
+            Case{
+                // T = 1000000, delta = -5.3e-9.
+                // Posterior = 999999.9999999947 exactly -- this needs only
+                // 16 significant digits (14 nines, then "47"), so it is
+                // exactly representable with NO rounding at exponent -10.
+                // postScale = -10 (again one digit finer than T's own -9,
+                // for the same power-of-ten-boundary reason as the row
+                // above). At that grid 5.3e-9 is exactly 53 ULPs (integer),
+                // so it floors to itself, unchanged.
+                .name = "IOU debit: exact at the true (finer) postScale",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-53, -10},
+                .expected = Number{53, -10},
+            },
+            Case{
+                // T = 1.000000000000000, delta = -7.3e-16.
+                // Posterior = 0.99999999999999927 exactly (17 significant
+                // digits: 15 nines then "27"). Rounding to 16 digits
+                // (ToNearest) gives mantissa 9999999999999993 at exponent
+                // -16 -- postScale = -16. At that grid, 7.3e-16 is 7.3
+                // ULPs (not integral), so it floors to 7e-16, not to
+                // itself. See discrepancy note in the report.
+                .name = "IOU debit: decade-crossing debit, floored (not exact) at finer grid",
+                .assetsTotal = Number{1, 0},
+                .delta = Number{-73, -17},
+                .expected = Number{7, -16},
+            },
+            Case{
+                // T = 1000000, delta = -999999.9999999999 (9.999999999999999e5).
+                // Posterior = 0.0000000001 = 1e-10 exactly. postScale is
+                // the exponent of 1e-10 as a canonical STAmount, i.e. -25 --
+                // far finer than the magnitude's own exponent (-10).
+                // roundToScale short-circuits ("value.exponent() >= scale")
+                // and returns the magnitude unchanged.
+                .name = "IOU debit: near-total debit, unchanged (finer postScale than magnitude)",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-9'999'999'999'999'999LL, -10},
+                .expected = Number{9'999'999'999'999'999LL, -10},
+            },
+        };
+
+        runCases(iou, cases);
+    }
+
+    // -------------------------------------------------------------------
+    // IOU credits (delta positive).
+    // -------------------------------------------------------------------
+    void
+    testIouCredits(Asset const& iou)
+    {
+        std::array const cases{
+            Case{
+                // T = 1000000, delta = +2e-9. Posterior = 1000000.000000002,
+                // exactly 16 significant digits at exponent -9
+                // (postScale = -9, unchanged from T -- addition never
+                // crosses below the 1e6 boundary the way subtraction does).
+                // magnitude is already exact at that scale, so it passes
+                // through unchanged.
+                .name = "IOU credit: on-grid",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{2, -9},
+                .expected = Number{2, -9},
+            },
+            Case{
+                // T = 9.999999999999999, delta = +5.
+                // Exact posterior = 14.999999999999999 (17 significant
+                // digits: "14" then 15 nines). postScale is computed under
+                // ToNearest at the Number (19-digit) level: normalized
+                // mantissa 1499999999999999900 (exponent -17) divided by
+                // 1000 (to reach 16-digit IOU precision) gives
+                // 1499999999999999.9, which rounds UP to 1500000000000000
+                // -- i.e. exactly 15, at exponent -14. postScale = -14.
+                // Downward-guarded posterior (exact, no rounding needed
+                // since 17 digits < 19): 14.999999999999999. Flooring THAT
+                // to 16 digits at scale -14 (Downward) gives
+                // 1499999999999999 * 10^-14 = 14.99999999999999 (postScale
+                // already matches the STAmount's own exponent, so no
+                // further roundToScale is applied).
+                // actualDelta = 14.99999999999999 - 9.999999999999999
+                //             = 4.999999999999991.
+                // This mirrors testBugVaultDepositOvercreditsAcrossScaleBoundary
+                // in VaultBugs_test.cpp (same seed/deposit values), which
+                // asserts post-fix `credited <= paid` rather than an exact
+                // number; this row pins the exact value.
+                .name = "IOU credit: overcredit fix across a scale boundary",
+                .assetsTotal = Number{9'999'999'999'999'999LL, -15},
+                .delta = Number{5, 0},
+                .expected = Number{4'999'999'999'999'991LL, -15},
+            },
+            Case{
+                // Finding-1 regression: T = 1000000, delta = +9.999999999999999e-10.
+                // The exact sum needs ~25 significant digits (1000000 at
+                // position 6, delta's last digit at position -25), far
+                // beyond Number's 19-digit mantissa.
+                //
+                // postScale (computed under ToNearest): the digits of delta
+                // that land within the 19-digit window (positions -10..-12,
+                // "999") plus an all-nines remainder below position -12
+                // round UP under ToNearest, carrying all the way through
+                // the intervening zeros: the sum rounds to exactly
+                // 1000000.000000001, i.e. postScale = -9.
+                //
+                // But the credit branch computes the *posterior* under a
+                // Downward guard, not ToNearest: positions -10..-12 stay
+                // "999" (no carry), giving posterior = 1000000.000000000999
+                // exactly. Flooring that (Downward) to scale -9 truncates
+                // the "999" entirely, landing back on exactly 1000000 --
+                // i.e. the same as T. actualDelta = 0 => tecPRECISION_LOSS.
+                // This is the ambient-rounding leak the Downward guard on
+                // the credit-side sum exists to close; this row is a
+                // regression test that the guard is doing its job.
+                .name = "IOU credit: Finding-1 regression, ToNearest sum would overcredit",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{9'999'999'999'999'999LL, -25},
+                .expected = std::nullopt,
+            },
+            Case{
+                // Same shape as the row above, but delta = +9.995e-10 is a
+                // 19-digit half-even tie at the position-(-12) cusp: the
+                // remainder below the retained "999" digits is exactly
+                // 0.5 ULP, and ToNearest ties-to-even rounds the (odd) "9"
+                // up, carrying the same way. Downward-guarded posterior
+                // still truncates to "...000999" and floors back to T, so
+                // the outcome is identical: tecPRECISION_LOSS.
+                .name = "IOU credit: Finding-1 regression, 19-digit half-even tie",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{9'995, -13},
+                .expected = std::nullopt,
+            },
+            Case{
+                // T = 0, delta = +3.7e-5. Posterior grid is delta's own
+                // scale (postScale = -20, the canonical exponent of
+                // 3.7e-5), so the magnitude is trivially unchanged.
+                .name = "IOU credit: zero-total vault",
+                .assetsTotal = Number{0},
+                .delta = Number{37, -6},
+                .expected = Number{37, -6},
+            },
+            Case{
+                // T = 1000000, delta = +4e-10. Exact sum needs 17
+                // significant digits (leading "1" at position 6, trailing
+                // "4" at position -10); rounding to 16 digits drops the "4"
+                // entirely (0.4 ULP at scale -9 rounds down under both
+                // ToNearest and Downward), so postScale = -9 and the
+                // Downward-guarded posterior floors straight back to T.
+                // actualDelta = 0 => tecPRECISION_LOSS.
+                .name = "IOU credit: sub-ULP credit",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{4, -10},
+                .expected = std::nullopt,
+            },
+        };
+
+        runCases(iou, cases);
+    }
+
+    // -------------------------------------------------------------------
+    // Integral assets (XRP, MPT): rounding is a no-op, magnitude is
+    // returned unchanged and positive regardless of delta's sign. This is
+    // a regression test for a signed-return bug: the function must not
+    // hand back a negative delta for a debit.
+    // -------------------------------------------------------------------
+    void
+    testIntegralAssets(Asset const& mpt, Asset const& xrp)
+    {
+        std::array const mptCases{
+            Case{
+                .name = "MPT debit: magnitude is positive, not the signed delta",
+                .assetsTotal = Number{1'000'000},
+                .delta = Number{-5},
+                .expected = Number{5},
+            },
+            Case{
+                .name = "MPT credit: unchanged",
+                .assetsTotal = Number{1'000'000},
+                .delta = Number{7},
+                .expected = Number{7},
+            },
+        };
+        runCases(mpt, mptCases);
+
+        std::array const xrpCases{
+            Case{
+                .name = "XRP debit: magnitude is positive, not the signed delta",
+                .assetsTotal = Number{100'000},
+                .delta = Number{-3},
+                .expected = Number{3},
+            },
+            Case{
+                .name = "XRP credit: unchanged",
+                .assetsTotal = Number{100'000},
+                .delta = Number{10},
+                .expected = Number{10},
+            },
+        };
+        runCases(xrp, xrpCases);
+    }
+
+public:
+    void
+    run() override
+    {
+        testProbeLarge330Regime();
+
+        test::jtx::Account const issuer{"issuer"};
+        Issue const iou{toCurrency("USD"), issuer.id()};
+        MPTIssue const mpt{makeMptID(1, issuer.id())};
+        Issue const xrp = xrpIssue();
+
+        testIouDebits(iou);
+        testIouCredits(iou);
+        testIntegralAssets(mpt, xrp);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultInvariantPrecision_test.cpp b/src/test/app/vault/VaultInvariantPrecision_test.cpp
new file mode 100644
index 0000000000..a7eeda34ae
--- /dev/null
+++ b/src/test/app/vault/VaultInvariantPrecision_test.cpp
@@ -0,0 +1,458 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+// With fixCleanup3_4_0 disabled the six delta invariants and the
+// lossUnrealized > (assetsTotal - assetsAvailable) gap invariant spuriously
+// fire on legitimate flows; with the amendment enabled the one-unit
+// tolerance absorbs the sub-ULP drift and every one of these transactions
+// must succeed.  Exactness (assetsTotal delta == assetsAvailable delta
+// exactly) is covered by VaultTransactorPrecision_test.
+class VaultInvariantPrecision_test : public VaultPrecisionFixture
+{
+    // Deposit small integer amounts into an A-1 vault.  Pre-amendment,
+    // deposits of 1, 7, and 10'000'000 land on assetsTotal/assetsAvailable
+    // grids that disagree by one ULP and the invariant fires.  Post-
+    // amendment the tolerance-widened check accepts the same states.
+    void
+    testDepositBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 deposit boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            auto const before = read(env, f);
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+
+                auto const after = read(env, f);
+                Number const tDelta = after.assetsTotal - before.assetsTotal;
+                Number const aDelta = after.assetsAvailable - before.assetsAvailable;
+                Number const requested = asset(amount).number();
+
+                BEAST_EXPECT(tDelta <= requested);
+
+                Number const gap = tDelta > aDelta ? tDelta - aDelta : aDelta - tDelta;
+                BEAST_EXPECT(gap <= oneUnit(asset, after.assetsTotal));
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    actual == tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
+                        transToken(actual));
+            }
+        }
+    }
+
+    // Withdraw long-mantissa share counts from an A-1 vault.  Pre-fix
+    // some counts trip the withdraw delta invariants; post-fix none does.
+    void
+    testWithdrawBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 withdraw boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kShareCounts{
+            99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u, 333'333'333u};
+
+        // Fill the vault with enough shares that every count below is
+        // available to the depositor.
+        Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!f.asset || !f.broker)
+        {
+            BEAST_EXPECT(f.asset && f.broker);
+            return;
+        }
+        auto const& asset = *f.asset;
+
+        Vault const v{env};
+        // Deposit a large amount so we can afford every withdrawal below.
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        for (auto const count : kShareCounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal < count)
+                continue;
+
+            STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}};
+            env(v.withdraw(
+                    {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual != tecINVARIANT_FAILED,
+                    "shares=" + std::to_string(count) + " unexpected invariant failure");
+
+                if (actual == tesSUCCESS)
+                {
+                    auto const after = read(env, f);
+                    Number const tDelta = before.assetsTotal - after.assetsTotal;
+                    Number const pDelta = before.pseudo - after.pseudo;
+                    Number const gap = tDelta > pDelta ? tDelta - pDelta : pDelta - tDelta;
+                    // VaultTransactorPrecision_test tightens this to strict
+                    // equality.
+                    BEAST_EXPECT(gap <= oneUnit(asset, before.assetsTotal));
+                }
+            }
+            // Pre-fix behaviour is fixture-dependent: some share counts may
+            // succeed even without the amendment.  The important property is
+            // that post-fix no legitimate withdrawal is rejected by the
+            // widened invariant.
+        }
+    }
+
+    // Clawback of small IOU amounts against a live-loan vault.  Pre-fix
+    // some amounts trip the clawback delta invariants; post-fix none does.
+    // Also assert the owner force-burn path returns tecNO_PERMISSION
+    // under both amendment states (it never enters assetsToClawback).
+    void
+    testClawbackBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 clawback boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 99, 333, 993, 2000};
+
+        Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false, /*allowClawback=*/true);
+        if (!f.asset || !f.broker)
+        {
+            BEAST_EXPECT(f.asset && f.broker);
+            return;
+        }
+        auto const& asset = *f.asset;
+
+        Vault const v{env};
+
+        // Give the depositor a stake so that the issuer has something to
+        // claw back.
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(2'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        for (auto const amount : kAmounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal == 0)
+                continue;
+
+            env(v.clawback(
+                    {.issuer = f.issuer,
+                     .id = f.vaultKeylet.key,
+                     .holder = f.depositor,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual != tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " unexpected invariant failure");
+            }
+            // Pre-fix behaviour is fixture-dependent: some clawback amounts
+            // may succeed even without the amendment.  The important
+            // property is that post-fix no legitimate clawback is rejected
+            // by the widened invariant.
+        }
+
+        // Owner force-burn only succeeds against an EMPTY vault (see
+        // VaultClawback::preclaim).  Our fixture keeps a live loan, so
+        // this must return tecNO_PERMISSION regardless of the amendment.
+        env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}),
+            Ter(tecNO_PERMISSION));
+        env.close();
+    }
+
+    // Deposit into an A-3 vault where the impaired-loan gap plus the
+    // interest earned from the sibling repayment lands L > (T - A) by
+    // sub-ULP.  Pre-fix the loss invariant fires; post-fix it does not.
+    void
+    testLossInvariantA3(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-3 loss invariant sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+
+                auto const after = read(env, f);
+                BEAST_EXPECT(
+                    after.lossUnrealized <= (after.assetsTotal - after.assetsAvailable) +
+                        oneUnit(asset, after.assetsTotal));
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    actual == tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
+                        transToken(actual));
+            }
+        }
+    }
+
+    // Full 17-magnitude A-1 deposit sweep.  Pre-fix {1, 7, 10'000'000}
+    // are the boundary amounts that fail; post-fix every amount succeeds.
+    void
+    testA1DepositMagnitudes(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 deposit magnitude sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{
+            1,
+            2,
+            5,
+            7,
+            10,
+            50,
+            100,
+            500,
+            1'000,
+            5'000,
+            10'000,
+            50'000,
+            100'000,
+            500'000,
+            1'000'000,
+            5'000'000,
+            10'000'000};
+        std::array const kPreFixFailures{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+            }
+            else
+            {
+                bool const shouldFail =
+                    std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
+                if (shouldFail)
+                {
+                    BEAST_EXPECTS(
+                        actual == tecINVARIANT_FAILED,
+                        "pre-fix amount=" + std::to_string(amount) +
+                            " expected tecINVARIANT_FAILED, got " + transToken(actual));
+                }
+                // For other amounts pre-fix, we accept any outcome; the
+                // interesting property is only asserted for the known-failing
+                // ones.
+            }
+        }
+    }
+
+    // A-3 deposit sweep.  Pre-fix {1, 7, 10'000, 10'000'000} fail; post-fix
+    // every amount succeeds.  99'999 (delta tolerance) and 10'000'000
+    // (loss tolerance) are the two boundary cases that motivate this PR.
+    void
+    testA3DepositMagnitudes(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-3 deposit magnitude sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{
+            1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000, 99'999};
+
+        std::array const kPreFixFailures{1, 7, 10'000, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+            }
+            else
+            {
+                bool const shouldFail =
+                    std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
+                if (shouldFail)
+                {
+                    BEAST_EXPECTS(
+                        actual == tecINVARIANT_FAILED,
+                        "pre-fix amount=" + std::to_string(amount) +
+                            " expected tecINVARIANT_FAILED, got " + transToken(actual));
+                }
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        for (auto const& features : {all_ - fixCleanup3_4_0, all_})
+        {
+            testDepositBoundaryInvariant(features);
+            testWithdrawBoundaryInvariant(features);
+            testClawbackBoundaryInvariant(features);
+            testLossInvariantA3(features);
+            testA1DepositMagnitudes(features);
+            testA3DepositMagnitudes(features);
+        }
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultInvariantPrecision, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/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/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h
new file mode 100644
index 0000000000..22a3276fdf
--- /dev/null
+++ b/src/test/app/vault/VaultPrecisionFixture.h
@@ -0,0 +1,256 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+// Shared fixture for VaultInvariantPrecision_test and
+// VaultTransactorPrecision_test.
+// impairAndPaySibling=false: 1000 USD vault and one ordinary loan.
+// impairAndPaySibling=true: a second loan is impaired then a sibling is paid
+// off, leaving lossUnrealized at assetsTotal - assetsAvailable.
+class VaultPrecisionFixture : public LoanTestBase
+{
+protected:
+    static constexpr std::uint32_t kFixturePaymentInterval = 86400u * 30u;
+    static constexpr std::uint32_t kFixtureGracePeriod = 86400u * 30u;
+    static constexpr std::uint32_t kFixturePaymentTotal = 120u;
+    // 10% APR, expressed in tenth-bips (1000 = 10.00 %).
+    static constexpr std::uint32_t kFixtureInterestTenthBips = 1000u;
+
+    struct Fixture
+    {
+        // Every account is initialised with a placeholder name because
+        // jtx::Account has no default constructor; setupSingleLoanVault
+        // overwrites them.
+        jtx::Account issuer{"vp_issuer_placeholder"};
+        jtx::Account lender{"vp_lender_placeholder"};
+        jtx::Account borrower{"vp_borrower_placeholder"};
+        // Distinct account used to deposit into the vault. Keeps share
+        // ownership independent of the initial vault seeding.
+        jtx::Account depositor{"vp_depositor_placeholder"};
+        // Optional so callers can BEAST_EXPECT(f.asset && f.broker)
+        // after setup; both are populated in the happy path.
+        std::optional asset;
+        std::optional broker;
+        // Keylet has no default constructor. Fill with an obviously
+        // meaningless placeholder; setupSingleLoanVault overwrites the
+        // fields that matter.
+        Keylet vaultKeylet{ltACCOUNT_ROOT, uint256{}};
+        Keylet loan1Keylet{ltACCOUNT_ROOT, uint256{}};
+        // Only meaningful when impairAndPaySibling == true.
+        Keylet loan2Keylet{ltACCOUNT_ROOT, uint256{}};
+        jtx::Account vaultAccount{"vp_vault_pseudo_placeholder"};
+        MPTID share;
+    };
+
+    // Read-only snapshot of the vault + share issuance at a point in time.
+    // Uses Number for exact arithmetic (no re-quantization).
+    struct Numbers
+    {
+        Asset asset;
+        MPTIssue share;
+        // The {} initializers are not redundant: Number's default constructor is explicit, so
+        // fields omitted from the designated initializer in read() below would otherwise fail
+        // copy-list-initialization.
+        // NOLINTBEGIN(readability-redundant-member-init)
+        Number assetsTotal{};      // sfAssetsTotal
+        Number assetsAvailable{};  // sfAssetsAvailable
+        Number lossUnrealized{};   // sfLossUnrealized
+        Number pseudo{};           // vault pseudo-account balance in the asset
+        Number sharesTotal{};      // sfOutstandingAmount on the share MPT
+        // NOLINTEND(readability-redundant-member-init)
+    };
+
+    static Numbers
+    read(jtx::Env const& env, Fixture const& f)
+    {
+        Numbers n{.asset = f.asset ? f.asset->raw() : Asset{}, .share = MPTIssue{f.share}};
+        if (auto const vaultSle = env.le(f.vaultKeylet))
+        {
+            n.assetsTotal = vaultSle->at(sfAssetsTotal);
+            n.assetsAvailable = vaultSle->at(sfAssetsAvailable);
+            n.lossUnrealized = vaultSle->at(sfLossUnrealized);
+        }
+        if (auto const issuanceSle = env.le(keylet::mptokenIssuance(f.share)))
+        {
+            n.sharesTotal = issuanceSle->at(sfOutstandingAmount);
+        }
+        if (f.asset)
+            n.pseudo = env.balance(f.vaultAccount, *f.asset).number();
+        return n;
+    }
+
+    // One unit at the STAmount scale of `assetsTotalAfter`.  Used as the
+    // tolerance in one-unit-band assertions.
+    static Number
+    oneUnit(Asset const& asset, Number const& assetsTotalAfter)
+    {
+        return Number{1, scale(assetsTotalAfter, asset)};
+    }
+
+    // Build the shared vault + loan(s) layout.  The caller constructs
+    // `env` with whatever FeatureBitset they want to exercise; this helper
+    // just uses it.  If `allowClawback` is true, the issuer's
+    // asfAllowTrustLineClawback flag is set BEFORE any trust line is
+    // established for that issuer.  A separate env.close() runs so the
+    // flag lands in the ledger before the trust lines are set up.
+    static Fixture
+    setupSingleLoanVault(jtx::Env& env, bool impairAndPaySibling, bool allowClawback = false)
+    {
+        using namespace jtx;
+        using namespace jtx::loan;
+        using namespace jtx::loan_broker;
+
+        Fixture f;
+        f.issuer = Account{"vp_issuer"};
+        f.lender = Account{"vp_lender"};
+        f.borrower = Account{"vp_borrower"};
+        f.depositor = Account{"vp_depositor"};
+
+        env.fund(XRP(1'000'000), f.issuer, f.lender, f.borrower, f.depositor);
+        env.close();
+
+        // Must be set BEFORE any trust line to `issuer` is created.
+        if (allowClawback)
+        {
+            env(fset(f.issuer, asfAllowTrustLineClawback));
+            env.close();
+        }
+
+        PrettyAsset const asset = f.issuer["USD"];
+        f.asset = asset;
+
+        env.trust(asset(1'000'000'000), f.lender);
+        env.trust(asset(1'000'000'000), f.borrower);
+        env.trust(asset(1'000'000'000), f.depositor);
+        env(pay(f.issuer, f.lender, asset(100'000'000)));
+        env(pay(f.issuer, f.borrower, asset(100'000'000)));
+        env(pay(f.issuer, f.depositor, asset(100'000'000)));
+        env.close();
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000,
+            .debtMax = 0,
+            .coverRateMin = percentageToTenthBips(1),
+            .coverDeposit = 10'000,
+            .managementFeeRate = TenthBips16{100},
+            .coverRateLiquidation = xrpl::lending::kMaxCoverRate};
+
+        // Build the vault + broker manually (rather than calling
+        // createVaultAndBroker) so we can seed only the lender/depositor
+        // trust lines we set up above, and skip the LoanTestBase auto
+        // funding that assumes an XRP asset.
+        Vault const vault{env};
+        auto [createTx, vaultKeylet] = vault.create({.owner = f.lender, .asset = asset});
+        env(createTx);
+        env.close();
+        f.vaultKeylet = vaultKeylet;
+
+        env(vault.deposit(
+            {.depositor = f.lender,
+             .id = vaultKeylet.key,
+             .amount = asset(brokerParams.vaultDeposit)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender)));
+
+        env(set(f.lender, vaultKeylet.key, brokerParams.flags),
+            kManagementFeeRate(brokerParams.managementFeeRate),
+            kDebtMaximum(asset(brokerParams.debtMax).value()),
+            kCoverRateMinimum(brokerParams.coverRateMin),
+            kCoverRateLiquidation(TenthBips32(brokerParams.coverRateLiquidation)));
+        env(coverDeposit(f.lender, brokerKeylet.key, asset(brokerParams.coverDeposit).value()));
+        env.close();
+
+        f.broker = BrokerInfo{asset, brokerKeylet, vaultKeylet, brokerParams};
+
+        auto const vaultSle = env.le(vaultKeylet);
+        f.vaultAccount = Account{"vp_vault_pseudo", vaultSle->at(sfAccount)};
+        f.share = vaultSle->at(sfShareMPTID);
+
+        Fee const bigFee{env.current()->fees().base * 200};
+
+        auto const setLoan = [&](Number const& principal) -> Keylet {
+            auto const brokerSle = env.le(brokerKeylet);
+            auto const loanKeylet = keylet::loan(
+                brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+            env(loan::set(f.borrower, brokerKeylet.key, asset(principal).number()),
+                Sig(sfCounterpartySignature, f.lender),
+                jtx::loan::kInterestRate(TenthBips32{kFixtureInterestTenthBips}),
+                jtx::loan::kPaymentTotal(kFixturePaymentTotal),
+                jtx::loan::kPaymentInterval(kFixturePaymentInterval),
+                jtx::loan::kGracePeriod(kFixtureGracePeriod),
+                bigFee);
+            env.close();
+            return loanKeylet;
+        };
+
+        // Loan 1: principal 7, the one ordinary loan in both fixtures.
+        // With vault deposit 1000, this leaves A ≈ 993 (see plan).
+        f.loan1Keylet = setLoan(Number{7});
+
+        if (!impairAndPaySibling)
+            return f;
+
+        // Loan 2: sibling loan of principal 11.
+        f.loan2Keylet = setLoan(Number{11});
+
+        // Pay off loan 2 in full so its total value flows into the vault
+        // and pushes T-A upward, meeting the residual loss.  Generous
+        // upper bound; the transactor takes only what is due.
+        //
+        // This happens before the impair below because impair under
+        // fixCleanup3_4_0 requires loan 1 to already be late, and the two
+        // loans are originated close enough together that advancing past
+        // loan 1's due date also makes loan 2 late — which would reject
+        // this full payment with tecEXPIRED.
+        auto const payoff = asset(Number{50}).value();
+        env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee);
+        env.close();
+
+        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            std::uint32_t const dueDate = env.le(f.loan1Keylet)->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
+        env.close();
+
+        return f;
+    }
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp
new file mode 100644
index 0000000000..dbceb1cb9c
--- /dev/null
+++ b/src/test/app/vault/VaultRPC_test.cpp
@@ -0,0 +1,620 @@
+#include 
+#include 
+#include 
+#include 
+#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"));
+            }
+        };
+
+        // An error response must carry a registered token together with the matching code and
+        // message, so that clients dispatching on either of them reach the same conclusion.
+        auto const checkError = [this](
+                                    json::Value const& result,
+                                    std::string const& token,
+                                    ErrorCodeI const code,
+                                    std::string const& message) {
+            BEAST_EXPECT(result[jss::error].asString() == token);
+            BEAST_EXPECT(result[jss::error_code].asInt() == code);
+            BEAST_EXPECT(result[jss::error_message].asString() == message);
+        };
+
+        std::string const badSeqMessage = "Invalid field 'seq', not a positive 32-bit integer.";
+        std::string const badFieldsMessage =
+            "Must specify either 'vault_id' or both 'owner' and 'seq'.";
+
+        {
+            testcase("RPC ledger_entry selected by key");
+            json::Value jvParams;
+            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));
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
+        }
+
+        {
+            testcase("RPC vault_info json numeric vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = 0;
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
+        }
+
+        {
+            testcase("RPC vault_info json object vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = json::Value(json::ValueType::Object);
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
+        }
+
+        {
+            // An all-zero key is a well-formed request for a vault that cannot exist, not a
+            // malformed one. parseHex accepts both the padded form and the short "0".
+            testcase("RPC vault_info json all zero vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = strHex(uint256(beast::kZero));
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
+        }
+
+        {
+            testcase("RPC vault_info json short zero vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = "0";
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
+        }
+
+        {
+            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));
+            checkError(
+                jv[jss::result],
+                "actMalformed",
+                RpcActMalformed,
+                "Invalid field 'owner', not AccountID.");
+        }
+
+        {
+            testcase("RPC vault_info json array owner");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::owner] = json::Value(json::ValueType::Array);
+            jvParams[jss::seq] = sequence;
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(
+                jv[jss::result],
+                "actMalformed",
+                RpcActMalformed,
+                "Invalid field 'owner', not AccountID.");
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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));
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
+        }
+
+        {
+            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 zero index");
+            json::Value jv = env.rpc("vault_info", "0", "validated");
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
+        }
+
+        {
+            testcase("RPC vault_info command line unknown index");
+            json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
+        }
+
+        {
+            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..c2858a204d
--- /dev/null
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -0,0 +1,1340 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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) {
+            // These scale-focused tests build an open-ended vault and
+            // exercise deposit/withdraw/clawback (with one test also
+            // attaching a loan broker). featureLendingProtocolV1_1 adds a
+            // closed-ended vault gate on LoanBrokerSet::preclaim and is
+            // orthogonal to what this suite asserts, so strip it here.
+            Env env{*this, testableAmendments() - featureLendingProtocolV1_1};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            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 (truncated post-fixCleanup3_4_0)");
+                // Pre-fixCleanup3_4_0:
+                //   shares = round(875 * 3.75 / 87.5) = 38
+                //   assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
+                // Post-fixCleanup3_4_0:
+                //   shares = floor(37.5) = 37
+                //   assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.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 - 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(875 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 37, 0)));
+            }
+
+            {
+                testcase("Scale withdraw with rounding shares down");
+                // Chained state: 838 shares outstanding, 83.8 assets.
+                //   shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
+                //   assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.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(838 - 37));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(838 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(838 - 37, 0)));
+            }
+
+            {
+                testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0");
+                // Chained state: 801 shares outstanding, 80.1 assets.
+                //   shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
+                // Zero shares => tecPRECISION_LOSS. State is unchanged.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(9, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 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 (truncated post-fixCleanup3_4_0)");
+                // Pre-fixCleanup3_4_0:
+                //   shares = round(875 * 3.75 / 87.5) = 38
+                //   assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
+                // Post-fixCleanup3_4_0:
+                //   shares = floor(37.5) = 37
+                //   assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.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 - 37));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 37, 0)));
+            }
+
+            {
+                testcase("Scale clawback with rounding shares down");
+                // Chained state: 838 shares outstanding, 83.8 assets.
+                //   shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
+                //   assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.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(838 - 37));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(838 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(838 - 37, 0)));
+            }
+
+            {
+                testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0");
+                // Chained state: 801 shares outstanding, 80.1 assets.
+                //   shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
+                // Zero shares => tecPRECISION_LOSS. State is unchanged.
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(9, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 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));
+            }
+        });
+
+        // peek() writes the open ledger only; do not close() before le().
+        auto seedLargeTotal = [](Env& env,
+                                 Data& d,
+                                 Number const& total,
+                                 Number const& available,
+                                 std::uint64_t outstanding) {
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            d.peek([&](SLE& vault, SLE& shares) -> bool {
+                vault[sfAssetsTotal] = total;
+                vault[sfAssetsAvailable] = available;
+                shares[sfOutstandingAmount] = outstanding;
+                return true;
+            });
+        };
+
+        auto expectVault = [this](
+                               Env& env,
+                               Data const& d,
+                               Number const& total,
+                               Number const& available,
+                               STAmount const& shareBalance) {
+            auto const sle = env.le(d.keylet);
+            BEAST_EXPECT(sle != nullptr);
+            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
+            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == shareBalance);
+        };
+
+        // T-6 is exact after the decade; recover 6.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback uses posterior scale across decade boundary");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{6};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - available, Number(0), d.share(94));
+        });
+
+        // T stays on the 10-asset grid; 6 is unrepresentable.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback rejects amount below posterior scale");
+
+            Number const midGridTotal{12345678901234567ll};
+            Number const available{6};
+            seedLargeTotal(env, d, midGridTotal, available, 12345678901234567ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tecPRECISION_LOSS));
+            expectVault(env, d, midGridTotal, available, d.share(100));
+        });
+
+        // A recovery larger than the anterior ULP also lands exactly on the finer posterior grid.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback preserves exact posterior amount");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{15};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - available, Number(0), d.share(85));
+        });
+
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale deposit rejects amount below posterior scale");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{100};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto const assetsBefore = env.balance(d.depositor, d.assets);
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(6))});
+            env(tx, Ter(tecPRECISION_LOSS));
+            expectVault(env, d, midGridTotal, available, d.share(100));
+            BEAST_EXPECT(env.balance(d.depositor, d.assets) == assetsBefore);
+        });
+
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale withdraw uses posterior scale across decade boundary");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{100};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto const assetsBefore = env.balance(d.depositor, d.assets);
+            auto tx = d.vault.withdraw(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.share, Number(15))});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - Number(15), Number(85), d.share(85));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) ==
+                STAmount(d.asset, assetsBefore.number() + Number(15)));
+        });
+    }
+
+    void
+    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..92d5dd04d4
--- /dev/null
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -0,0 +1,685 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#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;
+            using namespace std::chrono_literals;
+            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();
+
+            // Impairment requires the payment to be late, so advance past
+            // the due date before impairing.
+            auto const loanSle = env.le(*f.loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return f;
+            std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
+            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        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)");
+
+        // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an
+        // open-ended vault and this test asserts amendment-independent
+        // withdrawal invariants (see the note on run()).
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            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)");
+
+        // Strip featureLendingProtocolV1_1 as above.
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
+        {
+            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 each time; the loan transactor caps the receivable). The
+        // loan is still overdue from the impairment setup, so the first
+        // (and only remaining, since kStuckPayTotal == 2) outstanding
+        // installment must be caught up with a late payment before the
+        // final regular payment can close the loan out.
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
+            Ter(tesSUCCESS));
+        env.close();
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
+        env.close();
+
+        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
+    {
+        // These sole-shareholder exit scenarios build an open-ended vault
+        // and drive it through deposits, a loan broker, an impaired loan
+        // and finally a withdrawal by the last shareholder. Under
+        // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults, so this suite runs with
+        // the amendment stripped; the invariants asserted here are
+        // amendment-independent.
+        auto const legacy = all_ - featureLendingProtocolV1_1;
+        testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(legacy);
+        testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(legacy);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy);
+        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
+        testWithdrawSoleShareholderLoanRepaymentExit();
+    }
+};
+
+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/VaultTransactorPrecision_test.cpp b/src/test/app/vault/VaultTransactorPrecision_test.cpp
new file mode 100644
index 0000000000..8e8ee2d629
--- /dev/null
+++ b/src/test/app/vault/VaultTransactorPrecision_test.cpp
@@ -0,0 +1,362 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+// With fixCleanup3_4_0, deposit/withdraw/clawback apply one amount on the
+// sfAssetsTotal grid. These tests require T, A, and the pseudo-account to
+// change by the same Number; the invariant suite still allows a one-unit gap.
+class VaultTransactorPrecision_test : public VaultPrecisionFixture
+{
+    jtx::Env
+    makeEnv()
+    {
+        return jtx::Env{*this, jtx::envconfig(), all_, nullptr, beast::Severity::Disabled};
+    }
+
+    bool
+    ready(Fixture const& f)
+    {
+        return BEAST_EXPECT(f.asset && f.broker) && f.asset;
+    }
+
+    void
+    assertEqualDeltas(Numbers const& before, Numbers const& after, std::string const& tag)
+    {
+        Number const tDelta = before.assetsTotal - after.assetsTotal;
+        Number const aDelta = before.assetsAvailable - after.assetsAvailable;
+        Number const pDelta = before.pseudo - after.pseudo;
+        BEAST_EXPECTS(tDelta == aDelta, tag + " tDelta != aDelta");
+        BEAST_EXPECTS(tDelta == pDelta, tag + " tDelta != pDelta");
+    }
+
+    void
+    testDeposit()
+    {
+        using namespace jtx;
+
+        testcase("deposit clamp does not over-credit");
+
+        std::array const kAmounts{1, 7, 1'000, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env = makeEnv();
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!ready(f))
+                continue;
+            // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+            jtx::PrettyAsset const& asset = f.asset.value();
+
+            auto const before = read(env, f);
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            if (env.ter() != tesSUCCESS)
+                continue;
+
+            auto const after = read(env, f);
+            Number const tDelta = after.assetsTotal - before.assetsTotal;
+            Number const requested = asset(amount).number();
+            BEAST_EXPECTS(
+                tDelta <= requested,
+                "amount=" + std::to_string(amount) + " tDelta exceeds requested");
+
+            Number const sharesMinted = after.sharesTotal - before.sharesTotal;
+            if (before.sharesTotal == Number{0})
+                continue;
+            Number const shareValue = (before.assetsTotal * sharesMinted) / before.sharesTotal;
+            // The depositor is never charged more than the shares they received are worth.
+            BEAST_EXPECTS(
+                tDelta <= shareValue,
+                "amount=" + std::to_string(amount) + " assetsTaken > shareValue");
+            // Discount is strictly less than one ULP of the new AssetsTotal
+            BEAST_EXPECTS(
+                shareValue - tDelta < oneUnit(asset.raw(), after.assetsTotal),
+                "amount=" + std::to_string(amount) + " discount is not below one unit");
+        }
+
+        {
+            Env env = makeEnv();
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!ready(f))
+                return;
+            // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+            jtx::PrettyAsset const& asset = f.asset.value();
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(99'000'000).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            auto const before = read(env, f);
+            Number const kLowerBound{1, 6};
+            BEAST_EXPECT(before.assetsTotal > kLowerBound);
+
+            auto const tinyAmount = asset(Number{1, -10}).value();
+            env(v.deposit(
+                    {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = tinyAmount}),
+                Ter(std::ignore));
+            env.close();
+
+            BEAST_EXPECTS(
+                env.ter() == tecPRECISION_LOSS,
+                std::string{"expected tecPRECISION_LOSS, got "} + transToken(env.ter()));
+
+            auto const after = read(env, f);
+            BEAST_EXPECT(after.assetsTotal == before.assetsTotal);
+            BEAST_EXPECT(after.assetsAvailable == before.assetsAvailable);
+            BEAST_EXPECT(after.sharesTotal == before.sharesTotal);
+        }
+    }
+
+    void
+    testWithdraw()
+    {
+        using namespace jtx;
+
+        testcase("withdraw deltas are equal");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkSuccess = [&](STAmount const& amount, std::string const& tag) {
+            auto const before = read(env, f);
+            env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = amount}),
+                Ter(std::ignore));
+            env.close();
+            if (env.ter() != tesSUCCESS)
+                return;
+
+            auto const after = read(env, f);
+            assertEqualDeltas(before, after, tag);
+
+            Number const sharesBurned = before.sharesTotal - after.sharesTotal;
+            if (before.sharesTotal == Number{0})
+                return;
+            Number const shareValue = (before.assetsTotal * sharesBurned) / before.sharesTotal;
+            Number const tDelta = before.assetsTotal - after.assetsTotal;
+            BEAST_EXPECTS(tDelta <= shareValue, tag + " payout > shareValue");
+        };
+
+        std::array const kShareCounts{99'999u, 333'333u, 1'234'567u};
+        for (auto const count : kShareCounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal < count)
+                continue;
+            STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}};
+            checkSuccess(shareAmount, "shares=" + std::to_string(count));
+        }
+
+        std::array const kAssetAmounts{1, 7, 99};
+        for (auto const amount : kAssetAmounts)
+            checkSuccess(asset(amount).value(), "assets=" + std::to_string(amount));
+    }
+
+    // Withdraw more than sfAssetsAvailable must return tecINSUFFICIENT_FUNDS,
+    // not tecPRECISION_LOSS.
+    void
+    testWithdrawInsufficientFundsPrecedence()
+    {
+        using namespace jtx;
+
+        testcase("withdraw over available returns insufficient funds, not precision loss");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto const before = read(env, f);
+        if (!BEAST_EXPECT(before.assetsAvailable > Number{0}))
+            return;
+
+        STAmount const request = asset(before.assetsAvailable + Number{1}).value();
+        env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = request}),
+            Ter(std::ignore));
+        env.close();
+
+        BEAST_EXPECTS(
+            env.ter() == tecINSUFFICIENT_FUNDS,
+            std::string{"expected tecINSUFFICIENT_FUNDS, got "} + transToken(env.ter()));
+    }
+
+    void
+    testClawback()
+    {
+        using namespace jtx;
+
+        testcase("clawback deltas are equal");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(
+            env,
+            /*impairAndPaySibling=*/false,
+            /*allowClawback=*/true);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(2'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkSuccess = [&](std::optional const& amount, std::string const& tag) {
+            auto const before = read(env, f);
+            if (before.sharesTotal == Number{0})
+                return;
+
+            env(v.clawback(
+                    {.issuer = f.issuer,
+                     .id = f.vaultKeylet.key,
+                     .holder = f.depositor,
+                     .amount = amount}),
+                Ter(std::ignore));
+            env.close();
+            if (env.ter() != tesSUCCESS)
+                return;
+
+            assertEqualDeltas(before, read(env, f), tag);
+        };
+
+        std::array const kAmounts{1, 7, 99};
+        for (auto const amount : kAmounts)
+            checkSuccess(asset(amount).value(), "amount=" + std::to_string(amount));
+
+        checkSuccess(std::nullopt, "sfAmount absent");
+    }
+
+    void
+    testImpairedVault()
+    {
+        using namespace jtx;
+
+        testcase("impaired vault loss stays within assetsTotal - assetsAvailable");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(5'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkInvariant = [&](std::string const& tag) {
+            TER const actual = env.ter();
+            BEAST_EXPECTS(actual != tecINVARIANT_FAILED, tag + " unexpected invariant failure");
+            if (actual != tesSUCCESS)
+                return;
+            auto const after = read(env, f);
+            BEAST_EXPECTS(
+                after.lossUnrealized <= after.assetsTotal - after.assetsAvailable,
+                tag + " lossUnrealized exceeds assetsTotal - assetsAvailable");
+        };
+
+        std::array const kAmounts{1, 7, 51, 137};
+        for (std::size_t i = 0; i + 1 < kAmounts.size(); i += 2)
+        {
+            int const depositAmount = kAmounts[i];
+            int const withdrawAmount = kAmounts[i + 1];
+
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(depositAmount).value()}),
+                Ter(std::ignore));
+            env.close();
+            checkInvariant("deposit=" + std::to_string(depositAmount));
+
+            env(v.withdraw(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(withdrawAmount).value()}),
+                Ter(std::ignore));
+            env.close();
+            checkInvariant("withdraw=" + std::to_string(withdrawAmount));
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testDeposit();
+        testWithdraw();
+        testWithdrawInsufficientFundsPrecedence();
+        testClawback();
+        testImpairedVault();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultTransactorPrecision, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp
new file mode 100644
index 0000000000..45f6d1deaf
--- /dev/null
+++ b/src/test/app/vault/VaultValidation_test.cpp
@@ -0,0 +1,1198 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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();
+        }
+    }
+
+    // A pseudo-account belongs to a ledger object, so it must never be the
+    // destination of a withdrawal. The payout is refused either way, by the
+    // deposit authorization every pseudo-account carries, so the only change
+    // is a misleading tecNO_PERMISSION becoming tecPSEUDO_ACCOUNT. The check
+    // runs ahead of the private-vault domain check, which would otherwise
+    // report a domain problem against an account that can never join one.
+    void
+    testVaultWithdrawPseudoAccountDestination(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_4_0];
+        testcase(
+            std::string{"VaultWithdraw pseudo-account destination"} +
+            (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+
+        Env env{*this, features};
+        Vault const vault{env};
+
+        env.fund(XRP(100'000), issuer, owner, depositor, pdOwner, credIssuer);
+        // Rippling plays no part in what is being tested here, and would
+        // otherwise stop the payout before it reaches the check under test.
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        for (auto const& account : {owner, depositor})
+        {
+            env.trust(asset(1'000'000), account);
+            env(pay(issuer, account, asset(10'000)));
+        }
+        env.close();
+
+        // Another vault over the same asset supplies the destination. Its
+        // pseudo-account holds a trust line for the asset from creation, so
+        // the payout is refused for being a pseudo-account and nothing else.
+        auto const pseudoDestination = [&]() {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            return Account("otherVault", env.le(keylet)->at(sfAccount));
+        }();
+
+        TER const expected = withFix ? TER(tecPSEUDO_ACCOUNT) : TER(tecNO_PERMISSION);
+
+        auto const withdrawToPseudo = [&](uint256 const& vaultId) {
+            auto tx = vault.withdraw({.depositor = depositor, .id = vaultId, .amount = asset(1)});
+            tx[sfDestination] = pseudoDestination.human();
+            return tx;
+        };
+
+        {
+            auto [createTx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+            env.close();
+
+            env(withdrawToPseudo(keylet.key), Ter(expected));
+            env.close();
+
+            // Withdrawing to self out of the same vault stays unaffected.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+        }
+
+        {
+            auto const domainId = [&]() {
+                pdomain::Credentials const credentials{
+                    {.issuer = credIssuer, .credType = credType}};
+                env(pdomain::setTx(pdOwner, credentials));
+                env.close();
+                return pdomain::getNewDomain(env.meta());
+            }();
+
+            env(credentials::create(depositor, credIssuer, credType));
+            env(credentials::accept(depositor, credIssuer, credType));
+            env.close();
+
+            auto [createTx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+            env(createTx);
+            env.close();
+
+            auto setTx = vault.set({.owner = owner, .id = keylet.key});
+            setTx[sfDomainID] = to_string(domainId);
+            env(setTx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+            env.close();
+
+            // The domain check never gets a say: the destination is rejected
+            // for what it is, not for the domain it is missing.
+            env(withdrawToPseudo(keylet.key), Ter(expected));
+            env.close();
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testPreflight();
+        testCreateFailXRP();
+        testCreateFailIOU();
+        testCreateFailMPT();
+        testVaultDeleteMemoData();
+        testVaultCreateLEVersion();
+
+        testVaultWithdrawPseudoAccountDestination(all_ - fixCleanup3_4_0);
+        testVaultWithdrawPseudoAccountDestination(all_);
+    }
+};
+
+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/envconfig.h b/src/test/jtx/envconfig.h
index 1f920fca58..5ad24e25c4 100644
--- a/src/test/jtx/envconfig.h
+++ b/src/test/jtx/envconfig.h
@@ -3,6 +3,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -62,6 +63,19 @@ envconfig(F&& modfunc, Args&&... args)
     return modfunc(envconfig(), std::forward(args)...);
 }
 
+/**
+ * @brief adjust config to enable online_delete
+ *
+ * @param cfg config instance to be modified
+ *
+ * @param deleteInterval how many new ledgers should be available before
+ * rotating. Defaults to 8, because the standalone minimum is 8.
+ *
+ * @return unique_ptr to Config instance
+ */
+std::unique_ptr
+onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval = 8);
+
 /**
  * @brief adjust config so no admin ports are enabled
  *
diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp
index d65e6f89c6..9eab91459a 100644
--- a/src/test/jtx/impl/envconfig.cpp
+++ b/src/test/jtx/impl/envconfig.cpp
@@ -7,8 +7,10 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -65,6 +67,15 @@ setupConfigForUnitTests(Config& cfg)
 
 namespace jtx {
 
+std::unique_ptr
+onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval)
+{
+    cfg->ledgerHistory = deleteInterval;
+    auto& section = cfg->section(Sections::kNodeDatabase);
+    section.set(Keys::kOnlineDelete, std::to_string(deleteInterval));
+    return cfg;
+}
+
 std::unique_ptr
 noAdmin(std::unique_ptr cfg)
 {
diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp
index c6cd49fa26..0c1ff14eab 100644
--- a/src/test/jtx/impl/mpt.cpp
+++ b/src/test/jtx/impl/mpt.cpp
@@ -17,7 +17,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -648,6 +648,38 @@ MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const
     });
 }
 
+[[nodiscard]] bool
+MPTTester::checkKeyEpochs(
+    std::optional issuerKeyEpoch,
+    std::optional auditorKeyEpoch) const
+{
+    return forObject([&](SLEP const& sle) -> bool {
+        return (*sle)[~sfIssuerKeyEpoch] == issuerKeyEpoch &&
+            (*sle)[~sfAuditorKeyEpoch] == auditorKeyEpoch;
+    });
+}
+
+[[nodiscard]] bool
+MPTTester::checkEncryptionKeys(
+    std::optional const& issuerKeyOwner,
+    std::optional const& auditorKeyOwner) const
+{
+    auto const matches =
+        [this](SLEP const& sle, SF_VL const& field, std::optional const& owner) {
+            if (!owner)
+                return !sle->isFieldPresent(field);
+
+            auto const expected = getPubKey(*owner);
+            return expected && sle->isFieldPresent(field) &&
+                strHex((*sle)[field]) == strHex(*expected);
+        };
+
+    return forObject([&](SLEP const& sle) -> bool {
+        return matches(sle, sfIssuerEncryptionKey, issuerKeyOwner) &&
+            matches(sle, sfAuditorEncryptionKey, auditorKeyOwner);
+    });
+}
+
 void
 MPTTester::pay(
     Account const& src,
diff --git a/src/test/jtx/impl/multisign.cpp b/src/test/jtx/impl/multisign.cpp
index d948042bda..e04e1bb58a 100644
--- a/src/test/jtx/impl/multisign.cpp
+++ b/src/test/jtx/impl/multisign.cpp
@@ -60,10 +60,12 @@ signers(Account const& account, NoneT)
 //------------------------------------------------------------------------------
 
 void
-Msig::operator()(Env& env, JTx& jt) const
+Msig::operator()(Env&, JTx& jt) const
 {
     auto const mySigners = signers;
-    auto callback = [subField = subField, mySigners, &env](Env&, JTx& jtx) {
+    auto callback = [subField = subField, mySigners](Env& env, JTx& jtx) {
+        auto const prefix =
+            signingPrefix(jtx::signatureRole(subField), true, env.current()->rules());
         // Where to put the signature. Supports sfCounterPartySignature and
         // sfSponsorSignature.
         auto& sigObject = subField ? jtx[*subField] : jtx.jv;
@@ -95,7 +97,7 @@ Msig::operator()(Env& env, JTx& jt) const
             jo[jss::Account] = e.acct.human();
             jo[jss::SigningPubKey] = strHex(e.sig.pk().slice());
 
-            Serializer const ss{buildMultiSigningData(*st, e.acct.id())};
+            Serializer const ss{buildMultiSigningData(*st, e.acct.id(), prefix)};
             auto const sig = xrpl::sign(*publicKeyType(e.sig.pk().slice()), e.sig.sk(), ss.slice());
             jo[sfTxnSignature.getJsonName()] = strHex(Slice{sig.data(), sig.size()});
         }
diff --git a/src/test/jtx/impl/sig.cpp b/src/test/jtx/impl/sig.cpp
index e0123073b1..41833c8802 100644
--- a/src/test/jtx/impl/sig.cpp
+++ b/src/test/jtx/impl/sig.cpp
@@ -4,6 +4,8 @@
 #include 
 #include 
 
+#include 
+
 namespace xrpl::test::jtx {
 
 void
@@ -17,11 +19,15 @@ Sig::operator()(Env&, JTx& jt) const
     {
         // VFALCO Inefficient pre-C++14
         auto const account = *account_;
-        auto callback = [subField = subField_, account](Env&, JTx& jtx) {
+        auto callback = [subField = subField_, account](Env& env, JTx& jtx) {
             // Where to put the signature. Supports sfCounterPartySignature and sfSponsorSignature.
             auto& sigObject = subField ? jtx[*subField] : jtx.jv;
 
-            jtx::sign(jtx.jv, account, sigObject);
+            jtx::sign(
+                jtx.jv,
+                account,
+                sigObject,
+                signingPrefix(jtx::signatureRole(subField), false, env.current()->rules()));
         };
         if (subField_ == nullptr)
         {
diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp
index c298cee684..f83cb7772c 100644
--- a/src/test/jtx/impl/utility.cpp
+++ b/src/test/jtx/impl/utility.cpp
@@ -19,9 +19,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 
@@ -36,12 +38,22 @@ parse(json::Value const& jv)
     return std::move(*p.object);
 }
 
+SignatureRole
+signatureRole(SField const* subField)
+{
+    if (subField == nullptr)
+        return SignatureRole::Transaction;
+    if (auto const role = xrpl::signatureRole(*subField))
+        return *role;
+    Throw(subField->getName() + " does not hold a transaction signature.");
+}
+
 void
-sign(json::Value& jv, Account const& account, json::Value& sigObject)
+sign(json::Value& jv, Account const& account, json::Value& sigObject, HashPrefix prefix)
 {
     sigObject[jss::SigningPubKey] = strHex(account.pk().slice());
     Serializer ss;
-    ss.add32(HashPrefix::TxSign);
+    ss.add32(prefix);
     parse(jv).addWithoutSigningFields(ss);
     auto const sig = xrpl::sign(account.pk(), account.sk(), ss.slice());
     sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()});
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index baff576243..5bf8ac9981 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -3,17 +3,22 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -28,9 +33,38 @@ 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;
+    if (args.leVersion)
+        jv[sfLEVersion] = std::to_underlying(*args.leVersion);
     return {jv, keylet};
 }
 
+std::tuple
+Vault::createClosedEnded(CreateClosedEndedArgs const& args) const
+{
+    auto const sub = env.now() + args.subscriptionOffset;
+    auto const red = sub + args.investmentWindow;
+    auto [jv, keylet] = create(
+        {.owner = args.owner,
+         .asset = args.asset,
+         .flags = args.flags,
+         .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+         .subscriptionDate = static_cast(sub.time_since_epoch().count()),
+         .redemptionDate = static_cast(red.time_since_epoch().count())});
+    return {jv, keylet, sub};
+}
+
+void
+Vault::closePastSubscription(NetClock::time_point subscriptionDate) const
+{
+    env.close(subscriptionDate + std::chrono::seconds{1});
+}
+
 json::Value
 Vault::set(SetArgs const& args)
 {
diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h
index 35ab7264bd..26329ad78c 100644
--- a/src/test/jtx/mpt.h
+++ b/src/test/jtx/mpt.h
@@ -612,6 +612,21 @@ public:
     [[nodiscard]] bool
     checkImmutableFlags(std::uint32_t expectedFlags) const;
 
+    // Checks both key epochs on the issuance. Pass std::nullopt for an epoch
+    // that is expected to be absent, which means the key is never rotated.
+    [[nodiscard]] bool
+    checkKeyEpochs(
+        std::optional issuerKeyEpoch,
+        std::optional auditorKeyEpoch) const;
+
+    // Checks that the issuance carries the encryption keys of the given
+    // accounts. Pass std::nullopt for a key that is expected to be absent,
+    // which means the key is never registered.
+    [[nodiscard]] bool
+    checkEncryptionKeys(
+        std::optional const& issuerKeyOwner,
+        std::optional const& auditorKeyOwner) const;
+
     [[nodiscard]] Account const&
     issuer() const
     {
diff --git a/src/test/jtx/utility.h b/src/test/jtx/utility.h
index 289afec8b3..5a37abd920 100644
--- a/src/test/jtx/utility.h
+++ b/src/test/jtx/utility.h
@@ -5,7 +5,10 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
+#include 
 
 #include 
 #include 
@@ -33,12 +36,29 @@ struct ParseError : std::logic_error
 STObject
 parse(json::Value const& jv);
 
+/**
+ * The role that signs into an optional signature subfield.
+ *
+ * @param subField The signature field, or nullptr for the transaction's own
+ * signature. Throws if the field does not hold a transaction signature.
+ */
+SignatureRole
+signatureRole(SField const* subField);
+
 /**
  * Sign automatically into a specific Json field of the jv object.
+ *
+ * @param prefix Prefix to insert before the serialized transaction when
+ * hashing. Use signingPrefix to get the prefix that matches the field that
+ * holds sigObject.
  * @note This only works on accounts with multi-signing off.
  */
 void
-sign(json::Value& jv, Account const& account, json::Value& sigObject);
+sign(
+    json::Value& jv,
+    Account const& account,
+    json::Value& sigObject,
+    HashPrefix prefix = HashPrefix::TxSign);
 
 /**
  * Sign automatically.
diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h
index e72eae89b7..000e8a20ea 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -3,10 +3,13 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 
+#include 
 #include 
 #include 
 #include 
@@ -25,6 +28,14 @@ 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)
+        std::optional leVersion =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
     };
 
     /**
@@ -33,6 +44,38 @@ struct Vault
     [[nodiscard]] std::tuple
     create(CreateArgs const& args) const;
 
+    struct CreateClosedEndedArgs
+    {
+        Account owner;
+        Asset asset;
+        std::optional flags =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        NetClock::duration subscriptionOffset = std::chrono::seconds{10};
+        NetClock::duration investmentWindow = std::chrono::seconds{1'000'000};
+    };
+
+    /**
+     * Return a VaultCreate transaction for a closed-ended vault, its
+     * expected keylet, and the vault's SubscriptionDate.
+     *
+     * Under featureLendingProtocolV1_1, LoanBrokerSet::preclaim only
+     * accepts closed-ended vaults, so tests that attach a loan broker
+     * need one. SubscriptionDate is set to now() + subscriptionOffset,
+     * giving callers a window to deposit while still in the Subscription
+     * phase; pass the returned date to closePastSubscription() afterwards
+     * to advance into the Investment phase.
+     */
+    [[nodiscard]] std::tuple
+    createClosedEnded(CreateClosedEndedArgs const& args) const;
+
+    /**
+     * Advance env's clock to just past subscriptionDate, moving a
+     * closed-ended vault from the Subscription phase into the Investment
+     * phase.
+     */
+    void
+    closePastSubscription(NetClock::time_point subscriptionDate) const;
+
     struct SetArgs
     {
         Account owner;
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/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/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp
index 74792e0a70..1e5027df49 100644
--- a/src/test/protocol/STNumber_test.cpp
+++ b/src/test/protocol/STNumber_test.cpp
@@ -12,6 +12,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -176,61 +177,32 @@ struct STNumber_test : public beast::unit_test::Suite
                 numberFromJson(sfNumber, std::to_string(kUMax)) ==
                 STNumber(sfNumber, Number(kUMax, 0)));
 
+            auto const expectJsonThrows = [this](
+                                              json::Value const& num, std::string const& expected) {
+                try
+                {
+                    numberFromJson(sfNumber, num);
+                    fail();
+                }
+                catch (std::exception const& e)
+                {
+                    std::ostringstream out;
+                    out << "Json: " << num.asString() << " got exception: " << e.what()
+                        << ", expected: " << expected;
+                    BEAST_EXPECTS(std::string(e.what()) == expected, out.str());
+                }
+            };
+
+            // Obvious overflows tested here
+            expectJsonThrows("1e2000000", "Number::normalize 2");
+            expectJsonThrows("1e2000000000", "Number::normalize 2");
+
             // Obvious non-numbers tested here
-            try
-            {
-                auto _ = numberFromJson(sfNumber, "");
-                BEAST_EXPECT(false);
-            }
-            catch (std::runtime_error const& e)
-            {
-                std::string const expected = "'' is not a number";
-                BEAST_EXPECT(e.what() == expected);
-            }
-
-            try
-            {
-                auto _ = numberFromJson(sfNumber, "e");
-                BEAST_EXPECT(false);
-            }
-            catch (std::runtime_error const& e)
-            {
-                std::string const expected = "'e' is not a number";
-                BEAST_EXPECT(e.what() == expected);
-            }
-
-            try
-            {
-                auto _ = numberFromJson(sfNumber, "1e");
-                BEAST_EXPECT(false);
-            }
-            catch (std::runtime_error const& e)
-            {
-                std::string const expected = "'1e' is not a number";
-                BEAST_EXPECT(e.what() == expected);
-            }
-
-            try
-            {
-                auto _ = numberFromJson(sfNumber, "e2");
-                BEAST_EXPECT(false);
-            }
-            catch (std::runtime_error const& e)
-            {
-                std::string const expected = "'e2' is not a number";
-                BEAST_EXPECT(e.what() == expected);
-            }
-
-            try
-            {
-                auto _ = numberFromJson(sfNumber, json::Value());
-                BEAST_EXPECT(false);
-            }
-            catch (std::runtime_error const& e)
-            {
-                std::string const expected = "not a number";
-                BEAST_EXPECT(e.what() == expected);
-            }
+            expectJsonThrows("", "'' is not a number");
+            expectJsonThrows("e", "'e' is not a number");
+            expectJsonThrows("1e", "'1e' is not a number");
+            expectJsonThrows("e2", "'e2' is not a number");
+            expectJsonThrows(json::Value(), "not a number");
 
             try
             {
diff --git a/src/test/protocol/STTx_test.cpp b/src/test/protocol/STTx_test.cpp
index 777234be83..f310274e56 100644
--- a/src/test/protocol/STTx_test.cpp
+++ b/src/test/protocol/STTx_test.cpp
@@ -1,9 +1,13 @@
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -11,10 +15,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,6 +30,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -55,6 +62,279 @@ public:
         testSTTx(KeyType::Ed25519);
         testObjectCtorErrors();
         testBatchInnerCtorErrors();
+        testSigningPrefixes();
+        testRoleSignatureBinding();
+        testRoleMultiSignatureBinding();
+    }
+
+    // Rules with no amendments enabled, and rules with only fixCleanup3_4_0
+    // enabled. Rules keep a reference to the presets, so the presets must
+    // outlive the Rules; both are returned together.
+    struct RulesFixture
+    {
+        std::unordered_set> const noPresets;
+        std::unordered_set> const fixPresets{fixCleanup3_4_0};
+        Rules const legacy{noPresets};
+        Rules const fixed{fixPresets};
+    };
+
+    // A transaction with fixed contents, so its signing data is stable from
+    // run to run.
+    static STTx
+    makeFixedTx()
+    {
+        auto const keypair = generateKeyPair(KeyType::Secp256k1, generateSeed("masterpassphrase"));
+        return STTx(ttACCOUNT_SET, [&keypair](auto& obj) {
+            obj.setAccountID(sfAccount, calcAccountID(keypair.first));
+            obj.setFieldAmount(sfFee, STAmount(10ull));
+            obj.setFieldU32(sfSequence, 1);
+            obj.setFieldVL(sfSigningPubKey, keypair.first.slice());
+        });
+    }
+
+    void
+    testSigningPrefixes()
+    {
+        testcase("signing prefixes");
+
+        // The prefixes are protocol constants. Spell them out so that a typo
+        // in a prefix character fails here, and not in a downstream library.
+        static_assert(safeCast(HashPrefix::TxSign) == 0x53545800);
+        static_assert(safeCast(HashPrefix::TxMultiSign) == 0x534D5400);
+        static_assert(safeCast(HashPrefix::CounterpartyTxSign) == 0x43505400);
+        static_assert(safeCast(HashPrefix::CounterpartyTxMultiSign) == 0x43504D00);
+        static_assert(safeCast(HashPrefix::SponsorTxSign) == 0x53504E00);
+        static_assert(safeCast(HashPrefix::SponsorTxMultiSign) == 0x53504D00);
+
+        RulesFixture const r;
+
+        // Every role gets its own prefix once the fix is enabled.
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Transaction, false, r.fixed) == HashPrefix::TxSign);
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Transaction, true, r.fixed) == HashPrefix::TxMultiSign);
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Counterparty, false, r.fixed) ==
+            HashPrefix::CounterpartyTxSign);
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Counterparty, true, r.fixed) ==
+            HashPrefix::CounterpartyTxMultiSign);
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Sponsor, false, r.fixed) == HashPrefix::SponsorTxSign);
+        BEAST_EXPECT(
+            signingPrefix(SignatureRole::Sponsor, true, r.fixed) == HashPrefix::SponsorTxMultiSign);
+
+        // Before the fix, every role signs the same bytes.
+        for (auto const role :
+             {SignatureRole::Transaction, SignatureRole::Counterparty, SignatureRole::Sponsor})
+        {
+            BEAST_EXPECT(signingPrefix(role, false, r.legacy) == HashPrefix::TxSign);
+            BEAST_EXPECT(signingPrefix(role, true, r.legacy) == HashPrefix::TxMultiSign);
+        }
+
+        // Each role's field, and each signature field's role, agree.
+        BEAST_EXPECT(signatureField(SignatureRole::Transaction) == nullptr);
+        BEAST_EXPECT(*signatureField(SignatureRole::Counterparty) == sfCounterpartySignature);
+        BEAST_EXPECT(*signatureField(SignatureRole::Sponsor) == sfSponsorSignature);
+        BEAST_EXPECT(signatureRole(sfCounterpartySignature) == SignatureRole::Counterparty);
+        BEAST_EXPECT(signatureRole(sfSponsorSignature) == SignatureRole::Sponsor);
+        BEAST_EXPECT(!signatureRole(sfBook));
+        BEAST_EXPECT(!signatureRole(sfSigner));
+
+        // The bytes signed by an ordinary transaction must not move. Both the
+        // single- and the multi-signing data are pinned, and neither depends
+        // on the amendment.
+        auto const tx = makeFixedTx();
+        for (Rules const& rules : {r.legacy, r.fixed})
+        {
+            Serializer single;
+            single.add32(signingPrefix(SignatureRole::Transaction, false, rules));
+            tx.addWithoutSigningFields(single);
+            // 53545800 STX prefix, 120003 AccountSet, 2400000001 Sequence,
+            // 68400000000000000A Fee, 7321... SigningPubKey, 8114... Account.
+            BEAST_EXPECT(
+                strHex(single.peekData()) ==
+                "53545800"
+                "120003"
+                "2400000001"
+                "68400000000000000A"
+                "73210330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020"
+                "8114B5F762798A53D543A014CAF8B297CFF8F2F937E8");
+            BEAST_EXPECT(
+                to_string(single.getSHA512Half()) ==
+                "AB7473EA8D05527A7465229447B0E9B05365C72B87E921762AD30742B253F3E6");
+
+            auto const signer = calcAccountID(
+                generateKeyPair(KeyType::Secp256k1, generateSeed("multisigner")).first);
+            Serializer const multi = buildMultiSigningData(
+                tx, signer, signingPrefix(SignatureRole::Transaction, true, rules));
+
+            // The multi-signing data is the single-signing data with a
+            // different prefix and the signer's account appended.
+            Serializer expected;
+            expected.add32(HashPrefix::TxMultiSign);
+            tx.addWithoutSigningFields(expected);
+            expected.addBitString(signer);
+            BEAST_EXPECT(strHex(multi.peekData()) == strHex(expected.peekData()));
+        }
+    }
+
+    void
+    testRoleSignatureBinding()
+    {
+        testcase("role signature binding");
+
+        RulesFixture const r;
+
+        auto const keypair = generateKeyPair(KeyType::Secp256k1, generateSeed("masterpassphrase"));
+        auto const account = calcAccountID(keypair.first);
+
+        // A transaction signed by its own account, with that signature copied
+        // into an alternate signature field. sfSponsorSignature is a common
+        // field; sfCounterpartySignature is only on a LoanSet.
+        auto makeCopiedSig = [&keypair, &account](SField const& sigField) {
+            bool const counterparty = sigField == sfCounterpartySignature;
+            STTx tx(counterparty ? ttLOAN_SET : ttACCOUNT_SET, [&](auto& obj) {
+                obj.setAccountID(sfAccount, account);
+                obj.setFieldAmount(sfFee, STAmount(10ull));
+                obj.setFieldU32(sfSequence, 1);
+                obj.setFieldVL(sfSigningPubKey, keypair.first.slice());
+                if (counterparty)
+                {
+                    obj.setFieldH256(sfLoanBrokerID, uint256{1});
+                    obj.setFieldNumber(
+                        sfPrincipalRequested, STNumber{sfPrincipalRequested, Number{1}});
+                }
+                else
+                {
+                    obj.setAccountID(sfSponsor, account);
+                    obj.setFieldU32(sfSponsorFlags, 0);
+                }
+            });
+            tx.sign(keypair.first, keypair.second);
+
+            STObject sigObject(sigField);
+            sigObject.setFieldVL(sfSigningPubKey, keypair.first.slice());
+            sigObject.setFieldVL(sfTxnSignature, tx.getSignature());
+
+            // NOLINTNEXTLINE(cppcoreguidelines-slicing)
+            STObject copy{tx};
+            copy.setFieldObject(sigField, sigObject);
+            return STTx{std::move(copy)};
+        };
+
+        for (SField const& sigField :
+             {std::cref(sfCounterpartySignature), std::cref(sfSponsorSignature)})
+        {
+            auto const tx = makeCopiedSig(sigField);
+
+            // Before the fix, the copied signature verifies in the other role.
+            BEAST_EXPECT(tx.checkSign(r.legacy));
+
+            // With the fix, it does not, and the error names the role that
+            // failed so the two role checks cannot be confused.
+            auto const ret = tx.checkSign(r.fixed);
+            BEAST_EXPECT(!ret);
+            if (!ret)
+            {
+                char const* const rolePrefix =
+                    sigField == sfCounterpartySignature ? "Counterparty: " : "Sponsor: ";
+                BEAST_EXPECT(ret.error().starts_with(rolePrefix));
+                BEAST_EXPECT(matches(ret.error().c_str(), "Invalid signature"));
+            }
+        }
+    }
+
+    // Multi-sign analogue of testRoleSignatureBinding. Both the top-level
+    // Signers array and a role slot's Signers array carry the same signer
+    // entry, signed under HashPrefix::TxMultiSign. Before the fix every role
+    // uses that same prefix, so the copied entry verifies in the role slot;
+    // after the fix the role slot verifies against CounterpartyTxMultiSign
+    // (CPM) or SponsorTxMultiSign (SPM) and the entry no longer matches.
+    void
+    testRoleMultiSignatureBinding()
+    {
+        testcase("role multi-signature binding");
+
+        RulesFixture const r;
+
+        auto const acctKp = generateKeyPair(KeyType::Secp256k1, generateSeed("masterpassphrase"));
+        auto const account = calcAccountID(acctKp.first);
+        // The signer must differ from the top-level account so that the
+        // multiSignHelper "account owner may not multisign for themselves"
+        // check passes for the top-level Signers array.
+        auto const signerKp = generateKeyPair(KeyType::Secp256k1, generateSeed("multisigner"));
+        auto const signerId = calcAccountID(signerKp.first);
+
+        auto makeCopiedMultiSig = [&](SField const& sigField) {
+            bool const counterparty = sigField == sfCounterpartySignature;
+            STTx const tx(counterparty ? ttLOAN_SET : ttACCOUNT_SET, [&](auto& obj) {
+                obj.setAccountID(sfAccount, account);
+                obj.setFieldAmount(sfFee, STAmount(10ull));
+                obj.setFieldU32(sfSequence, 1);
+                // Empty SigningPubKey selects the multi-sign path.
+                obj.setFieldVL(sfSigningPubKey, Slice{});
+                if (counterparty)
+                {
+                    obj.setFieldH256(sfLoanBrokerID, uint256{1});
+                    obj.setFieldNumber(
+                        sfPrincipalRequested, STNumber{sfPrincipalRequested, Number{1}});
+                }
+                else
+                {
+                    obj.setAccountID(sfSponsor, account);
+                    obj.setFieldU32(sfSponsorFlags, 0);
+                }
+            });
+
+            // Sign the top-level tx with TxMultiSign, which is what a
+            // multi-signer of the transaction itself would use.
+            Serializer const ss = buildMultiSigningData(tx, signerId, HashPrefix::TxMultiSign);
+            auto const sig = xrpl::sign(signerKp.first, signerKp.second, ss.slice());
+
+            STObject entry(sfSigner);
+            entry.setAccountID(sfAccount, signerId);
+            entry.setFieldVL(sfSigningPubKey, signerKp.first.slice());
+            entry.setFieldVL(sfTxnSignature, sig);
+            STArray signers(sfSigners, 1);
+            signers.pushBack(entry);
+
+            // Attach the Signers array to the top level so its own signature
+            // check succeeds, then copy the identical array into the role
+            // slot. Both arrays carry TxMultiSign-based signatures.
+            // NOLINTNEXTLINE(cppcoreguidelines-slicing)
+            STObject copy{tx};
+            copy.setFieldArray(sfSigners, signers);
+
+            STObject sigObject(sigField);
+            sigObject.setFieldVL(sfSigningPubKey, Slice{});
+            sigObject.setFieldArray(sfSigners, signers);
+            copy.setFieldObject(sigField, sigObject);
+            return STTx{std::move(copy)};
+        };
+
+        for (SField const& sigField :
+             {std::cref(sfCounterpartySignature), std::cref(sfSponsorSignature)})
+        {
+            auto const tx = makeCopiedMultiSig(sigField);
+
+            // Before the fix, both signature checks use TxMultiSign, so the
+            // copied Signers array verifies in the role slot as well.
+            BEAST_EXPECT(tx.checkSign(r.legacy));
+
+            // With the fix, the role slot uses its own multi-sign prefix
+            // (CPM or SPM) and the copied signature no longer verifies. The
+            // error must name the role that failed.
+            auto const ret = tx.checkSign(r.fixed);
+            BEAST_EXPECT(!ret);
+            if (!ret)
+            {
+                char const* const rolePrefix =
+                    sigField == sfCounterpartySignature ? "Counterparty: " : "Sponsor: ";
+                BEAST_EXPECT(ret.error().starts_with(rolePrefix));
+                BEAST_EXPECT(matches(ret.error().c_str(), "Invalid signature"));
+            }
+        }
     }
 
     void
@@ -1555,7 +1835,7 @@ public:
         auto const id2 = calcAccountID(kp2.first);
 
         // Get the stream of the transaction for use in multi-signing.
-        Serializer const s = buildMultiSigningData(txn, id2);
+        Serializer const s = buildMultiSigningData(txn, id2, HashPrefix::TxMultiSign);
 
         auto const saMultiSignature = sign(kp2.first, kp2.second, s.slice());
 
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/BookChanges_test.cpp b/src/test/rpc/BookChanges_test.cpp
index 98a9372982..f0b4a4e187 100644
--- a/src/test/rpc/BookChanges_test.cpp
+++ b/src/test/rpc/BookChanges_test.cpp
@@ -1,3 +1,4 @@
+#include 
 #include 
 #include 
 #include 
@@ -8,13 +9,33 @@
 #include 
 #include 
 
+#include 
+
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
 #include 
 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
 namespace xrpl::test {
 
 class BookChanges_test : public beast::unit_test::Suite
@@ -115,6 +136,195 @@ public:
         BEAST_EXPECT(jrr[jss::changes][0u][jss::domain].asString() == to_string(domainID));
     }
 
+    void
+    testSkipsOverflowingRate()
+    {
+        testcase("book_changes skips overflowing rate");
+        using namespace jtx;
+
+        Env env(*this);
+        Account const gw{"gw"};
+        Account const iouGw{"iouGw"};
+
+        auto const big = MPT{gw.id(), 1};
+        auto const usd = iouGw["USD"];
+
+        // This metadata represents a partial MPT/IOU offer fill whose deltas
+        // make divide(deltaGets, deltaPays) overflow before MPTokensV2 skips
+        // the unrepresentable book-change rate.
+        STObject finalFields = STObject::makeInnerObject(sfFinalFields);
+        finalFields.setFieldU32(sfSequence, 1);
+        finalFields.setFieldAmount(sfTakerGets, big(1'800'000'000'000'000'000ull));
+        finalFields.setFieldAmount(sfTakerPays, usd(9));
+
+        STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
+        previousFields.setFieldU32(sfSequence, 1);
+        previousFields.setFieldAmount(sfTakerGets, big(3'600'000'000'000'000'000ull));
+        previousFields.setFieldAmount(sfTakerPays, usd(18));
+
+        STObject modifiedOffer{sfModifiedNode};
+        modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
+        modifiedOffer.setFieldObject(sfFinalFields, finalFields);
+        modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
+
+        STArray affectedNodes{sfAffectedNodes};
+        affectedNodes.pushBack(std::move(modifiedOffer));
+
+        auto metadata = std::make_shared(sfTransactionMetaData);
+        metadata->setFieldArray(sfAffectedNodes, affectedNodes);
+
+        auto tx = std::make_shared(ttOFFER_CREATE, [](STObject&) {});
+
+        auto const test = [&](std::unordered_set> const& features) {
+            auto ledger = std::make_shared(
+                2,
+                NetClock::time_point{},
+                Rules{features},
+                env.current()->fees(),
+                env.app().getNodeFamily());
+
+            auto txSerializer = std::make_shared();
+            tx->add(*txSerializer);
+
+            auto metaSerializer = std::make_shared();
+            metadata->add(*metaSerializer);
+
+            ledger->rawTxInsert(uint256{1}, txSerializer, metaSerializer);
+            ledger->setImmutable();
+            ledger->setValidated();
+
+            try
+            {
+                auto const result =
+                    xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger));
+                BEAST_EXPECT(result[jss::type] == "bookChanges");
+                BEAST_EXPECT(result[jss::changes].size() == 0);
+            }
+            catch (std::overflow_error const&)
+            {
+                fail("Overflowing book-change rate shouldn't throw");
+            }
+        };
+
+        test(std::unordered_set>{});
+        test(std::unordered_set>{featureMPTokensV2});
+    }
+
+    // Build a ledger whose transactions are OfferCreates carrying the supplied
+    // consumed-offer deltas, then run computeBookChanges over it. Each pair is
+    // (TakerGets, TakerPays) fully consumed off a resting offer.
+    static json::Value
+    bookChangesFor(jtx::Env& env, std::vector> const& crossings)
+    {
+        auto ledger = std::make_shared(
+            2,
+            NetClock::time_point{},
+            Rules{std::unordered_set>{featureMPTokensV2}},
+            env.current()->fees(),
+            env.app().getNodeFamily());
+
+        std::uint32_t seq = 0;
+        for (auto const& [gets, pays] : crossings)
+        {
+            ++seq;
+
+            STObject finalFields = STObject::makeInnerObject(sfFinalFields);
+            finalFields.setFieldU32(sfSequence, seq);
+            finalFields.setFieldAmount(sfTakerGets, STAmount{gets.asset()});
+            finalFields.setFieldAmount(sfTakerPays, STAmount{pays.asset()});
+
+            STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
+            previousFields.setFieldU32(sfSequence, seq);
+            previousFields.setFieldAmount(sfTakerGets, gets);
+            previousFields.setFieldAmount(sfTakerPays, pays);
+
+            STObject modifiedOffer{sfModifiedNode};
+            modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
+            modifiedOffer.setFieldObject(sfFinalFields, finalFields);
+            modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
+
+            STArray affectedNodes{sfAffectedNodes};
+            affectedNodes.pushBack(std::move(modifiedOffer));
+
+            auto metadata = std::make_shared(sfTransactionMetaData);
+            metadata->setFieldArray(sfAffectedNodes, affectedNodes);
+
+            STTx const tx{ttOFFER_CREATE, [](STObject&) {}};
+
+            auto txSerializer = std::make_shared();
+            tx.add(*txSerializer);
+
+            auto metaSerializer = std::make_shared();
+            metadata->add(*metaSerializer);
+
+            ledger->rawTxInsert(uint256{seq}, txSerializer, metaSerializer);
+        }
+
+        ledger->setImmutable();
+        ledger->setValidated();
+
+        return xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger));
+    }
+
+    void
+    testSkipsOverflowingVolume()
+    {
+        testcase("book_changes skips overflowing volume");
+        using namespace jtx;
+
+        Env env(*this);
+
+        // Two crossings in one book, accumulated by the `+=` in the tally's
+        // else branch. The rate is 1 either way, so the divide() guard is not
+        // what is under test here.
+        //
+        // MPT: kMaxMpTokenAmount is INT64_MAX, so two halves sum past it. The
+        // add is a raw int64 add, which wraps to a negative amount rather than
+        // throwing, and canonicalize() only bounds the magnitude -- so before
+        // the fix this reported a negative volume.
+        {
+            auto const mptA = MPT{Account{"gw"}.id(), 1};
+            auto const mptB = MPT{Account{"gw"}.id(), 2};
+            auto const half = 5'000'000'000'000'000'000ull;  // 2 * half > INT64_MAX
+
+            auto const result =
+                bookChangesFor(env, {{mptA(half), mptB(half)}, {mptA(half), mptB(half)}});
+
+            BEAST_EXPECT(result[jss::type] == "bookChanges");
+            if (BEAST_EXPECT(result[jss::changes].size() == 1))
+            {
+                auto const& change = result[jss::changes][0u];
+                // The second crossing is dropped, so the first one's volume
+                // stands. Above all it must not be negative.
+                BEAST_EXPECT(change[jss::volume_a].asString() == std::to_string(half));
+                BEAST_EXPECT(change[jss::volume_b].asString() == std::to_string(half));
+            }
+        }
+
+        // IOU: the addition throws std::overflow_error once the summed
+        // exponent passes IOUAmount::kMaxExponent. Before the fix that
+        // escaped computeBookChanges entirely.
+        {
+            Account const gwA{"gwA"};
+            Account const gwB{"gwB"};
+            // Mantissa in range, exponent at the maximum: two of these sum to
+            // one exponent past it.
+            STAmount const bigA{gwA["USD"].issue(), UINT64_C(9'000'000'000'000'000), 80};
+            STAmount const bigB{gwB["EUR"].issue(), UINT64_C(9'000'000'000'000'000), 80};
+
+            try
+            {
+                auto const result = bookChangesFor(env, {{bigA, bigB}, {bigA, bigB}});
+                BEAST_EXPECT(result[jss::type] == "bookChanges");
+                BEAST_EXPECT(result[jss::changes].size() == 1);
+            }
+            catch (std::overflow_error const&)
+            {
+                fail("Overflowing book-change volume shouldn't throw");
+            }
+        }
+    }
+
     void
     run() override
     {
@@ -122,6 +332,8 @@ public:
         testLedgerInputDefaultBehavior();
 
         testDomainOffer();
+        testSkipsOverflowingRate();
+        testSkipsOverflowingVolume();
         // Note: Other aspects of the book_changes rpc are fertile grounds
         // for unit-testing purposes. It can be included in future work
     }
diff --git a/src/test/rpc/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/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp
index af93108ff2..e7c5dd4a80 100644
--- a/src/test/rpc/LedgerRPC_test.cpp
+++ b/src/test/rpc/LedgerRPC_test.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -20,6 +21,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -258,6 +260,102 @@ class LedgerRPC_test : public beast::unit_test::Suite
         BEAST_EXPECT(jrr[jss::ledger][jss::accountState].size() == 3u);
     }
 
+    void
+    testLedgerOwnerFundsMPTOffer()
+    {
+        testcase("Ledger owner_funds with MPT offer");
+        using namespace test::jtx;
+
+        Env env{*this};
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        auto const usd = gw["USD"];
+
+        env.fund(XRP(10'000), gw, alice);
+        env.close();
+        env.trust(usd(1'000), alice);
+        env(pay(gw, alice, usd(100)));
+        MPTTester mpt(
+            {.env = env,
+             .issuer = gw,
+             .holders = {alice},
+             .pay = 100,
+             .flags = tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true,
+             .close = false});
+        MPT const mptAsset = mpt;
+        env.close();
+
+        env(noop(alice));
+        // These offers differ only by TakerGets asset type. Omitting
+        // owner_funds serializes the tx JSON without computing offer balances;
+        // owner_funds=true asks LedgerToJson to compute accountFunds(TakerGets)
+        // for both offers, which is where IOU and MPT used to diverge.
+        env(offer(alice, XRP(10), usd(10)));
+        env(offer(alice, XRP(10), mptAsset(10)));
+        // The MPT offer was created while authorized. Unauthorizing in the
+        // same ledger makes owner_funds depend on AuthHandling::IgnoreAuth.
+        mpt.authorize({.account = gw, .holder = alice, .flags = tfMPTUnauthorize});
+        env(noop(alice));
+        env.close();
+
+        auto const ledgerHash = to_string(env.closed()->header().hash);
+
+        auto const getTransactions = [&](bool includeOwnerFunds) {
+            json::Value params;
+            params[jss::ledger_hash] = ledgerHash;
+            params[jss::transactions] = true;
+            params[jss::expand] = true;
+            // The baseline omits owner_funds, which the RPC treats as false.
+            // Setting it true requests the same ledger, but asks the ledger
+            // serializer to add owner_funds to offer transactions in that
+            // ledger's transaction array.
+            if (includeOwnerFunds)
+                params[jss::owner_funds] = true;
+
+            auto const result = env.rpc("json", "ledger", to_string(params))[jss::result];
+            BEAST_EXPECT(!result.isMember(jss::error));
+            BEAST_EXPECT(result[jss::ledger][jss::transactions].isArray());
+            return result[jss::ledger][jss::transactions];
+        };
+
+        auto const findOffer = [](json::Value const& txs, bool mpt) -> json::Value const* {
+            for (auto i = 0u; i < txs.size(); ++i)
+            {
+                auto const& tx = txs[i].isMember(jss::tx_json) ? txs[i][jss::tx_json] : txs[i];
+                if (tx[jss::TransactionType] == jss::OfferCreate &&
+                    tx[jss::TakerGets].isMember(jss::mpt_issuance_id) == mpt)
+                {
+                    return &txs[i];
+                }
+            }
+            return nullptr;
+        };
+
+        // Baseline: same ledger request without owner_funds fields.
+        auto const baseline = getTransactions(false);
+        BEAST_EXPECT(baseline.size() == 5u);
+        BEAST_EXPECT(findOffer(baseline, false) != nullptr);
+        BEAST_EXPECT(findOffer(baseline, true) != nullptr);
+
+        // Same ledger request with owner_funds added to eligible offer txs.
+        auto const withOwnerFunds = getTransactions(true);
+        // Requesting owner_funds must not change which ledger transactions are
+        // returned, even when one offer's TakerGets is MPT.
+        BEAST_EXPECT(withOwnerFunds.size() == baseline.size());
+
+        // The IOU offer is the control case for expected owner_funds output.
+        auto const* iouOfferTx = findOffer(withOwnerFunds, false);
+        if (BEAST_EXPECT(iouOfferTx != nullptr))
+            BEAST_EXPECT((*iouOfferTx)[jss::owner_funds] == "100");
+
+        // MPT owner_funds should match the IOU behavior, even though Alice is
+        // unauthorized by the ledger snapshot used for serialization.
+        auto const* mptOfferTx = findOffer(withOwnerFunds, true);
+        if (BEAST_EXPECT(mptOfferTx != nullptr))
+            BEAST_EXPECT((*mptOfferTx)[jss::owner_funds] == "100");
+    }
+
     /**
      * @brief ledger RPC requests as a way to drive
      * input options to lookupLedger. The point of this test is
@@ -719,6 +817,7 @@ public:
         testLedgerFull();
         testLedgerFullNonAdmin();
         testLedgerAccounts();
+        testLedgerOwnerFundsMPTOffer();
         testLookupLedger();
         testNoQueue();
         testQueue();
diff --git a/src/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/Number.cpp b/src/tests/libxrpl/basics/Number.cpp
index 32f93eb1f7..8e958b40d4 100644
--- a/src/tests/libxrpl/basics/Number.cpp
+++ b/src/tests/libxrpl/basics/Number.cpp
@@ -1,5 +1,6 @@
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -16,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -183,6 +185,17 @@ TEST(NumberTest, limits)
         }
         EXPECT_TRUE(caught);
 
+        try
+        {
+            Number{1, 2000000, Number::Normalized{}};
+            ADD_FAILURE();
+        }
+        catch (std::overflow_error const& e)
+        {
+            std::string const expected = "Number::normalize 2";
+            EXPECT_EQ(e.what(), expected) << e.what();
+        }
+
         if (scale == MantissaRange::MantissaScale::Large330)
         {
             // Normalization with the other scales, including the older large mantissa scales, will
@@ -406,6 +419,158 @@ TEST(NumberTest, add)
     }
 }
 
+TEST(NumberTest, add_sub_extreme_exponents)
+{
+    for (auto const mantissaScale : MantissaRange::getAllScales())
+    {
+        NumberMantissaScaleGuard const sg(mantissaScale);
+
+        auto const scale = Number::getMantissaScale();
+
+        EXPECT_EQ(Number::getround(), Number::RoundingMode::ToNearest)
+            << to_string(Number::getround());
+
+        // Special cases: Exponents at each end of the allowable range
+        for (auto const round :
+             {Number::RoundingMode::ToNearest,
+              Number::RoundingMode::TowardsZero,
+              Number::RoundingMode::Downward,
+              Number::RoundingMode::Upward})
+        {
+            NumberRoundModeGuard const rg{round};
+
+            auto const bigMantissa = std::invoke([scale, round] {
+                auto m = Number::maxMantissa();
+                if (scale != MantissaRange::MantissaScale::Small)
+                {
+                    // At the large scales, the maxMantissa is not representable, so we need to
+                    // shrink it down to a representable value.
+                    m /= 10;
+                }
+                if (round == Number::RoundingMode::Upward)
+                {
+                    // Rounding upward will overflow if the mantissa is at maxMantissa. Subtract an
+                    // arbitrary small value to keep the mantissa near the limit, but with a
+                    // little room to grow. 67 has no meaning, except that it's, you know,
+                    // six seven.
+                    m -= 67;
+                }
+                return m;
+            });
+            auto const params = {
+                std::make_pair(Number::minMantissa(), 0),
+                // At the large scales, the maxMantissa is not representable, so we need to shrink
+                // it down to a representable value. Rounding upward will overflow if the mantissa
+                // is right at the all nines value. To keep things a little simpler, do those
+                // modifications unconditionally.
+                std::make_pair(bigMantissa, 1),
+            };
+            for (auto const& [mantissa, exponentOffset] : params)
+            {
+                auto const x = Number{mantissa, Number::kMaxExponent, Number::Normalized{}};
+                auto const y =
+                    Number{mantissa, Number::kMinExponent + exponentOffset, Number::Normalized{}};
+
+                std::ostringstream detail;
+                detail << "Scale: " << to_string(scale) << ", round: " << to_string(round)
+                       << ", x: " << x << ", y: " << y;
+
+                EXPECT_EQ(x.mantissa(), mantissa);
+                EXPECT_EQ(x.exponent(), Number::kMaxExponent);
+                EXPECT_NE(x, beast::kZero);
+                EXPECT_EQ(y.mantissa(), mantissa);
+                EXPECT_EQ(y.exponent(), Number::kMinExponent + exponentOffset);
+                EXPECT_NE(y, beast::kZero);
+
+                {
+                    // x + y
+                    auto const result = x + y;
+
+                    if (round == Number::RoundingMode::Upward)
+                    {
+                        // Rounding upward will take that little x-bit and round result up to the
+                        // next representable value.
+                        EXPECT_NE(result, x);
+                        EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()}));
+                    }
+                    else
+                    {
+                        EXPECT_EQ(result, x);
+                    }
+                }
+                {
+                    // x - y
+                    auto const result = x - y;
+
+                    switch (round)
+                    {
+                        case Number::RoundingMode::TowardsZero:
+                            if (scale < MantissaRange::MantissaScale::Large330)
+                            {
+                                // Rounding TowardsZero was broken before Large330.
+                                EXPECT_EQ(result, x) << detail.str();
+                                break;
+                            }
+                            [[fallthrough]];
+                        case Number::RoundingMode::Downward:
+                            // Rounding downward (or toward zero in Large330) will take that little
+                            // x-bit and round result down to the next representable value.
+                            EXPECT_NE(result, x) << detail.str();
+                            EXPECT_EQ(result, (Number{x.mantissa() - 1, x.exponent()}))
+                                << detail.str();
+                            break;
+                        default:
+                            // Rounding up and toNearest rounds back to the original value
+                            EXPECT_EQ(result, x) << detail.str();
+                    }
+                }
+                {
+                    // y + x
+                    auto const result = y + x;
+
+                    if (round == Number::RoundingMode::Upward)
+                    {
+                        // Rounding upward will take that little x-bit and round result up to the
+                        // next representable value.
+                        EXPECT_NE(result, x);
+                        EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()}));
+                    }
+                    else
+                    {
+                        EXPECT_EQ(result, x);
+                    }
+                }
+                {
+                    // y - x
+                    auto const result = y - x;
+
+                    switch (round)
+                    {
+                        case Number::RoundingMode::TowardsZero:
+                            if (scale < MantissaRange::MantissaScale::Large330)
+                            {
+                                // Rounding TowardsZero was broken before Large330.
+                                EXPECT_EQ(result, -x) << detail.str();
+                                break;
+                            }
+                            [[fallthrough]];
+                        case Number::RoundingMode::Upward:
+                            // Rounding upward (or toward zero in Large330) will take that little
+                            // x-bit and round result up to the next representable negative value.
+                            EXPECT_NE(result, -x) << detail.str();
+                            EXPECT_EQ(result, (Number{-x.mantissa() + 1, x.exponent()}))
+                                << detail.str();
+                            break;
+                        default:
+                            // Rounding up and toNearest rounds back to the original value
+                            EXPECT_EQ(result, -x) << detail.str();
+                    }
+                }
+            }
+        }
+    }
+}
+
 TEST(NumberTest, sub)
 {
     for (auto const mantissaScale : MantissaRange::getAllScales())
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/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
index 974d0e81d7..e8c1b645d8 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
@@ -36,6 +36,8 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     auto const referenceHoldingValue = canonical_UINT256();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
+    auto const issuerKeyEpochValue = canonical_UINT32();
+    auto const auditorKeyEpochValue = canonical_UINT32();
     auto const confidentialOutstandingAmountValue = canonical_UINT64();
 
     MPTokenIssuanceBuilder builder{
@@ -57,6 +59,8 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     builder.setReferenceHolding(referenceHoldingValue);
     builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
     builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
+    builder.setIssuerKeyEpoch(issuerKeyEpochValue);
+    builder.setAuditorKeyEpoch(auditorKeyEpochValue);
     builder.setConfidentialOutstandingAmount(confidentialOutstandingAmountValue);
 
     builder.setLedgerIndex(index);
@@ -184,6 +188,22 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(entry.hasAuditorEncryptionKey());
     }
 
+    {
+        auto const& expected = issuerKeyEpochValue;
+        auto const actualOpt = entry.getIssuerKeyEpoch();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfIssuerKeyEpoch");
+        EXPECT_TRUE(entry.hasIssuerKeyEpoch());
+    }
+
+    {
+        auto const& expected = auditorKeyEpochValue;
+        auto const actualOpt = entry.getAuditorKeyEpoch();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfAuditorKeyEpoch");
+        EXPECT_TRUE(entry.hasAuditorKeyEpoch());
+    }
+
     {
         auto const& expected = confidentialOutstandingAmountValue;
         auto const actualOpt = entry.getConfidentialOutstandingAmount();
@@ -221,6 +241,8 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     auto const referenceHoldingValue = canonical_UINT256();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
+    auto const issuerKeyEpochValue = canonical_UINT32();
+    auto const auditorKeyEpochValue = canonical_UINT32();
     auto const confidentialOutstandingAmountValue = canonical_UINT64();
 
     auto sle = std::make_shared(MPTokenIssuance::entryType, index);
@@ -241,6 +263,8 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     sle->at(sfReferenceHolding) = referenceHoldingValue;
     sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue;
     sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue;
+    sle->at(sfIssuerKeyEpoch) = issuerKeyEpochValue;
+    sle->at(sfAuditorKeyEpoch) = auditorKeyEpochValue;
     sle->at(sfConfidentialOutstandingAmount) = confidentialOutstandingAmountValue;
 
     MPTokenIssuanceBuilder builderFromSle{sle};
@@ -442,6 +466,32 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
         expectEqualField(expected, *fromBuilderOpt, "sfAuditorEncryptionKey");
     }
 
+    {
+        auto const& expected = issuerKeyEpochValue;
+
+        auto const fromSleOpt = entryFromSle.getIssuerKeyEpoch();
+        auto const fromBuilderOpt = entryFromBuilder.getIssuerKeyEpoch();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfIssuerKeyEpoch");
+        expectEqualField(expected, *fromBuilderOpt, "sfIssuerKeyEpoch");
+    }
+
+    {
+        auto const& expected = auditorKeyEpochValue;
+
+        auto const fromSleOpt = entryFromSle.getAuditorKeyEpoch();
+        auto const fromBuilderOpt = entryFromBuilder.getAuditorKeyEpoch();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfAuditorKeyEpoch");
+        expectEqualField(expected, *fromBuilderOpt, "sfAuditorKeyEpoch");
+    }
+
     {
         auto const& expected = confidentialOutstandingAmountValue;
 
@@ -539,6 +589,10 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getIssuerEncryptionKey().has_value());
     EXPECT_FALSE(entry.hasAuditorEncryptionKey());
     EXPECT_FALSE(entry.getAuditorEncryptionKey().has_value());
+    EXPECT_FALSE(entry.hasIssuerKeyEpoch());
+    EXPECT_FALSE(entry.getIssuerKeyEpoch().has_value());
+    EXPECT_FALSE(entry.hasAuditorKeyEpoch());
+    EXPECT_FALSE(entry.getAuditorKeyEpoch().has_value());
     EXPECT_FALSE(entry.hasConfidentialOutstandingAmount());
     EXPECT_FALSE(entry.getConfidentialOutstandingAmount().has_value());
 }
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/LoanBrokerCoverWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
index 5b0a8c9146..043ab0a252 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     LoanBrokerCoverWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     LoanBrokerCoverWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/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/protocol_autogen/transactions/VaultWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
index 4067a6551d..518957d47b 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     VaultWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     VaultWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsVaultWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp
index e662e16be4..7f7d6ffba2 100644
--- a/src/tests/libxrpl/shamap/SHAMap.cpp
+++ b/src/tests/libxrpl/shamap/SHAMap.cpp
@@ -3,13 +3,16 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -113,7 +116,7 @@ protected:
     intToVuc(std::uint8_t v)
     {
         Buffer vuc{32};
-        std::fill_n(vuc.data(), vuc.size(), v);
+        vuc.fill(v);
         return vuc;
     }
 };
@@ -347,4 +350,149 @@ TEST_F(SHAMapPathProof, verify_proof_path)
     EXPECT_FALSE(map.verifyProofPath(rootHash, key, badPath));
 }
 
+// A legitimate proof path for two keys sharing all 63 leading nibbles is 65 elements: inner nodes
+// at depths 0..63 plus the leaf at depth 64. This pins that the 65 bound is real, so the fix for
+// the forged-path case below must not simply tighten the length limit.
+TEST_F(SHAMapPathProof, legitimate_deep_path_is_sixty_five_elements)
+{
+    tests::TestNodeFamily f{j_};
+    SHAMap map{SHAMapType::FREE, f};
+    map.setUnbacked();
+
+    auto const kA = uint256{std::string_view{std::string(63, 'a') + "1"}};
+    auto const kB = uint256{std::string_view{std::string(63, 'a') + "2"}};
+
+    for (auto const& k : {kA, kB})
+    {
+        Buffer vuc{32};
+        std::fill_n(vuc.data(), vuc.size(), std::uint8_t{1});
+        ASSERT_TRUE(map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, std::move(vuc))));
+    }
+    map.invariants();
+
+    auto const pathA = map.getProofPath(kA);
+    ASSERT_TRUE(pathA.has_value());
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    EXPECT_EQ(pathA->size(), 65u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kA, *pathA));
+    // NOLINTEND(bugprone-unchecked-optional-access)
+
+    auto const pathB = map.getProofPath(kB);
+    ASSERT_TRUE(pathB.has_value());
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    EXPECT_EQ(pathB->size(), 65u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kB, *pathB));
+    // NOLINTEND(bugprone-unchecked-optional-access)
+}
+
+// A forged path of 65 hash-chained inner nodes reaches depth kLeafDepth, where only the leaf
+// terminating the path may sit. Such a path must be rejected.
+TEST_F(SHAMapPathProof, all_inner_path_at_leaf_depth_is_rejected)
+{
+    // An arbitrary well-formed key; the test does not care about its specific value.
+    constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8");
+
+    // Build upwards from the deepest node so each parent's selected branch carries its child's hash
+    // and the hash chain validates at every level.
+    std::vector path;
+    SHAMapHash childHash{uint256{1}};
+
+    for (auto depth = SHAMap::kLeafDepth + 1u; depth-- > 0;)
+    {
+        auto const id = SHAMapNodeID::createID(std::min(depth, SHAMap::kLeafDepth - 1u), kTestKey);
+        auto const branch = selectBranch(id, kTestKey);
+
+        Serializer s;
+        for (auto i = 0u; i < SHAMap::kBranchFactor; ++i)
+            s.addBitString(i == branch ? childHash.asUInt256() : uint256{});
+        s.add8(kWireTypeInner);
+        path.push_back(s.getData());
+
+        auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.back()));
+        ASSERT_TRUE(node);
+        node->updateHash();
+        childHash = node->getHash();
+    }
+
+    ASSERT_EQ(path.size(), 65u);
+    EXPECT_FALSE(SHAMap::verifyProofPath(childHash.asUInt256(), kTestKey, path));
+}
+
+/**
+ * Wrap a leaf blob in a forged root inner node whose branch for `key` carries that leaf's hash.
+ *
+ * The resulting two-element path hash-chains for `key` no matter which leaf sits at the bottom,
+ * which is exactly the substitution a peer could attempt.
+ *
+ * @param leafBlob the wire form of the leaf to place at the bottom of the path.
+ * @param key the key the forged path claims to prove.
+ * @return the path (deepest element first) and the forged root hash, or an empty path if the leaf
+ *         blob does not parse.
+ */
+static std::pair, uint256>
+forgeRootOverLeaf(Blob const& leafBlob, uint256 const& key)
+{
+    auto leaf = SHAMapTreeNode::makeFromWire(makeSlice(leafBlob));
+    if (!leaf || !leaf->isLeaf())
+        return {};
+    leaf->updateHash();
+
+    auto const branch = selectBranch(SHAMapNodeID::createID(0, key), key);
+    Serializer s;
+    for (auto i = 0u; i < SHAMap::kBranchFactor; ++i)
+        s.addBitString(i == branch ? leaf->getHash().asUInt256() : uint256{});
+    s.add8(kWireTypeInner);
+
+    auto root = SHAMapTreeNode::makeFromWire(makeSlice(s.peekData()));
+    if (!root)
+        return {};
+    root->updateHash();
+
+    return {std::vector{leafBlob, s.getData()}, root->getHash().asUInt256()};
+}
+
+// The hash chain above a leaf proves nothing about which key that leaf holds, so a peer can graft a
+// genuine leaf from elsewhere in the map onto a path forged for another key. Comparing the terminal
+// leaf's own key against the key being proved is what rejects it.
+TEST_F(SHAMapPathProof, substituted_leaf_for_other_key_is_rejected)
+{
+    tests::TestNodeFamily f{j_};
+    SHAMap map{SHAMapType::FREE, f};
+    map.setUnbacked();
+
+    // Two arbitrary keys differing in their first nibble, so each leaf hangs off the root directly.
+    constexpr uint256 kKey("1c8cec8e5e9b0e5e0e0f5b3e2c9f7a1d6b4e8c2a0d7f3b9e5c1a8d4f2b6e0c93");
+    constexpr uint256 kOtherKey("e3f1a7d5b9c2e8f406a1d3b5c7e9f2a4d6b8c0e2f4a6d8b0c2e4f6a8d0b2c4e6");
+
+    for (auto const& k : {kKey, kOtherKey})
+    {
+        ASSERT_TRUE(map.addItem(
+            SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()})));
+    }
+    map.invariants();
+
+    auto const ownPath = map.getProofPath(kKey);
+    auto const otherPath = map.getProofPath(kOtherKey);
+    ASSERT_TRUE(ownPath.has_value());
+    ASSERT_TRUE(otherPath.has_value());
+
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    // The genuine leaf blobs, deepest element first.
+    auto const& ownLeaf = ownPath->front();
+    auto const& otherLeaf = otherPath->front();
+    // NOLINTEND(bugprone-unchecked-optional-access)
+
+    // Control: the forged root is accepted when the leaf below it really is kKey's leaf, so the
+    // rejection below can only come from the leaf key comparison.
+    auto const [goodPath, goodRoot] = forgeRootOverLeaf(ownLeaf, kKey);
+    ASSERT_EQ(goodPath.size(), 2u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(goodRoot, kKey, goodPath));
+
+    // Same forged root, but kOtherKey's leaf substituted at the bottom: the hash chain still
+    // validates, yet the path does not prove anything about kKey.
+    auto const [badPath, badRoot] = forgeRootOverLeaf(otherLeaf, kKey);
+    ASSERT_EQ(badPath.size(), 2u);
+    EXPECT_FALSE(SHAMap::verifyProofPath(badRoot, kKey, badPath));
+}
+
 }  // namespace xrpl::tests
diff --git a/src/tests/libxrpl/shamap/SHAMapNodeID.cpp b/src/tests/libxrpl/shamap/SHAMapNodeID.cpp
new file mode 100644
index 0000000000..95b7497c9c
--- /dev/null
+++ b/src/tests/libxrpl/shamap/SHAMapNodeID.cpp
@@ -0,0 +1,193 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+
+namespace xrpl::tests {
+
+// An arbitrary 32-byte key reused across tests below that don't care about its specific value,
+// only that it is a well-formed key.
+constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8");
+
+TEST(SHAMapNodeIDTest, root_is_prefix_of_every_key)
+{
+    SHAMapNodeID const root;
+    EXPECT_EQ(root.getDepth(), 0u);
+    EXPECT_TRUE(root.isPrefixOf(uint256{}));
+    EXPECT_TRUE(root.isPrefixOf(kTestKey));
+}
+
+TEST(SHAMapNodeIDTest, child_id_is_prefix_of_keys_in_that_branch)
+{
+    // Walking the branches spelled by the key's own nibbles must keep every
+    // intermediate ID a prefix of that key.
+    SHAMapNodeID id;
+    for (auto depth = 0u; depth < SHAMap::kLeafDepth; ++depth)
+    {
+        id = id.getChildNodeID(selectBranch(id, kTestKey));
+        EXPECT_EQ(id.getDepth(), depth + 1);
+        EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << id.getDepth();
+    }
+}
+
+TEST(SHAMapNodeIDTest, wrong_branch_is_not_prefix_of_key)
+{
+    SHAMapNodeID const root;
+    auto const correct = selectBranch(root, kTestKey);
+    ASSERT_EQ(correct, 0xbu);
+
+    // An ID built from the wrong branch still has a valid depth and a self-consistent mask, so
+    // isPrefixOf(kTestKey) below is what actually distinguishes the correct branch from the rest.
+    for (auto branch = 0u; branch < SHAMap::kBranchFactor; ++branch)
+    {
+        auto const child = root.getChildNodeID(branch);
+        EXPECT_EQ(child.getDepth(), 1u);
+        EXPECT_EQ(child.isPrefixOf(kTestKey), branch == correct) << "branch " << branch;
+    }
+}
+
+TEST(SHAMapNodeIDTest, prefix_check_is_depth_sensitive)
+{
+    // kTestKey and kOther agree on the first two nibbles ("b9") and then diverge.
+    constexpr uint256 kOther("b99891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8");
+
+    auto id = SHAMapNodeID{}.getChildNodeID(selectBranch(SHAMapNodeID{}, kTestKey));
+    EXPECT_TRUE(id.isPrefixOf(kTestKey));
+    EXPECT_TRUE(id.isPrefixOf(kOther)) << "shared first nibble";
+
+    id = id.getChildNodeID(selectBranch(id, kTestKey));
+    EXPECT_TRUE(id.isPrefixOf(kTestKey));
+    EXPECT_TRUE(id.isPrefixOf(kOther)) << "shared second nibble";
+
+    // Third nibble differs, so the deeper ID no longer covers kOther.
+    id = id.getChildNodeID(selectBranch(id, kTestKey));
+    EXPECT_TRUE(id.isPrefixOf(kTestKey));
+    EXPECT_FALSE(id.isPrefixOf(kOther));
+}
+
+TEST(SHAMapNodeIDTest, leaf_id_from_key_is_prefix_of_that_key)
+{
+    SHAMapNodeID const leaf{SHAMap::kLeafDepth, kTestKey};
+    EXPECT_TRUE(leaf.isPrefixOf(kTestKey));
+
+    // At full depth the prefix is the whole key, so nothing else matches.
+    constexpr uint256 kOther("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca9");
+    EXPECT_FALSE(leaf.isPrefixOf(kOther));
+}
+
+TEST(SHAMapNodeIDTest, create_id_masks_key_to_depth)
+{
+    for (auto depth = 0u; depth <= SHAMap::kLeafDepth; ++depth)
+    {
+        auto const id = SHAMapNodeID::createID(depth, kTestKey);
+        EXPECT_EQ(id.getDepth(), depth);
+        EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << depth;
+    }
+}
+
+// The guards below must hold with XRPL_ASSERT compiled out (NDEBUG), so each one
+// has to be a real runtime check rather than an assert.
+
+TEST(SHAMapNodeIDTest, child_of_leaf_depth_id_throws)
+{
+    auto const leafDepthID = SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey);
+    ASSERT_EQ(leafDepthID.getDepth(), SHAMap::kLeafDepth);
+    EXPECT_THROW((void)leafDepthID.getChildNodeID(0), std::logic_error);
+}
+
+TEST(SHAMapNodeIDDeathTest, out_of_range_depth_is_clamped)
+{
+    // A depth past kLeafDepth has no mask in depthMask's 65-entry table, so both the constructor
+    // and createID clamp it. createID needs its own clamp: it picks the mask while evaluating the
+    // constructor's argument, so the constructor's clamp cannot cover that read.
+    //
+    // Both clamps are marked UNREACHABLE, which is an assert and therefore fatal wherever asserts
+    // are live. Only a build with them compiled out (or routed to Antithesis's non-fatal handler)
+    // reaches the clamp itself, so that is the only configuration that can assert on the result.
+#if defined(NDEBUG) || defined(ENABLE_VOIDSTAR)
+    for (auto const depth : {SHAMap::kLeafDepth + 1u, 100u, 255u, 256u, 320u})
+    {
+        auto const id = SHAMapNodeID::createID(depth, kTestKey);
+
+        // Clamped to a real depth, not the depth asked for, and not a byte-narrowed version of it:
+        // 256 would otherwise become 0 and name the root, 320 would become 64.
+        EXPECT_EQ(id.getDepth(), SHAMap::kLeafDepth) << "depth " << depth;
+
+        // id_ and depth_ still agree, so the object is usable rather than merely non-crashing.
+        EXPECT_TRUE(id.isPrefixOf(kTestKey)) << "depth " << depth;
+        EXPECT_EQ(id, SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey)) << "depth " << depth;
+
+        // The clamp holds through the wire format too, which encodes the depth in one byte.
+        auto const roundTripped = deserializeSHAMapNodeID(id.getRawString());
+        ASSERT_TRUE(roundTripped.has_value()) << "depth " << depth;
+        EXPECT_EQ(roundTripped->getDepth(), SHAMap::kLeafDepth) << "depth " << depth;
+    }
+
+    // The constructor clamps on its own, for the paths that do not go through createID.
+    SHAMapNodeID const direct{SHAMap::kLeafDepth + 1u, uint256{}};
+    EXPECT_EQ(direct.getDepth(), SHAMap::kLeafDepth);
+#else
+    EXPECT_DEATH(
+        (void)SHAMapNodeID::createID(SHAMap::kLeafDepth + 1u, kTestKey), "depth within tree");
+#endif
+}
+
+TEST(SHAMapNodeIDDeathTest, select_branch_clamps_leaf_depth)
+{
+    // selectBranch's own precondition is depth < kLeafDepth: a depth-64 ID has no nibble left
+    // to select. That makes it unlike the guards above, which have a throw/return reachable
+    // even with XRPL_ASSERT compiled out; selectBranch has no such path, so the two build
+    // configurations have to be tested differently.
+    //
+    // Under ENABLE_VOIDSTAR, XRPL_ASSERT routes to Antithesis's assert_impl, which only records
+    // the hit and returns rather than aborting, even though NDEBUG is undefined there (voidstar
+    // requires a Debug build). So the assert is live in name but never fatal, the same as the
+    // NDEBUG case below.
+    auto const leafDepthID = SHAMapNodeID::createID(SHAMap::kLeafDepth, kTestKey);
+
+#if defined(NDEBUG) || defined(ENABLE_VOIDSTAR)
+    // With the assert compiled out or routed to a non-fatal handler, the clamp is what stands
+    // between this call and reading past the end of the 32-byte key. Clamping means it reads the
+    // same byte, and returns the same branch, as the deepest ID that still has one: depth 63.
+    auto const deepestWithBranchID = SHAMapNodeID::createID(SHAMap::kLeafDepth - 1u, kTestKey);
+    auto const branch = selectBranch(leafDepthID, kTestKey);
+    EXPECT_LT(branch, SHAMap::kBranchFactor);
+    EXPECT_EQ(branch, selectBranch(deepestWithBranchID, kTestKey));
+#else
+    // In a debug build the assert is live and must reject this call outright, in a forked
+    // process so a failure here cannot take down the rest of the suite.
+    EXPECT_DEATH((void)selectBranch(leafDepthID, kTestKey), "depth below leaf depth");
+#endif
+}
+
+TEST(SHAMapNodeIDTest, deserialize_rejects_out_of_range_depth)
+{
+    // getRawString() only serializes a depth already accepted by the constructor's own
+    // assertion, so an out-of-range depth here is built by hand instead.
+    auto serializeWithRawDepth = [](unsigned int depth) {
+        Serializer s;
+        s.addBitString(uint256{});
+        s.add8(static_cast(depth));
+        return s.getString();
+    };
+
+    for (auto const depth : {65u, 100u, 255u})
+    {
+        EXPECT_FALSE(deserializeSHAMapNodeID(serializeWithRawDepth(depth)).has_value())
+            << "depth " << depth;
+    }
+
+    // A depth-64 ID is legal, since leaves live there, but it has no children.
+    auto const id =
+        deserializeSHAMapNodeID(SHAMapNodeID{SHAMap::kLeafDepth, uint256{}}.getRawString());
+    ASSERT_TRUE(id.has_value());
+    // NOLINTNEXTLINE(bugprone-unchecked-optional-access) has_value checked above
+    EXPECT_THROW((void)id->getChildNodeID(0), std::logic_error);
+}
+
+}  // namespace xrpl::tests
diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h
index 32163fd57b..140b12fa59 100644
--- a/src/xrpld/app/ledger/LedgerMaster.h
+++ b/src/xrpld/app/ledger/LedgerMaster.h
@@ -123,7 +123,10 @@ public:
     failedSave(std::uint32_t seq, uint256 const& hash);
 
     std::string
-    getCompleteLedgers();
+    getCompleteLedgers() const;
+
+    std::size_t
+    missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const;
 
     /**
      * Apply held transactions to the open ledger
@@ -190,7 +193,7 @@ public:
     fixMismatch(ReadView const& ledger);
 
     bool
-    haveLedger(std::uint32_t seq);
+    haveLedger(std::uint32_t seq) const;
     void
     clearLedger(std::uint32_t seq);
     bool
@@ -348,7 +351,7 @@ private:
     // A set of transactions to replay during the next close
     std::unique_ptr replayData_;
 
-    std::recursive_mutex completeLock_;
+    std::recursive_mutex mutable completeLock_;
     RangeSet completeLedgers_;
 
     // Publish thread is running.
diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
index 83d76bcd2a..878b257b69 100644
--- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
@@ -57,6 +57,7 @@
 #include 
 #include 
 
+#include 
 #include 
 
 #include 
@@ -492,7 +493,7 @@ LedgerMaster::setBuildingLedger(LedgerIndex i)
 }
 
 bool
-LedgerMaster::haveLedger(std::uint32_t seq)
+LedgerMaster::haveLedger(std::uint32_t seq) const
 {
     std::scoped_lock const sl(completeLock_);
     return boost::icl::contains(completeLedgers_, seq);
@@ -1576,12 +1577,36 @@ LedgerMaster::getPublishedLedger()
 }
 
 std::string
-LedgerMaster::getCompleteLedgers()
+LedgerMaster::getCompleteLedgers() const
 {
     std::scoped_lock const sl(completeLock_);
     return to_string(completeLedgers_);
 }
 
+std::size_t
+LedgerMaster::missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const
+{
+    if (first > last)
+    {
+        // In expected usage, this will never happen because "first" is generally initialized to
+        // "last", "last" is guaranteed to grow monotonically, and "first" either doesn't change
+        // or grows more slowly.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::LedgerMaster::missingFromCompleteLedgerRange : invalid parameters");
+        return 0;
+        // LCOV_EXCL_STOP
+    }
+
+    RangeSet const target{range(first, last)};
+
+    auto const missing = [&target, this] {
+        std::scoped_lock const sl(completeLock_);
+        return target - completeLedgers_;
+    }();
+
+    return boost::icl::size(missing);
+}
+
 std::optional
 LedgerMaster::getCloseTimeBySeq(LedgerIndex ledgerIndex)
 {
diff --git a/src/xrpld/app/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/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
index 9d3820e9f7..1d789aa604 100644
--- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
@@ -4,8 +4,7 @@
 #include 
 #include 
 #include 
-#include 
-#include 
+#include 
 
 #include 
 #include 
@@ -19,6 +18,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -141,19 +141,12 @@ fillJsonTx(
         {
             txJson[jss::meta] = stMeta->getJson(JsonOptions::Values::None);
 
-            // If applicable, insert delivered amount
-            if (txnType == ttPAYMENT || txnType == ttCHECK_CASH)
-            {
-                rpc::insertDeliveredAmount(
-                    txJson[jss::meta],
-                    fill.ledger,
-                    txn,
-                    {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
-            }
-
-            // If applicable, insert mpt issuance id
-            rpc::insertMPTokenIssuanceID(
-                txJson[jss::meta], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
+            // Insert all synthetic fields
+            rpc::insertAllSyntheticInJson(
+                txJson[jss::meta],
+                fill.ledger,
+                txn,
+                {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
         }
 
         if (!fill.ledger.open())
@@ -177,19 +170,12 @@ fillJsonTx(
         {
             txJson[jss::metaData] = stMeta->getJson(JsonOptions::Values::None);
 
-            // If applicable, insert delivered amount
-            if (txnType == ttPAYMENT || txnType == ttCHECK_CASH)
-            {
-                rpc::insertDeliveredAmount(
-                    txJson[jss::metaData],
-                    fill.ledger,
-                    txn,
-                    {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
-            }
-
-            // If applicable, insert mpt issuance id
-            rpc::insertMPTokenIssuanceID(
-                txJson[jss::metaData], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
+            // Insert all synthetic fields
+            rpc::insertAllSyntheticInJson(
+                txJson[jss::metaData],
+                fill.ledger,
+                txn,
+                {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
         }
     }
 
@@ -208,6 +194,7 @@ fillJsonTx(
                 account,
                 amount,
                 FreezeHandling::IgnoreFreeze,
+                AuthHandling::IgnoreAuth,
                 beast::Journal{beast::Journal::getNullSink()});
             txJson[jss::owner_funds] = ownerFunds.getText();
         }
diff --git a/src/xrpld/app/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 2bfb24b121..26bf6bd24b 100644
--- a/src/xrpld/app/misc/FeeVoteImpl.cpp
+++ b/src/xrpld/app/misc/FeeVoteImpl.cpp
@@ -326,50 +326,46 @@ 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 gasLimit = gasLimitVote.getVotes();
-    auto const bytecodeSizeLimit = bytecodeSizeLimitVote.getVotes();
-    auto const gasPrice = gasPriceVote.getVotes();
+    auto const [baseFee, baseFeeChanged] = baseFeeVote.getVotes();
+    auto const [baseReserve, baseReserveChanged] = baseReserveVote.getVotes();
+    auto const [incReserve, incReserveChanged] = incReserveVote.getVotes();
+    auto const [gasLimit, gasLimitChanged] = gasLimitVote.getVotes();
+    auto const [bytecodeSizeLimit, bytecodeSizeLimitChanged] = bytecodeSizeLimitVote.getVotes();
+    auto const [gasPrice, gasPriceChanged] = gasPriceVote.getVotes();
 
     auto const seq = lastClosedLedger->header().seq + 1;
 
     // add transactions to our position
-    if (baseFee.second || baseReserve.second || incReserve.second || gasLimit.second ||
-        bytecodeSizeLimit.second || gasPrice.second)
+    if (baseFeeChanged || baseReserveChanged || incReserveChanged || gasLimitChanged ||
+        bytecodeSizeLimitChanged || gasPriceChanged)
     {
-        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;
             }
             if (rules.enabled(featureSmartEscrow))
             {
-                obj[sfGasLimit] = gasLimit.first;
-                obj[sfBytecodeSizeLimit] = bytecodeSizeLimit.first;
-                obj[sfGasPrice] = gasPrice.first;
+                obj[sfGasLimit] = gasLimit;
+                obj[sfBytecodeSizeLimit] = bytecodeSizeLimit;
+                obj[sfGasPrice] = gasPrice;
             }
         });
 
diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp
index cded5a9194..97243f82bc 100644
--- a/src/xrpld/app/misc/NetworkOPs.cpp
+++ b/src/xrpld/app/misc/NetworkOPs.cpp
@@ -28,9 +28,8 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
+#include 
 
 #include 
 #include 
@@ -74,10 +73,11 @@
 #include 
 #include 
 #include 
-#include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -85,10 +85,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -1465,11 +1467,17 @@ NetworkOPsImp::preProcessTransaction(std::shared_ptr& transaction)
 
     // NOTE ximinez - I think this check is redundant,
     // but I'm not 100% sure yet.
-    // If so, only cost is looking up HashRouter flags.
-    auto const [validity, reason] =
-        checkValidity(registry_.get().getHashRouter(), sttx, view->rules());
-    XRPL_ASSERT(
-        validity == Validity::Valid, "xrpl::NetworkOPsImp::processTransaction : valid validity");
+    //
+    // For an ordinary transaction it is: the relay and submit paths have
+    // already run checkValidity, so this costs a HashRouter lookup. It is not
+    // redundant for a role-signature transaction while fixCleanup3_4_0 is
+    // activating. Those paths verify against the validated rules, which lag
+    // the open ledger rules used here, and checkValidity scopes a cached
+    // verdict to the rules that reached it, so this call can verify the
+    // signature again and come to a different answer. SigBad is therefore
+    // reachable, and the handler below is the correct response to it.
+    auto const& viewRules = view->rules();
+    auto const [validity, reason] = checkValidity(registry_.get().getHashRouter(), sttx, viewRules);
 
     // Not concerned with local checks at this point.
     if (validity == Validity::SigBad)
@@ -1477,7 +1485,15 @@ NetworkOPsImp::preProcessTransaction(std::shared_ptr& transaction)
         JLOG(journal_.info()) << "Transaction has bad signature: " << reason;
         transaction->setStatus(TransStatus::INVALID);
         transaction->setResult(temBAD_SIGNATURE);
-        registry_.get().getHashRouter().setFlags(transaction->getID(), HashRouterFlags::BAD);
+        // See the matching guard in PeerImp::checkTransaction: only cache
+        // BAD for a role-signature transaction once fixCleanup3_4_0 is
+        // enabled on this node. Remove together with the amendment.
+        if (viewRules.enabled(fixCleanup3_4_0) ||
+            (!sttx.isFieldPresent(sfSponsorSignature) &&
+             !sttx.isFieldPresent(sfCounterpartySignature)))
+        {
+            registry_.get().getHashRouter().setFlags(transaction->getID(), HashRouterFlags::BAD);
+        }
         return false;
     }
 
@@ -3526,9 +3542,7 @@ NetworkOPsImp::transJson(
     if (meta)
     {
         jvObj[jss::meta] = meta->get().getJson(JsonOptions::Values::None);
-        rpc::insertDeliveredAmount(jvObj[jss::meta], *ledger, transaction, meta->get());
-        rpc::insertNFTSyntheticInJson(jvObj, transaction, meta->get());
-        rpc::insertMPTokenIssuanceID(jvObj[jss::meta], transaction, meta->get());
+        rpc::insertAllSyntheticInJson(jvObj[jss::meta], *ledger, transaction, meta->get());
     }
 
     // add CTID where the needed data for it exists
@@ -4873,8 +4887,7 @@ NetworkOPsImp::getBookPage(
 
     ReadView const& view = *lpLedger;
 
-    bool const bGlobalFreeze =
-        isGlobalFrozen(view, book.out.getIssuer()) || isGlobalFrozen(view, book.in.getIssuer());
+    bool const bGlobalFreeze = isGlobalFrozen(view, book.out) || isGlobalFrozen(view, book.in);
 
     bool bDone = false;
     bool bDirectAdvance = true;
@@ -4884,7 +4897,7 @@ NetworkOPsImp::getBookPage(
     unsigned int uBookEntry = 0;
     STAmount saDirRate;
 
-    auto const rate = transferRate(view, book.out.getIssuer());
+    auto const rate = transferRate(view, book.out);
     auto viewJ = registry_.get().getJournal("View");
 
     while (!bDone && iLimit-- > 0)
@@ -4933,12 +4946,37 @@ NetworkOPsImp::getBookPage(
                 auto const& saTakerPays = sleOffer->getFieldAmount(sfTakerPays);
                 STAmount saOwnerFunds;
                 bool firstOwnerOffer(true);
+                auto foundBalance = [&]() {
+                    auto umBalanceEntry = umBalance.find(uOfferOwnerID);
+                    if (umBalanceEntry == umBalance.end())
+                        return false;
+
+                    // Found in running balance table.
+                    saOwnerFunds = umBalanceEntry->second;
+                    firstOwnerOffer = false;
+                    return true;
+                };
 
                 if (book.out.getIssuer() == uOfferOwnerID)
                 {
-                    // If an offer is selling issuer's own IOUs, it is fully
-                    // funded.
-                    saOwnerFunds = saTakerGets;
+                    book.out.visit(
+                        [&](Issue const&) {
+                            // If an offer is selling issuer's own IOUs, it is
+                            // fully funded.
+                            saOwnerFunds = saTakerGets;
+                        },
+                        [&](MPTIssue const& issue) {
+                            // MPT issuers have bounded self-issuance. Use the
+                            // running balance table so multiple issuer-owned
+                            // offers share the same remaining issuance
+                            // headroom.
+                            if (!foundBalance())
+                            {
+                                // Did not find balance in table.
+
+                                saOwnerFunds = issuerFundsToSelfIssue(view, issue);
+                            }
+                        });
                 }
                 else if (bGlobalFreeze)
                 {
@@ -4948,15 +4986,7 @@ NetworkOPsImp::getBookPage(
                 }
                 else
                 {
-                    auto umBalanceEntry = umBalance.find(uOfferOwnerID);
-                    if (umBalanceEntry != umBalance.end())
-                    {
-                        // Found in running balance table.
-
-                        saOwnerFunds = umBalanceEntry->second;
-                        firstOwnerOffer = false;
-                    }
-                    else
+                    if (!foundBalance())
                     {
                         // Did not find balance in table.
 
@@ -4992,7 +5022,28 @@ NetworkOPsImp::getBookPage(
                 {
                     // Need to charge a transfer fee to offer owner.
                     offerRate = rate;
-                    saOwnerFundsLimit = divide(saOwnerFunds, offerRate);
+                    // Why MPT does not use divide(): divide() is built for an
+                    // IOU mantissa, which is always normalized into
+                    // [1e15, 1e16]. An MPT mantissa is the raw int64 balance,
+                    // and divide() scales the numerator by 1e17, so a balance
+                    // over ~1.8e17 leaves uint64 range and throws -- failing
+                    // the whole RPC rather than this one offer.
+                    //
+                    // Why mulRatio is safe: it evaluates in 128 bits, and here
+                    // it cannot overflow either. offerRate is
+                    // 1e9 + 10'000 * TransferFee, so this branch runs only with
+                    // offerRate > kParityRate, making the quotient smaller than
+                    // saOwnerFunds. Rounded down, so reported liquidity is
+                    // never overstated.
+                    saOwnerFundsLimit = saOwnerFunds.holds()
+                        ? toSTAmount(
+                              mulRatio(
+                                  saOwnerFunds.mpt(),
+                                  kParityRate.value,
+                                  offerRate.value,
+                                  /*roundUp*/ false),
+                              saOwnerFunds.asset())
+                        : divide(saOwnerFunds, offerRate);
                 }
 
                 if (saOwnerFundsLimit >= saTakerGets)
@@ -5049,6 +5100,8 @@ NetworkOPsImp::getBookPage(
 
 // This is the new code that uses the book iterators
 // It has temporarily been disabled
+// If this path is re-enabled, add MPT support mirroring the functional
+// getBookPage() implementation above.
 
 void
 NetworkOPsImp::getBookPage(
diff --git a/src/xrpld/app/misc/SHAMapStore.h b/src/xrpld/app/misc/SHAMapStore.h
index eeb04df53d..9d50f988b5 100644
--- a/src/xrpld/app/misc/SHAMapStore.h
+++ b/src/xrpld/app/misc/SHAMapStore.h
@@ -8,6 +8,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -34,8 +35,8 @@ public:
     virtual void
     start() = 0;
 
-    virtual void
-    rendezvous() const = 0;
+    [[nodiscard]] virtual bool
+    rendezvous(std::optional const& timeout = {}) const = 0;
 
     virtual void
     stop() = 0;
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp
index e41837d206..e19df597a2 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.cpp
+++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp
@@ -6,8 +6,10 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,12 +29,12 @@
 #include 
 
 #include 
-#include 
-#include 
-#include 
 
 #include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -128,22 +130,6 @@ SHAMapStoreImp::SHAMapStoreImp(
 
     if (deleteInterval_ != 0u)
     {
-        // Configuration that affects the behavior of online delete
-        getIfExists(section, Keys::kDeleteBatch, deleteBatch_);
-        std::uint32_t temp = 0;
-        if (getIfExists(section, Keys::kBackOffMilliseconds, temp) ||
-            // Included for backward compatibility with an undocumented setting
-            getIfExists(section, Keys::kBackOff, temp))
-        {
-            backOff_ = std::chrono::milliseconds{temp};
-        }
-        if (getIfExists(section, Keys::kAgeThresholdSeconds, temp))
-            ageThreshold_ = std::chrono::seconds{temp};
-        if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp))
-            recoveryWaitTime_ = std::chrono::seconds{temp};
-
-        getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_);
-
         auto const minInterval =
             config.standalone() ? kMinimumDeletionIntervalSa : kMinimumDeletionInterval;
         if (deleteInterval_ < minInterval)
@@ -160,6 +146,40 @@ SHAMapStoreImp::SHAMapStoreImp(
                 std::to_string(config.ledgerHistory) + ")");
         }
 
+        // Configuration that affects the behavior of online delete
+        getIfExists(section, Keys::kDeleteBatch, deleteBatch_);
+        std::uint32_t temp = 0;
+        if (getIfExists(section, Keys::kBackOffMilliseconds, temp) ||
+            // Included for backward compatibility with an undocumented setting
+            getIfExists(section, Keys::kBackOff, temp))
+        {
+            backOff_ = std::chrono::milliseconds{temp};
+        }
+        if (getIfExists(section, Keys::kAgeThresholdSeconds, temp))
+            ageThreshold_ = std::chrono::seconds{temp};
+        if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp))
+            recoveryWaitTime_ = std::chrono::seconds{temp};
+        if (recoveryWaitTime_ < std::chrono::seconds{1})
+            Throw("recovery_wait_seconds must be at least 1 second");
+
+        getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_);
+
+        if (getIfExists(section, Keys::kMaxWaitingLedgers, temp))
+        {
+            maxWaitingLedgers_ = temp;
+        }
+        else
+        {
+            maxWaitingLedgers_ = deleteInterval_;
+        }
+
+        auto const minWaiting = minInterval / 4;
+        if (maxWaitingLedgers_ < minWaiting)
+        {
+            Throw(
+                "max_waiting_ledgers must be at least " + std::to_string(minWaiting));
+        }
+
         stateDb_.init(config, dbName_);
         dbPaths();
     }
@@ -236,14 +256,22 @@ SHAMapStoreImp::onLedgerClosed(std::shared_ptr const& ledger)
     cond_.notify_one();
 }
 
-void
-SHAMapStoreImp::rendezvous() const
+[[nodiscard]]
+bool
+SHAMapStoreImp::rendezvous(std::optional const& timeout) const
 {
     if (!working_)
-        return;
+        return true;
+
+    auto notWorking = [&] { return !working_; };
 
     std::unique_lock lock(mutex_);
-    rendezvous_.wait(lock, [&] { return !working_; });
+    if (timeout)
+    {
+        return rendezvous_.wait_for(lock, *timeout, notWorking);
+    }
+    rendezvous_.wait(lock, notWorking);
+    return true;
 }
 
 int
@@ -276,7 +304,7 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node)
     }
     if ((++nodeCount % checkHealthInterval_) == 0u)
     {
-        if (healthWait() == HealthResult::Stopping)
+        if (healthWait() != HealthResult::KeepGoing)
             return false;
     }
 
@@ -327,9 +355,35 @@ SHAMapStoreImp::run()
             stateDb_.setLastRotated(lastRotated);
         }
 
+        // We're starting a new cycle, so reset back to the default.
+        lastSuccessfulHealthCheck_ = 0;
+
         bool const readyToRotate = validatedSeq >= lastRotated + deleteInterval_ &&
             canDelete_ >= lastRotated - 1 && healthWait() == HealthResult::KeepGoing;
 
+        {
+            // Note that this is set after the healthWait() check, so that we
+            // don't start the rotation until the validated ledger is fully
+            // processed. It is not guaranteed to be done at this point. It also
+            // allows the testLedgerGaps unit test to work.
+            std::unique_lock lock(mutex_);
+            if (newLedger_)
+            {
+                // It is possible, though very unlikely outside of tests which manipulate internals,
+                // that healthWait() took so long that the validated ledger (newLedger_) has moved
+                // on from where we started. If that's the case, update lastGoodValidatedLedger_
+                // to that ledger's sequence number.
+                lastGoodValidatedLedger_ = newLedger_->header().seq;
+            }
+            else
+            {
+                lastGoodValidatedLedger_ = validatedSeq;
+            }
+            auto const l = lastGoodValidatedLedger_;
+            lock.unlock();
+            JLOG(journal_.trace()) << "run: Set lastGoodValidatedLedger_ to " << l;
+        }
+
         // will delete up to (not including) lastRotated
         if (readyToRotate)
         {
@@ -337,11 +391,19 @@ SHAMapStoreImp::run()
                                   << lastRotated << " deleteInterval " << deleteInterval_
                                   << " canDelete_ " << canDelete_ << " state "
                                   << app_.getOPs().strOperatingMode(false) << " age "
-                                  << ledgerMaster_->getValidatedLedgerAge().count() << 's';
+                                  << ledgerMaster_->getValidatedLedgerAge().count()
+                                  << "s. Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
 
             clearPrior(lastRotated);
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
 
             JLOG(journal_.debug()) << "copying ledger " << validatedSeq;
             std::uint64_t nodeCount = 0;
@@ -360,8 +422,15 @@ SHAMapStoreImp::run()
                 continue;
             }
 
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
             // Only log if we completed without a "health" abort
             JLOG(journal_.debug())
                 << "copied ledger " << validatedSeq << " nodecount " << nodeCount;
@@ -385,8 +454,15 @@ SHAMapStoreImp::run()
 
             JLOG(journal_.debug()) << "freshening caches";
             freshenCaches();
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
             // Only log if we completed without a "health" abort
             JLOG(journal_.debug()) << validatedSeq << " freshened caches";
 
@@ -395,8 +471,15 @@ SHAMapStoreImp::run()
             JLOG(journal_.debug()) << validatedSeq << " new backend " << newBackend->getName();
 
             clearCaches(validatedSeq);
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
 
             lastRotated = validatedSeq;
 
@@ -412,7 +495,9 @@ SHAMapStoreImp::run()
                     clearCaches(validatedSeq);
                 });
 
-            JLOG(journal_.warn()) << "finished rotation " << validatedSeq;
+            JLOG(journal_.warn()) << "finished rotation. validatedSeq: " << validatedSeq
+                                  << ", lastRotated: " << lastRotated
+                                  << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
         }
     }
 }
@@ -426,10 +511,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 +522,7 @@ SHAMapStoreImp::dbPaths()
     }
     else
     {
-        boost::filesystem::create_directories(dbPath);
+        std::filesystem::create_directories(dbPath);
     }
 
     SavedState state = stateDb_.getState();
@@ -448,8 +533,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 +552,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 +575,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 +597,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 +613,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());
 
@@ -563,7 +645,7 @@ SHAMapStoreImp::clearSql(
         min = *m;
     }
 
-    if (min > lastRotated || healthWait() == HealthResult::Stopping)
+    if (min > lastRotated || healthWait() != HealthResult::KeepGoing)
         return;
     if (min == lastRotated)
     {
@@ -576,18 +658,19 @@ SHAMapStoreImp::clearSql(
                            << lastRotated;
     while (min < lastRotated)
     {
+        // The very first sleep is, arguably wasted, but clearSql is called multiple times for
+        // different tables, so the time is amortized among all the operations. This results in
+        // a backoff in between each set of tables, too.
+        std::this_thread::sleep_for(backOff_);
+        if (healthWait() != HealthResult::KeepGoing)
+            return;
+
         min = std::min(lastRotated, min + deleteBatch_);
         JLOG(journal_.trace()) << "Begin: Delete up to " << deleteBatch_
                                << " rows with LedgerSeq < " << min << " from: " << tableName;
         deleteBeforeSeq(min);
         JLOG(journal_.trace()) << "End: Delete up to " << deleteBatch_ << " rows with LedgerSeq < "
                                << min << " from: " << tableName;
-        if (healthWait() == HealthResult::Stopping)
-            return;
-        if (min < lastRotated)
-            std::this_thread::sleep_for(backOff_);
-        if (healthWait() == HealthResult::Stopping)
-            return;
     }
     JLOG(journal_.debug()) << "finished deleting from: " << tableName;
 }
@@ -620,7 +703,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
     JLOG(journal_.trace()) << "Begin: Clear internal ledgers up to " << lastRotated;
     ledgerMaster_->clearPriorLedgers(lastRotated);
     JLOG(journal_.trace()) << "End: Clear internal ledgers up to " << lastRotated;
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     auto& db = app_.getRelationalDatabase();
@@ -630,7 +713,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "Ledgers",
         [&db]() -> std::optional { return db.getMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     if (!app_.config().useTxTables())
@@ -641,7 +724,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "Transactions",
         [&db]() -> std::optional { return db.getTransactionsMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteTransactionsBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     clearSql(
@@ -649,30 +732,136 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "AccountTransactions",
         [&db]() -> std::optional { return db.getAccountTransactionsMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteAccountTransactionsBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 }
 
 SHAMapStoreImp::HealthResult
 SHAMapStoreImp::healthWait()
 {
-    auto age = ledgerMaster_->getValidatedLedgerAge();
-    OperatingMode mode = netOPs_->getOperatingMode();
-    std::unique_lock lock(mutex_);
-    while (!stop_ && (mode != OperatingMode::FULL || age > ageThreshold_))
-    {
-        lock.unlock();
-        JLOG(journal_.warn()) << "Waiting " << recoveryWaitTime_.count()
-                              << "s for node to stabilize. state: "
-                              << app_.getOPs().strOperatingMode(mode, false) << ". age "
-                              << age.count() << 's';
-        std::this_thread::sleep_for(recoveryWaitTime_);
+    // Gets the current status of the server from ledgerMaster_ and netOPs_. Must be called
+    // while mutex_ is unlocked to avoid unlikely, but possible, deadlock with ledgerMaster_'s
+    // completeLock_.
+    // Releasing the lock may mean that status will be slightly out of date when the lock is
+    // reacquired, but it's close enough. In a normal rotation, healthWait() is called frequently,
+    // so a false positive will be detected on the next call, and a false negative will be detected
+    // in the next loop iteration. Database rotation is important, but not timely, so an extra
+    // delay is fine.
+    auto readServerStatus = [this](
+                                LedgerIndex& index,
+                                bool& buildingIndex,
+                                std::chrono::seconds& age,
+                                OperatingMode& mode,
+                                std::size_t& numMissing,
+                                LedgerIndex const lowerBound,
+                                ScopeUnlock const&) {
+        index = ledgerMaster_->getValidLedgerIndex();
+        bool const haveIndex = ledgerMaster_->haveLedger(index);
         age = ledgerMaster_->getValidatedLedgerAge();
         mode = netOPs_->getOperatingMode();
-        lock.lock();
+
+        numMissing =
+            lowerBound == 0 ? 0 : ledgerMaster_->missingFromCompleteLedgerRange(lowerBound, index);
+
+        buildingIndex = (numMissing == 1 && !haveIndex);
+    };
+
+    // Tracked server status properties
+    LedgerIndex index = 0;
+    bool buildingIndex = false;
+    std::chrono::seconds age;
+    OperatingMode mode = OperatingMode::DISCONNECTED;
+    std::size_t numMissing = 0;
+
+    std::unique_lock lock(mutex_);
+
+    auto const waitTime = recoveryWaitTime_;
+    auto const ageThreshold = ageThreshold_;
+    {
+        auto const lowerBound = lastGoodValidatedLedger_;
+
+        ScopeUnlock const unlock(lock);
+
+        readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock);
+    }
+    // If index gets past this point without the health check succeeding, return
+    // HealthWait::Expired. This depends on index being initialized, so it must be after
+    // readServerStatus().
+    auto const lastSuccess = lastSuccessfulHealthCheck_ == 0 ? index : lastSuccessfulHealthCheck_;
+    auto const circuitBreaker = lastSuccess + maxWaitingLedgers_;
+
+    auto healthy = [&] {
+        // Special case: If the server is disconnected, it's not doing any ledger I/O, because
+        // it's focused on trying to get peers. A disconnected state is should never be caused by
+        // the activity of the server. It's usually limited to hardware or connectivity issues. Take
+        // advantage of that to run as much rotation I/O as possible before it comes back online.
+        if (mode == OperatingMode::DISCONNECTED)
+            return true;
+        if (age > ageThreshold)
+            return false;
+        if (numMissing > 0)
+            return false;
+        if (mode != OperatingMode::FULL)
+            return false;
+        return true;
+    };
+
+    while (!stop_ && !healthy() && index < circuitBreaker)
+    {
+        // Future-proofing: this value shouldn't change while we are sleeping, but grab it while we
+        // have the lock in case it does.
+        auto const lowerBound = lastGoodValidatedLedger_;
+
+        ScopeUnlock const unlock(lock);
+
+        auto const [stream, waitMs] = std::invoke(
+            [mode, age, ageThreshold, buildingIndex, waitTime, index, lastSuccess, this]
+            -> std::pair {
+                if (mode != OperatingMode::FULL || age > ageThreshold ||
+                    (index - lastSuccess > maxWaitingLedgers_ / 4))
+                    return {journal_.warn(), waitTime};
+                if (buildingIndex)
+                {
+                    // We expect this ledger to be built soon, so log at a lower level, and don't
+                    // wait as long.
+                    return {
+                        journal_.trace(),
+                        std::chrono::duration_cast(waitTime) / 10};
+                }
+                return {journal_.info(), waitTime};
+            });
+        JLOG(stream) << "Waiting " << waitMs.count() << "ms for node to stabilize. state: "
+                     << app_.getOPs().strOperatingMode(mode, false) << ". age " << age.count()
+                     << "s. Missing ledgers: " << numMissing << ". Expect: " << lowerBound << "-"
+                     << index << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
+        std::this_thread::sleep_for(waitMs);
+
+        [[maybe_unused]]
+        LedgerIndex const lastLedger = index;
+        readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock);
+        SOMETIMES(
+            index > lastLedger, "SHAMapStoreImp::healthWait : validated ledger index changed");
     }
 
-    return stop_ ? HealthResult::Stopping : HealthResult::KeepGoing;
+    auto const result = std::invoke([index, circuitBreaker, this]() -> HealthResult {
+        if (stop_)
+            return HealthResult::Stopping;
+        if (index < circuitBreaker)
+            return HealthResult::KeepGoing;
+        JLOG(journal_.error()) << "online_delete rotation has been unable to make progress for "
+                               << maxWaitingLedgers_ << " ledgers. "
+                               << "validated ledger index: " << index
+                               << ", last successful health check index: "
+                               << lastSuccessfulHealthCheck_
+                               << ", circuit breaker index: " << circuitBreaker;
+        return HealthResult::Expired;
+    });
+
+    XRPL_ASSERT(lock.owns_lock(), "SHAMapStoreImp::healthWait : lock held");
+    if (result == HealthResult::KeepGoing)
+        lastSuccessfulHealthCheck_ = index;
+
+    return result;
 }
 
 void
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h
index 8a1b7504b9..c1e9199665 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.h
+++ b/src/xrpld/app/misc/SHAMapStoreImp.h
@@ -88,6 +88,13 @@ private:
     std::thread thread_;
     bool stop_ = false;
     bool healthy_ = true;
+    // Used to prevent ledger gaps from forming during online deletion. Keeps
+    // track of the last validated ledger that was processed without gaps. There
+    // are no guarantees about gaps while online delete is not running. For
+    // that, use advisory_delete and check for gaps externally.
+    LedgerIndex lastGoodValidatedLedger_ = 0;
+    // Used to prevent the circuit breaker from tripping too quickly.
+    LedgerIndex lastSuccessfulHealthCheck_ = 0;
     mutable std::condition_variable cond_;
     mutable std::condition_variable rendezvous_;
     mutable std::mutex mutex_;
@@ -102,12 +109,18 @@ private:
     std::chrono::milliseconds backOff_{100};
     std::chrono::seconds ageThreshold_{60};
     /**
-     * If  the node is out of sync during an online_delete healthWait()
-     * call, sleep the thread for this time, and continue checking until
-     * recovery.
+     * If the node is out of sync, or any recent ledgers are not
+     * available during an online_delete healthWait() call, sleep
+     * the thread for this time, and continue checking until recovery.
      * See also: "recovery_wait_seconds" in xrpld-example.cfg
      */
-    std::chrono::seconds recoveryWaitTime_{5};
+    std::chrono::seconds recoveryWaitTime_{2};
+    /**
+     * If the rotation stays "unhealthy" for a very long time, the process is aborted, and tried
+     * again later. This value represents the number of ledgers that must be validated without
+     * making rotation progress before the process is aborted.
+     */
+    std::uint32_t maxWaitingLedgers_ = deleteBatch_;
 
     // these do not exist upon SHAMapStore creation, but do exist
     // as of run() or before
@@ -163,8 +176,9 @@ public:
     void
     onLedgerClosed(std::shared_ptr const& ledger) override;
 
-    void
-    rendezvous() const override;
+    [[nodiscard]]
+    bool
+    rendezvous(std::optional const& timeout = {}) const override;
     int
     fdRequired() const override;
 
@@ -192,7 +206,7 @@ private:
         for (auto const& key : cache.getKeys())
         {
             dbRotating_->fetchNodeObject(key, 0, node_store::FetchType::Synchronous, true);
-            if (!(++check % checkHealthInterval_) && healthWait() == HealthResult::Stopping)
+            if (!(++check % checkHealthInterval_) && healthWait() != HealthResult::KeepGoing)
                 return true;
         }
 
@@ -220,11 +234,11 @@ private:
     /**
      * This is a health check for online deletion that waits until xrpld is
      * stable before returning. It returns an indication of whether the server
-     * is stopping.
+     * is stopping, or if this attempt should be abandoned.
      *
      * @return Whether the server is stopping.
      */
-    enum class HealthResult { Stopping, KeepGoing };
+    enum class HealthResult { Stopping, Expired, KeepGoing };
     [[nodiscard]] HealthResult
     healthWait();
 
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 0b6e7e6f74..852a9d9f4b 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 
@@ -112,17 +111,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 25dce385e4..d8002f483c 100644
--- a/src/xrpld/core/detail/Config.cpp
+++ b/src/xrpld/core/detail/Config.cpp
@@ -22,21 +22,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 
@@ -46,6 +44,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -186,7 +185,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] == '#')
         {
@@ -314,13 +313,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;
     }
@@ -331,13 +330,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
@@ -364,10 +363,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;
             }
 
@@ -375,7 +374,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);
@@ -388,7 +387,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_)
         {
@@ -398,13 +397,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_);
@@ -456,7 +455,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)
@@ -509,8 +508,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());
         }
     }
 
@@ -1012,7 +1011,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_))
         {
@@ -1027,7 +1026,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 +
@@ -1036,8 +1035,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 +
@@ -1050,20 +1049,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)
             {
@@ -1196,7 +1195,7 @@ Config::loadFromString(std::string const& fileContents)
     }
 }
 
-boost::filesystem::path
+std::filesystem::path
 Config::getDebugLogFile() const
 {
     auto logFile = debugLogfile_;
@@ -1205,17 +1204,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.
@@ -1323,8 +1322,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
             {
@@ -1345,7 +1343,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
             {
@@ -1366,7 +1364,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..c56ea5797f 100644
--- a/src/xrpld/overlay/detail/PeerImp.cpp
+++ b/src/xrpld/overlay/detail/PeerImp.cpp
@@ -44,6 +44,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -542,10 +543,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 +882,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 +2419,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())
@@ -3123,8 +3088,9 @@ PeerImp::checkTransaction(
         if (checkSignature)
         {
             // Check the signature before handing off to the job queue.
-            if (auto [valid, validReason] = checkValidity(
-                    app_.getHashRouter(), *stx, app_.getLedgerMaster().getValidatedRules());
+            auto const& validatedRules = app_.getLedgerMaster().getValidatedRules();
+            if (auto [valid, validReason] =
+                    checkValidity(app_.getHashRouter(), *stx, validatedRules);
                 valid != Validity::Valid)
             {
                 if (!validReason.empty())
@@ -3132,9 +3098,20 @@ PeerImp::checkTransaction(
                     JLOG(pJournal_.debug()) << "Exception checking transaction: " << validReason;
                 }
 
-                // Probably not necessary to set HashRouterFlags::BAD, but
-                // doesn't hurt.
-                app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
+                // For a role-signature transaction, only cache BAD once
+                // fixCleanup3_4_0 is enabled on this node: the SigBad verdict
+                // then covers the post-fix prefix and cannot flip back.
+                // Before the amendment activates, checkValidity's own
+                // era-scoped cache handles the repeat lookups; setting BAD
+                // would block a correctly new-prefix-signed transaction until
+                // the router entry ages out. Remove the guard together with
+                // the amendment.
+                if (validatedRules.enabled(fixCleanup3_4_0) ||
+                    (!stx->isFieldPresent(sfSponsorSignature) &&
+                     !stx->isFieldPresent(sfCounterpartySignature)))
+                {
+                    app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
+                }
                 charge(resource::kFeeInvalidSignature, "check transaction signature failure");
                 return;
             }
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/AGENTS.md b/src/xrpld/rpc/AGENTS.md
new file mode 100644
index 0000000000..14fdd7a03e
--- /dev/null
+++ b/src/xrpld/rpc/AGENTS.md
@@ -0,0 +1,5 @@
+# AGENTS.md — rpc
+
+See the repo-level [AGENTS.md](../../../AGENTS.md) for general build/test/style guidance.
+
+Any change to a public RPC method's behavior (new/changed/removed fields, parameters, or error conditions) needs a corresponding entry in [`API-CHANGELOG.md`](../../../API-CHANGELOG.md), under the `## Unreleased` section (`### Additions`, `### Deprecations`, etc. as appropriate).
diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h
index 16f7ea8e43..1912b0512e 100644
--- a/src/xrpld/rpc/BookChanges.h
+++ b/src/xrpld/rpc/BookChanges.h
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -18,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -50,6 +52,36 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
             std::optional>>  // optional: domain id
         tally;
 
+    // Accumulating volume can exceed what the asset can represent, and the two
+    // types fail differently: STAmount's IOU addition throws, while its MPT
+    // addition is a raw int64 add that wraps past kMaxMpTokenAmount to a
+    // negative amount. Reject both so that one extreme crossing cannot poison
+    // this ledger's report, which is otherwise permanent -- the ledger is
+    // immutable and the computation deterministic.
+    auto const checkedAdd = [](STAmount& acc, STAmount const& delta) {
+        return acc.asset().visit(
+            [&](Issue const&) {
+                try
+                {
+                    acc += delta;
+                }
+                catch (std::overflow_error const&)
+                {
+                    return false;
+                }
+                return true;
+            },
+            [&](MPTIssue const&) {
+                // Both volumes are non-negative by the time they reach the
+                // tally, so this cannot underflow.
+                auto const room = static_cast(kMaxMpTokenAmount) - acc.mpt().value();
+                if (delta.mpt().value() > room)
+                    return false;
+                acc += delta;
+                return true;
+            });
+    };
+
     for (auto& tx : lpAccepted->txs)
     {
         if (!tx.first || !tx.second || !tx.first->isFieldPresent(sfTransactionType))
@@ -123,7 +155,16 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
             if (second == beast::kZero)
                 continue;
 
-            STAmount const rate = divide(first, second, noIssue());
+            std::optional maybeRate;
+            try
+            {
+                maybeRate = divide(first, second, noIssue());
+            }
+            catch (std::overflow_error const&)
+            {
+                continue;
+            }
+            STAmount const rate = *maybeRate;
 
             if (first < beast::kZero)
                 first = -first;
@@ -161,8 +202,15 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
                 // increment volume
                 auto& entry = tally[key];
 
-                std::get<0>(entry) += first;   // side A vol
-                std::get<1>(entry) += second;  // side B vol
+                // Commit both sides or neither, so an overflow on the second
+                // cannot leave the entry half-updated. Skipping the crossing
+                // matches how an unrepresentable rate is handled above.
+                STAmount volA = std::get<0>(entry);
+                STAmount volB = std::get<1>(entry);
+                if (!checkedAdd(volA, first) || !checkedAdd(volB, second))
+                    continue;
+                std::get<0>(entry) = volA;  // side A vol
+                std::get<1>(entry) = volB;  // side B vol
 
                 if (std::get<2>(entry) < rate)  // high
                     std::get<2>(entry) = rate;
diff --git a/src/xrpld/rpc/CLAUDE.md b/src/xrpld/rpc/CLAUDE.md
new file mode 120000
index 0000000000..47dc3e3d86
--- /dev/null
+++ b/src/xrpld/rpc/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/xrpld/rpc/detail/AccountAssets.cpp b/src/xrpld/rpc/detail/AccountAssets.cpp
index 67b9174fe3..0e71836b74 100644
--- a/src/xrpld/rpc/detail/AccountAssets.cpp
+++ b/src/xrpld/rpc/detail/AccountAssets.cpp
@@ -49,7 +49,7 @@ accountSourceAssets(
     {
         for (auto const& rspEntry : *mpts)
         {
-            if (!rspEntry.isZeroBalance() && !rspEntry.isMaxedOut())
+            if (rspEntry.canSend(account))
                 assets.insert(rspEntry.getMptID());
         }
     }
@@ -86,8 +86,10 @@ accountDestAssets(
     {
         for (auto const& rspEntry : *mpts)
         {
-            if (rspEntry.isZeroBalance() && !rspEntry.isMaxedOut())
-                assets.insert(rspEntry.getMptID());
+            // Any cached MPT entry means this account already has an issuance
+            // or MPToken object. A maxed-out issuance does not prevent
+            // receiving existing MPT from another holder.
+            assets.insert(rspEntry.getMptID());
         }
     }
 
diff --git a/src/xrpld/rpc/detail/MPT.h b/src/xrpld/rpc/detail/MPT.h
index 93c8517539..68054b2d0b 100644
--- a/src/xrpld/rpc/detail/MPT.h
+++ b/src/xrpld/rpc/detail/MPT.h
@@ -1,5 +1,7 @@
 #pragma once
 
+#include 
+#include 
 #include 
 
 namespace xrpl {
@@ -31,14 +33,11 @@ public:
         return mptID_;
     }
     [[nodiscard]] bool
-    isZeroBalance() const
+    canSend(AccountID const& account) const
     {
-        return zeroBalance_;
-    }
-    [[nodiscard]] bool
-    isMaxedOut() const
-    {
-        return maxedOut_;
+        // A maxed-out issuance only prevents the issuer from creating more
+        // MPT. Holders can still send existing balances.
+        return account == getMPTIssuer(mptID_) ? !maxedOut_ : !zeroBalance_;
     }
 };
 
diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp
index fb132199bc..0a01031ae2 100644
--- a/src/xrpld/rpc/detail/PathRequest.cpp
+++ b/src/xrpld/rpc/detail/PathRequest.cpp
@@ -416,20 +416,22 @@ PathRequest::parseJson(json::Value const& jvParams)
                 // If the assets don't match, ignore the source asset.
                 if (srcPathAsset == saSendMax_->asset())
                 {
-                    // If neither is the source and they are not equal, then the
-                    // source issuer is illegal.
-                    if (srcIssuerID != *raSrcAccount_ &&
-                        saSendMax_->getIssuer() != *raSrcAccount_ &&
-                        srcIssuerID != saSendMax_->getIssuer())
-                    {
-                        jvStatus_ = rpcError(RpcSrcIsrMalformed);
-                        return PFR_PJ_INVALID;
-                    }
-
-                    // If both are the source, use the source.
-                    // Otherwise, use the one that's not the source.
-                    srcPathAsset.visit(
+                    auto const status = srcPathAsset.visit(
                         [&](Currency const& currency) {
+                            // If neither is the source and they are not equal,
+                            // then the source issuer is illegal. srcIssuerID
+                            // comes from the optional IOU source_currencies
+                            // issuer field, so this reconciliation is IOU-only.
+                            if (srcIssuerID != *raSrcAccount_ &&
+                                saSendMax_->getIssuer() != *raSrcAccount_ &&
+                                srcIssuerID != saSendMax_->getIssuer())
+                            {
+                                jvStatus_ = rpcError(RpcSrcIsrMalformed);
+                                return PFR_PJ_INVALID;
+                            }
+
+                            // If both are the source, use the source.
+                            // Otherwise, use the one that's not the source.
                             if (srcIssuerID != *raSrcAccount_)
                             {
                                 sciSourceAssets_.insert(Issue{currency, srcIssuerID});
@@ -438,11 +440,18 @@ PathRequest::parseJson(json::Value const& jvParams)
                             {
                                 sciSourceAssets_.insert(Issue{currency, saSendMax_->getIssuer()});
                             }
+                            else
                             {
                                 sciSourceAssets_.insert(Issue{currency, *raSrcAccount_});
                             }
+                            return PFR_PJ_NOCHANGE;
                         },
-                        [&](MPTID const& mpt) { sciSourceAssets_.insert(mpt); });
+                        [&](MPTID const& mpt) {
+                            sciSourceAssets_.insert(mpt);
+                            return PFR_PJ_NOCHANGE;
+                        });
+                    if (status == PFR_PJ_INVALID)
+                        return status;
                 }
             }
             else
diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp
index 642b5c4253..1f530a1165 100644
--- a/src/xrpld/rpc/detail/Pathfinder.cpp
+++ b/src/xrpld/rpc/detail/Pathfinder.cpp
@@ -224,7 +224,7 @@ Pathfinder::Pathfinder(
     , dstAmount_(saDstAmount)
     , srcPathAsset_(uSrcPathAsset)
     , srcIssuer_(uSrcIssuer)
-    , srcAmount_(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount))
+    , srcAmount_(srcAmount.value_or(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount)))
     , convertAll_(convertAllCheck(dstAmount_))
     , domain_(domain)
     , ledger_(cache->getLedger())
@@ -815,8 +815,8 @@ Pathfinder::getPathsOut(
                 {
                     for (auto const& mpt : *mpts)
                     {
-                        if (pathAsset.get() != mpt.getMptID() || mpt.isZeroBalance() ||
-                            mpt.isMaxedOut() || bAuthRequired)
+                        if (pathAsset.get() != mpt.getMptID() || !mpt.canSend(account) ||
+                            bAuthRequired)
                             continue;
                         if (isDstAsset && dstAccount == getMPTIssuer(mpt))
                         {
@@ -1079,7 +1079,10 @@ Pathfinder::addLink(
                             }
                             if constexpr (kIsMpt)
                             {
-                                return asset.isZeroBalance() || asset.isMaxedOut() ||
+                                // `asset` came from uEndAccount's cached MPTs.
+                                // `acct` is the next issuer hop, not the
+                                // account whose balance is being tested.
+                                return !asset.canSend(uEndAccount) ||
                                     requireAuth(*ledger_, MPTIssue{asset}, acct);
                             }
                         };
diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp
index 619dd44638..a822df2f05 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCHelpers.cpp
@@ -42,6 +42,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -429,7 +430,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;
     }
 
@@ -440,7 +441,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;
         }
 
@@ -450,7 +451,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;
@@ -464,7 +465,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 9fc3465047..b4b2933789 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
@@ -9,7 +9,11 @@
 #include 
 #include 
 
+#include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -28,6 +32,7 @@
 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::rpc {
@@ -331,6 +336,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
@@ -520,10 +532,10 @@ injectSLE(json::Value& jv, SLE const& sle)
             auto const& hash = sle.getFieldH128(sfEmailHash);
             Blob const b(hash.begin(), hash.end());
             std::string md5 = strHex(makeSlice(b));
-            boost::to_lower(md5);
+            md5 = toLower(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);
+            jv[jss::urlgravatar] = std::format("https://www.gravatar.com/avatar/{}", md5);
         }
     }
     else
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/detail/SyntheticFields.cpp b/src/xrpld/rpc/detail/SyntheticFields.cpp
new file mode 100644
index 0000000000..9acf3abb36
--- /dev/null
+++ b/src/xrpld/rpc/detail/SyntheticFields.cpp
@@ -0,0 +1,40 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::rpc {
+
+void
+insertAllSyntheticInJson(
+    json::Value& metadata,
+    ReadView const& ledger,
+    std::shared_ptr const& transaction,
+    TxMeta const& transactionMeta)
+{
+    insertDeliveredAmount(metadata, ledger, transaction, transactionMeta);
+    insertNFTokenID(metadata, transaction, transactionMeta);
+    insertNFTokenOfferID(metadata, transaction, transactionMeta);
+    insertMPTokenIssuanceID(metadata, transaction, transactionMeta);
+}
+
+void
+insertAllSyntheticInJson(
+    json::Value& metadata,
+    JsonContext const& context,
+    std::shared_ptr const& transaction,
+    TxMeta const& transactionMeta)
+{
+    insertDeliveredAmount(metadata, context, transaction, transactionMeta);
+    insertNFTokenID(metadata, transaction, transactionMeta);
+    insertNFTokenOfferID(metadata, transaction, transactionMeta);
+    insertMPTokenIssuanceID(metadata, transaction, transactionMeta);
+}
+
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/SyntheticFields.h b/src/xrpld/rpc/detail/SyntheticFields.h
new file mode 100644
index 0000000000..6ece4bbcd5
--- /dev/null
+++ b/src/xrpld/rpc/detail/SyntheticFields.h
@@ -0,0 +1,39 @@
+#pragma once
+
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl {
+
+class ReadView;
+
+namespace rpc {
+
+struct JsonContext;
+
+/**
+ * Adds all synthetic fields to transaction metadata JSON.
+ * This includes delivered amount, NFT synthetic fields, and MPToken issuance
+ * ID.
+ */
+/** @{ */
+void
+insertAllSyntheticInJson(
+    json::Value& metadata,
+    ReadView const&,
+    std::shared_ptr const&,
+    TxMeta const&);
+
+void
+insertAllSyntheticInJson(
+    json::Value& metadata,
+    JsonContext const&,
+    std::shared_ptr const&,
+    TxMeta const&);
+/** @} */
+
+}  // namespace rpc
+}  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp
index 9c97577b27..3e9f62214e 100644
--- a/src/xrpld/rpc/detail/TransactionSign.cpp
+++ b/src/xrpld/rpc/detail/TransactionSign.cpp
@@ -460,7 +460,8 @@ transactionPreProcessImpl(
     Role role,
     SigningForParams& signingArgs,
     std::chrono::seconds validatedLedgerAge,
-    Application& app)
+    Application& app,
+    Rules const& rules)
 {
     auto j = app.getJournal("RPCHandler");
 
@@ -482,13 +483,16 @@ transactionPreProcessImpl(
     }();
 
     // Make sure the signature target field is valid, if specified, and save the
-    // template for use later
+    // template for use later. Only a field that holds a transaction signature
+    // is a valid target; the signature is bound to that field's role.
     auto const signatureTemplate = signatureTarget
         ? InnerObjectFormats::getInstance().findSOTemplateBySField(*signatureTarget)
         : nullptr;
+    auto const signatureRoleOpt =
+        signatureTarget ? signatureRole(signatureTarget->get()) : SignatureRole::Transaction;
     if (signatureTarget)
     {
-        if (signatureTemplate == nullptr)
+        if (!signatureRoleOpt || signatureTemplate == nullptr)
         {  // Invalid target field
             return rpc::makeError(RpcInvalidParams, signatureTarget->get().getName());
         }
@@ -687,7 +691,8 @@ transactionPreProcessImpl(
     // If multisign then return multiSignature, else set TxnSignature field.
     if (signingArgs.isMultiSigning())
     {
-        Serializer const s = buildMultiSigningData(*stTx, signingArgs.getSigner());
+        Serializer const s = buildMultiSigningData(
+            *stTx, signingArgs.getSigner(), signingPrefix(*signatureRoleOpt, true, rules));
 
         auto multisig = xrpl::sign(pk, sk, s.slice());
 
@@ -695,7 +700,7 @@ transactionPreProcessImpl(
     }
     else if (signingArgs.isSingleSigning())
     {
-        stTx->sign(pk, sk, signatureTarget);
+        stTx->sign(pk, sk, *signatureRoleOpt, rules);
     }
 
     return TransactionPreProcessResult{std::move(stTx)};
@@ -1007,18 +1012,20 @@ transactionSign(
 {
     using namespace detail;
 
+    // Sign and verify against the same ruleset: a ledger close in between
+    // could change the signing prefix of an alternate signature field.
+    std::shared_ptr const ledger = app.getOpenLedger().current();
     auto j = app.getJournal("RPCHandler");
     JLOG(j.debug()) << "transactionSign: " << jvRequest;
 
     // Add and amend fields based on the transaction type.
     SigningForParams signForParams;
-    TransactionPreProcessResult const preprocResult =
-        transactionPreProcessImpl(jvRequest, role, signForParams, validatedLedgerAge, app);
+    TransactionPreProcessResult const preprocResult = transactionPreProcessImpl(
+        jvRequest, role, signForParams, validatedLedgerAge, app, ledger->rules());
 
     if (!preprocResult.second)
         return preprocResult.first;
 
-    std::shared_ptr const ledger = app.getOpenLedger().current();
     // Make sure the STTx makes a legitimate Transaction.
     std::pair const txn =
         transactionConstructImpl(preprocResult.second, ledger->rules(), app);
@@ -1050,8 +1057,8 @@ transactionSubmit(
 
     // Add and amend fields based on the transaction type.
     SigningForParams signForParams;
-    TransactionPreProcessResult const preprocResult =
-        transactionPreProcessImpl(jvRequest, role, signForParams, validatedLedgerAge, app);
+    TransactionPreProcessResult const preprocResult = transactionPreProcessImpl(
+        jvRequest, role, signForParams, validatedLedgerAge, app, ledger->rules());
 
     if (!preprocResult.second)
         return preprocResult.first;
@@ -1218,8 +1225,8 @@ transactionSignFor(
     // Add and amend fields based on the transaction type.
     SigningForParams signForParams(*signerAccountID);
 
-    TransactionPreProcessResult const preprocResult =
-        transactionPreProcessImpl(jvRequest, role, signForParams, validatedLedgerAge, app);
+    TransactionPreProcessResult const preprocResult = transactionPreProcessImpl(
+        jvRequest, role, signForParams, validatedLedgerAge, app, ledger->rules());
 
     if (!preprocResult.second)
         return preprocResult.first;
diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp
index c216192ab3..0aa5334bd2 100644
--- a/src/xrpld/rpc/handlers/VaultInfo.cpp
+++ b/src/xrpld/rpc/handlers/VaultInfo.cpp
@@ -26,36 +26,48 @@ parseVault(json::Value const& params, json::Value& jvResult)
     uint256 uNodeIndex = beast::kZero;
     if (hasVaultId && !hasOwner && !hasSeq)
     {
-        if (!uNodeIndex.parseHex(params[jss::vault_id].asString()))
+        // asString() throws on an object or an array, so the type comes first.
+        auto const& vaultId = params[jss::vault_id];
+        if (!vaultId.isString() || !uNodeIndex.parseHex(vaultId.asString()))
         {
-            rpc::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcInvalidParams, rpc::expectedFieldMessage(jss::vault_id, "hex string"), jvResult);
             return std::nullopt;
         }
         // else uNodeIndex holds the value we need
     }
     else if (!hasVaultId && hasOwner && hasSeq)
     {
-        auto const id = parseBase58(params[jss::owner].asString());
+        auto const& owner = params[jss::owner];
+        auto const id = owner.isString() ? parseBase58(owner.asString())
+                                         : std::optional{};
         if (!id)
         {
-            rpc::injectError(RpcActMalformed, jvResult);
-            return std::nullopt;
-        }
-        if (!(params[jss::seq].isInt() || params[jss::seq].isUInt()) ||
-            params[jss::seq].asDouble() <= 0.0 ||
-            params[jss::seq].asDouble() > double(json::Value::kMaxUInt))
-        {
-            rpc::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcActMalformed, rpc::expectedFieldMessage(jss::owner, "AccountID"), jvResult);
             return std::nullopt;
         }
 
-        auto const seq = SeqProxy::rawSequence(params[jss::seq].asUInt());
+        // Int and UInt are both 32 bits wide, so the type check is the only upper bound needed.
+        auto const& seqField = params[jss::seq];
+        if (!(seqField.isInt() || seqField.isUInt()) || seqField.asDouble() <= 0.0)
+        {
+            rpc::injectError(
+                RpcInvalidParams,
+                rpc::expectedFieldMessage(jss::seq, "a positive 32-bit integer"),
+                jvResult);
+            return std::nullopt;
+        }
+
+        auto const seq = SeqProxy::rawSequence(seqField.asUInt());
         uNodeIndex = keylet::vault(*id, seq).key;
     }
     else
     {
-        // Invalid combination of fields vault_id/owner/seq
-        rpc::injectError(RpcInvalidParams, jvResult);
+        rpc::injectError(
+            RpcInvalidParams,
+            "Must specify either 'vault_id' or both 'owner' and 'seq'.",
+            jvResult);
         return std::nullopt;
     }
 
@@ -71,20 +83,25 @@ doVaultInfo(rpc::JsonContext& context)
     if (!lpLedger)
         return jvResult;
 
-    auto const uNodeIndex = parseVault(context.params, jvResult).value_or(beast::kZero);
-    if (uNodeIndex == beast::kZero)
+    // No key means the request could not be turned into one, and parseVault has already said why.
+    auto const uNodeIndex = parseVault(context.params, jvResult);
+    if (!uNodeIndex)
+        return jvResult;
+
+    // A zero key names an entry that cannot exist, and the ledger refuses to be asked for one.
+    if (*uNodeIndex == beast::kZero)
     {
-        jvResult[jss::error] = "malformedRequest";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
-    auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex));
+    auto const sleVault = lpLedger->read(keylet::vault(*uNodeIndex));
     auto const sleIssuance = sleVault == nullptr  //
         ? nullptr
         : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
     if (!sleVault || !sleIssuance)
     {
-        jvResult[jss::error] = "entryNotFound";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
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 02778abad5..7f48e8f285 100644
--- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
@@ -5,6 +5,8 @@
 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -22,15 +24,14 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -87,29 +88,37 @@ doAccountInfo(rpc::JsonContext& context)
     }
     auto const accountID{id.value()};
 
-    static constexpr std::array, 9> kLsFlags{
-        {{"defaultRipple", lsfDefaultRipple},
-         {"depositAuth", lsfDepositAuth},
-         {"disableMasterKey", lsfDisableMaster},
-         {"disallowIncomingXRP", lsfDisallowXRP},
-         {"globalFreeze", lsfGlobalFreeze},
-         {"noFreeze", lsfNoFreeze},
-         {"passwordSpent", lsfPasswordSpent},
-         {"requireAuthorization", lsfRequireAuth},
-         {"requireDestinationTag", lsfRequireDestTag}}};
-
-    static constexpr std::array, 4>
-        kDisallowIncomingFlags{
-            {{"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
+    // Flags that are always reported.
+    static constexpr auto kAccountRootFlags =
+        std::to_array>(
+            {{"allowTrustLineClawback", lsfAllowTrustLineClawback},
+             {"defaultRipple", lsfDefaultRipple},
+             {"depositAuth", lsfDepositAuth},
+             {"disableMasterKey", lsfDisableMaster},
              {"disallowIncomingCheck", lsfDisallowIncomingCheck},
+             {"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
              {"disallowIncomingPayChan", lsfDisallowIncomingPayChan},
-             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}}};
+             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline},
+             {"disallowIncomingXRP", lsfDisallowXRP},
+             {"globalFreeze", lsfGlobalFreeze},
+             {"noFreeze", lsfNoFreeze},
+             {"passwordSpent", lsfPasswordSpent},
+             {"requireAuthorization", lsfRequireAuth},
+             {"requireDestinationTag", lsfRequireDestTag}});
 
-    static constexpr std::pair kAllowTrustLineClawbackFlag{
-        "allowTrustLineClawback", lsfAllowTrustLineClawback};
+    // Flags that are only reported when their amendment is enabled. This can't be `constexpr`,
+    // since the amendment IDs are computed at runtime.
+    static auto const kAmendmentGatedFlags =
+        std::to_array>(
+            {{"allowTrustLineLocking", lsfAllowTrustLineLocking, featureTokenEscrow}});
 
-    static constexpr std::pair kAllowTrustLineLockingFlag{
-        "allowTrustLineLocking", lsfAllowTrustLineLocking};
+    // Every `AccountRoot` flag must be reported by `account_info`, so if a new flag is added, it
+    // needs to be added to one of the arrays above. This can't be a `static_assert` because
+    // `getAccountRootFlags()` builds its map at runtime.
+    XRPL_ASSERT_PARTS(
+        kAccountRootFlags.size() + kAmendmentGatedFlags.size() == getAccountRootFlags().size(),
+        "xrpl::doAccountInfo",
+        "number of account flags");
 
     auto const sleAccepted = ledger->read(keylet::account(accountID));
     if (sleAccepted)
@@ -129,19 +138,13 @@ doAccountInfo(rpc::JsonContext& context)
         result[jss::account_data] = jvAccepted;
 
         json::Value acctFlags{json::ValueType::Object};
-        for (auto const& lsf : kLsFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
+        for (auto const& [name, flag] : kAccountRootFlags)
+            acctFlags[name.data()] = sleAccepted->isFlag(flag);
 
-        for (auto const& lsf : kDisallowIncomingFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
-
-        acctFlags[kAllowTrustLineClawbackFlag.first.data()] =
-            sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second);
-
-        if (ledger->rules().enabled(featureTokenEscrow))
+        for (auto const& [name, flag, amendment] : kAmendmentGatedFlags)
         {
-            acctFlags[kAllowTrustLineLockingFlag.first.data()] =
-                sleAccepted->isFlag(kAllowTrustLineLockingFlag.second);
+            if (ledger->rules().enabled(amendment))
+                acctFlags[name.data()] = sleAccepted->isFlag(flag);
         }
 
         result[jss::account_flags] = std::move(acctFlags);
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/AccountTx.cpp b/src/xrpld/rpc/handlers/account/AccountTx.cpp
index 7b0c34e048..5385776b36 100644
--- a/src/xrpld/rpc/handlers/account/AccountTx.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountTx.cpp
@@ -4,12 +4,11 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -22,7 +21,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -378,9 +376,7 @@ populateJsonResponse(
                     if (txnMeta)
                     {
                         jvObj[jss::meta] = txnMeta->getJson(JsonOptions::Values::IncludeDate);
-                        insertDeliveredAmount(jvObj[jss::meta], context, txn, *txnMeta);
-                        rpc::insertNFTSyntheticInJson(jvObj, sttx, *txnMeta);
-                        rpc::insertMPTokenIssuanceID(jvObj[jss::meta], sttx, *txnMeta);
+                        rpc::insertAllSyntheticInJson(jvObj[jss::meta], context, sttx, *txnMeta);
                     }
                     else
                     {
diff --git a/src/xrpld/rpc/handlers/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 c730f64494..47cbd86afc 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"))
@@ -108,7 +107,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;
         }
diff --git a/src/xrpld/rpc/handlers/transaction/Simulate.cpp b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
index 8441add08b..61cdddafde 100644
--- a/src/xrpld/rpc/handlers/transaction/Simulate.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
@@ -4,7 +4,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 
 #include 
@@ -20,7 +20,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -290,12 +289,8 @@ simulateTxn(rpc::JsonContext& context, std::shared_ptr transaction)
         else
         {
             jvResult[jss::meta] = result.metadata->getJson(JsonOptions::Values::None);
-            rpc::insertDeliveredAmount(
+            rpc::insertAllSyntheticInJson(
                 jvResult[jss::meta], view, transaction->getSTransaction(), *result.metadata);
-            rpc::insertNFTSyntheticInJson(
-                jvResult, transaction->getSTransaction(), *result.metadata);
-            rpc::insertMPTokenIssuanceID(
-                jvResult[jss::meta], transaction->getSTransaction(), *result.metadata);
         }
     }
 
diff --git a/src/xrpld/rpc/handlers/transaction/Tx.cpp b/src/xrpld/rpc/handlers/transaction/Tx.cpp
index ee7110bf6b..cebe427af8 100644
--- a/src/xrpld/rpc/handlers/transaction/Tx.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Tx.cpp
@@ -5,8 +5,9 @@
 #include 
 #include 
 #include 
-#include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
@@ -18,7 +19,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -253,9 +253,7 @@ populateJsonResponse(
             if (meta)
             {
                 response[jss::meta] = meta->getJson(JsonOptions::Values::None);
-                insertDeliveredAmount(response[jss::meta], context, result.txn, *meta);
-                rpc::insertNFTSyntheticInJson(response, sttx, *meta);
-                rpc::insertMPTokenIssuanceID(response[jss::meta], sttx, *meta);
+                rpc::insertAllSyntheticInJson(response[jss::meta], context, sttx, *meta);
             }
         }
         response[jss::validated] = result.validated;