diff --git a/.clang-tidy b/.clang-tidy index 68fc9e75fc..02e90d9148 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -75,6 +75,8 @@ Checks: "-*, # readability-static-accessed-through-instance, # this check is probably unnecessary. It makes the code less readable # --- +FormatStyle: file + CheckOptions: bugprone-unsafe-functions.ReportMoreUnsafeFunctions: true bugprone-unused-return-value.CheckedReturnTypes: ::std::error_code;::std::error_condition;::std::errc @@ -83,6 +85,8 @@ CheckOptions: readability-braces-around-statements.ShortStatementLines: 2 readability-identifier-naming.MacroDefinitionCase: UPPER_CASE + readability-identifier-naming.NamespaceCase: lower_case + readability-identifier-naming.InlineNamespaceCase: lower_case readability-identifier-naming.ClassCase: CamelCase readability-identifier-naming.StructCase: CamelCase readability-identifier-naming.UnionCase: CamelCase 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 9cd8417362..48afdc2b7a 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,6 +7,7 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true @@ -63,11 +64,13 @@ words: - blindings - bookdir - Bougalis + - bthomee - Britto - Btrfs - Buildx - canonicality - canonicalised + - cctools - changespq - checkme - choco @@ -109,6 +112,7 @@ words: - disablerepo - distro - doxyfile + - dsymutil - dxrpl - elgamal - enabled @@ -132,11 +136,13 @@ words: - godexsoft - gpgcheck - gpgkey + - Hinnant - hotwallet - hwaddress - hwrap - ifndef - inequation + - Injectivity - insuf - insuff - invasively @@ -165,6 +171,8 @@ words: - llection - LOCALGOOD - logwstream + - Lombrozo + - lresolv - lseq - lsmf - ltype @@ -174,7 +182,6 @@ words: - MPTAMM - MPTDEX - Merkle - - Metafuncton - misprediction - missingok - mptbalance @@ -202,6 +209,7 @@ words: - nftokens - nftpage - nikb + - Nikolaos - nixfmt - nixos - nixpkgs @@ -218,6 +226,7 @@ words: - Nyffenegger - onlatest - ostr + - otool - oxalica - pargs - partitioner @@ -247,11 +256,15 @@ words: - Raphson - rcflags - replayer + - repodata + - repomd - rerandomize - rerandomization - rerandomized - rerandomizes - rerere + - retargeted + - retargets - retriable - RIPD - ripdtop @@ -287,6 +300,7 @@ words: - sles - soci - socidb + - Sonatype - sponsee - sponsees - SRPMS @@ -306,6 +320,7 @@ words: - summands - superpeer - superpeers + - Swatinem - takergets - takerpays - ters @@ -361,12 +376,16 @@ words: - wthread - xbridge - xchain + - xcrun - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld - xrplf - xxhash - xxhasher + - zstdio - CGNAT + - ungated diff --git a/.envrc b/.envrc index 3550a30f2d..a3f6be96ea 100644 --- a/.envrc +++ b/.envrc @@ -1 +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 da7a30dc77..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: @@ -15,3 +16,23 @@ updates: commit-message: prefix: "ci: [DEPENDABOT] " target-branch: develop + groups: + 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 20856bef95..7ac2599321 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)>; + using HandlerType = std::function)>; virtual ~Resolver() = 0; diff --git a/include/xrpl/basics/SHAMapHash.h b/include/xrpl/basics/SHAMapHash.h index 3c3d525022..1902a3b2ec 100644 --- a/include/xrpl/basics/SHAMapHash.h +++ b/include/xrpl/basics/SHAMapHash.h @@ -85,12 +85,6 @@ public: } }; -inline bool -operator!=(SHAMapHash const& x, SHAMapHash const& y) -{ - return !(x == y); -} - template <> inline std::size_t extract(SHAMapHash const& key) diff --git a/include/xrpl/basics/Slice.h b/include/xrpl/basics/Slice.h index 36e7615c3a..92b777ab98 100644 --- a/include/xrpl/basics/Slice.h +++ b/include/xrpl/basics/Slice.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -207,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 { @@ -251,4 +246,11 @@ makeSlice(std::basic_string const& s) return Slice(s.data(), s.size()); } +template +Slice +makeSlice(std::basic_string_view s) +{ + return Slice(s.data(), s.size()); +} + } // namespace xrpl diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index 2b360d2fda..7cb67cb15b 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); @@ -141,4 +162,89 @@ toUInt64(std::string const& s); bool isProperlyFormedTomlDomain(std::string_view domain); +/** + * Whether a view can be passed on as a C string. + * + * A reader given only data() stops at the first null, so the view must reach the + * terminating null. The test rebuilds the view from data() and compares: a view + * that stops earlier rebuilds longer, and so compares unequal. + * + * consteval because reading the byte after the view is only defined when @p str + * points into storage holding a null at or after its end, such as a string + * literal. An unterminated view is then a compile error, not an out-of-bounds + * read. + * + * @param str The view to test. + * @return Whether @p str is null-terminated. A view with no data is not. + */ +consteval bool +isNullTerminated(std::string_view str) +{ + if (str.data() == nullptr) + return false; + + // Reading past the view is the point, so the usual data() warning does not + // apply. + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + return std::string_view{str.data()} == str; +} + +/** + * A string that is known to reach its terminating null. + * + * Converts to std::string_view, so it compares and hashes as one. Unlike a + * view, asCString() may be handed to a reader that expects a C string, such + * as json::StaticString. + * + * The only constructor is consteval and rejects a view that stops before the + * null, so the property holds by construction and no caller asserts it. + */ +class NullTerminatedView +{ +public: + /** + * Build a view from one that reaches its terminating null. + * + * Explicit, so that a plain view cannot become a proof of termination by + * accident. The conversion the other way stays implicit. + * + * @param view The string to hold. Rejected at compile time if it stops + * before its terminating null, or has no data. + */ + explicit consteval NullTerminatedView(std::string_view view) + : data_(view.data()), size_(view.size()) + { + if (!isNullTerminated(view)) + throw "xrpl::NullTerminatedView : view does not reach a null"; + } + + constexpr + operator std::string_view() const noexcept + { + return view(); + } + + /** + * @return The string as a view. + */ + [[nodiscard]] constexpr std::string_view + view() const noexcept + { + return {data_, size_}; + } + + /** + * @return The string as a C string. Never null. + */ + [[nodiscard]] constexpr char const* + asCString() const noexcept + { + return data_; + } + +private: + char const* data_; + std::size_t size_; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 24fd660e65..30fdc1f118 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -41,6 +41,35 @@ namespace xrpl { +namespace base64 { + +/** + * Returns the maximum number of characters needed to base64-encode @p nBytes bytes. + * + * @param nBytes Number of input bytes. + * @return Size of the encoded string, including padding. + */ +constexpr std::size_t +encodedSize(std::size_t const nBytes) +{ + return 4 * ((nBytes + 2) / 3); +} + +/** + * Returns the maximum number of bytes a base64 string of @p numChars characters + * decodes to. + * + * @param numChars Number of base64 characters. + * @return Upper bound on the number of decoded bytes. + */ +constexpr std::size_t +decodedSize(std::size_t const numChars) +{ + return ((numChars / 4) * 3) + 2; +} + +} // namespace base64 + std::string base64Encode(std::uint8_t const* data, std::size_t len); diff --git a/include/xrpl/basics/base_uint.h b/include/xrpl/basics/base_uint.h index bee8b8b945..3786f28c6a 100644 --- a/include/xrpl/basics/base_uint.h +++ b/include/xrpl/basics/base_uint.h @@ -518,7 +518,7 @@ public: * The input must be precisely `2 * bytes` hexadecimal characters * long, with one exception: the value '0'. * - * @param sv A null-terminated string of hexadecimal characters + * @param sv A string of hexadecimal characters * @return true if the input was parsed properly; false otherwise. */ [[nodiscard]] constexpr bool 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/LexicalCast.h b/include/xrpl/beast/core/LexicalCast.h index 7cf21892bd..288c5d6673 100644 --- a/include/xrpl/beast/core/LexicalCast.h +++ b/include/xrpl/beast/core/LexicalCast.h @@ -58,7 +58,7 @@ struct LexicalCast "beast::LexicalCast can only be used with integral types"); template - bool + constexpr bool operator()(Integral& out, std::string_view in) const requires(std::is_integral_v && !std::is_same_v) { @@ -110,7 +110,7 @@ struct LexicalCast> { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, boost::core::basic_string_view in) const { return LexicalCast()(out, in); @@ -123,7 +123,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, std::string in) const { return LexicalCast()(out, in); @@ -136,7 +136,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, char const* in) const { XRPL_ASSERT(in, "beast::detail::LexicalCast(char const*) : non-null input"); @@ -151,7 +151,7 @@ struct LexicalCast { explicit LexicalCast() = default; - bool + constexpr bool operator()(Out& out, char* in) const { XRPL_ASSERT(in, "beast::detail::LexicalCast(char*) : non-null input"); @@ -177,7 +177,7 @@ struct BadLexicalCast : public std::bad_cast * @return `false` if there was a parsing or range error */ template -bool +constexpr bool lexicalCastChecked(Out& out, In in) { return detail::LexicalCast()(out, in); @@ -191,7 +191,7 @@ lexicalCastChecked(Out& out, In in) * @return The new type. */ template -Out +constexpr Out lexicalCastThrow(In in) { if (Out out; lexicalCastChecked(out, in)) @@ -207,7 +207,7 @@ lexicalCastThrow(In in) * @return The new type. */ template -Out +constexpr Out lexicalCast(In in, Out defaultValue = Out()) { if (Out out; lexicalCastChecked(out, in)) 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/core/SemanticVersion.h b/include/xrpl/beast/core/SemanticVersion.h index 338942c252..c2e395e3f4 100644 --- a/include/xrpl/beast/core/SemanticVersion.h +++ b/include/xrpl/beast/core/SemanticVersion.h @@ -17,14 +17,14 @@ namespace beast { class SemanticVersion { public: - using identifier_list = std::vector; + using IdentifierList = std::vector; int majorVersion; int minorVersion; int patchVersion; - identifier_list preReleaseIdentifiers; - identifier_list metaData; + IdentifierList preReleaseIdentifiers; + IdentifierList metaData; SemanticVersion(); diff --git a/include/xrpl/beast/insight/StatsDCollector.h b/include/xrpl/beast/insight/StatsDCollector.h index e14d3a27ff..0b44f345ba 100644 --- a/include/xrpl/beast/insight/StatsDCollector.h +++ b/include/xrpl/beast/insight/StatsDCollector.h @@ -26,7 +26,7 @@ public: * @param journal Destination for logging output. */ static std::shared_ptr - make(IP::Endpoint const& address, std::string const& prefix, Journal journal); + make(ip::Endpoint const& address, std::string const& prefix, Journal journal); }; } // namespace beast::insight diff --git a/include/xrpl/beast/net/IPAddress.h b/include/xrpl/beast/net/IPAddress.h index 4f4fb189a6..e636a69ce7 100644 --- a/include/xrpl/beast/net/IPAddress.h +++ b/include/xrpl/beast/net/IPAddress.h @@ -15,7 +15,7 @@ //------------------------------------------------------------------------------ namespace beast { -namespace IP { +namespace ip { using Address = boost::asio::ip::address; @@ -73,13 +73,13 @@ isPublic(Address const& addr) return (addr.is_v4()) ? isPublic(addr.to_v4()) : isPublic(addr.to_v6()); } -} // namespace IP +} // namespace ip //------------------------------------------------------------------------------ template void -hash_append(Hasher& h, beast::IP::Address const& addr) noexcept +hash_append(Hasher& h, beast::ip::Address const& addr) noexcept { using beast::hash_append; if (addr.is_v4()) @@ -101,12 +101,12 @@ hash_append(Hasher& h, beast::IP::Address const& addr) noexcept namespace boost { template <> -struct hash<::beast::IP::Address> +struct hash<::beast::ip::Address> { - explicit hash() = default; + hash() = default; std::size_t - operator()(::beast::IP::Address const& addr) const + operator()(::beast::ip::Address const& addr) const { return ::beast::Uhash<>{}(addr); } diff --git a/include/xrpl/beast/net/IPAddressConversion.h b/include/xrpl/beast/net/IPAddressConversion.h index 73777cf841..e2d485642f 100644 --- a/include/xrpl/beast/net/IPAddressConversion.h +++ b/include/xrpl/beast/net/IPAddressConversion.h @@ -4,7 +4,7 @@ #include -namespace beast::IP { +namespace beast::ip { /** * Convert to Endpoint. @@ -32,7 +32,7 @@ toAsioAddress(Endpoint const& endpoint); boost::asio::ip::tcp::endpoint toAsioEndpoint(Endpoint const& endpoint); -} // namespace beast::IP +} // namespace beast::ip namespace beast { @@ -41,25 +41,25 @@ struct IPAddressConversion { explicit IPAddressConversion() = default; - static IP::Endpoint + static ip::Endpoint fromAsio(boost::asio::ip::address const& address) { - return IP::fromAsio(address); + return ip::fromAsio(address); } - static IP::Endpoint + static ip::Endpoint fromAsio(boost::asio::ip::tcp::endpoint const& endpoint) { - return IP::fromAsio(endpoint); + return ip::fromAsio(endpoint); } static boost::asio::ip::address - toAsioAddress(IP::Endpoint const& address) + toAsioAddress(ip::Endpoint const& address) { - return IP::toAsioAddress(address); + return ip::toAsioAddress(address); } static boost::asio::ip::tcp::endpoint - toAsioEndpoint(IP::Endpoint const& address) + toAsioEndpoint(ip::Endpoint const& address) { - return IP::toAsioEndpoint(address); + return ip::toAsioEndpoint(address); } }; diff --git a/include/xrpl/beast/net/IPAddressV4.h b/include/xrpl/beast/net/IPAddressV4.h index 94943af3ea..280c2c791c 100644 --- a/include/xrpl/beast/net/IPAddressV4.h +++ b/include/xrpl/beast/net/IPAddressV4.h @@ -2,7 +2,7 @@ #include -namespace beast::IP { +namespace beast::ip { using AddressV4 = boost::asio::ip::address_v4; @@ -25,4 +25,4 @@ isPublic(AddressV4 const& addr); char getClass(AddressV4 const& address); -} // namespace beast::IP +} // namespace beast::ip diff --git a/include/xrpl/beast/net/IPAddressV6.h b/include/xrpl/beast/net/IPAddressV6.h index b51cb62532..0659e7e405 100644 --- a/include/xrpl/beast/net/IPAddressV6.h +++ b/include/xrpl/beast/net/IPAddressV6.h @@ -2,7 +2,7 @@ #include -namespace beast::IP { +namespace beast::ip { using AddressV6 = boost::asio::ip::address_v6; @@ -18,4 +18,4 @@ isPrivate(AddressV6 const& addr); bool isPublic(AddressV6 const& addr); -} // namespace beast::IP +} // namespace beast::ip diff --git a/include/xrpl/beast/net/IPEndpoint.h b/include/xrpl/beast/net/IPEndpoint.h index c4b269e9c3..a5fb5b4318 100644 --- a/include/xrpl/beast/net/IPEndpoint.h +++ b/include/xrpl/beast/net/IPEndpoint.h @@ -13,7 +13,7 @@ #include #include -namespace beast::IP { +namespace beast::ip { using Port = std::uint16_t; @@ -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) { @@ -223,7 +217,7 @@ operator<<(OutputStream& os, Endpoint const& endpoint) std::istream& operator>>(std::istream& is, Endpoint& endpoint); -} // namespace beast::IP +} // namespace beast::ip //------------------------------------------------------------------------------ @@ -232,12 +226,12 @@ namespace std { * std::hash support. */ template <> -struct hash<::beast::IP::Endpoint> +struct hash<::beast::ip::Endpoint> { hash() = default; std::size_t - operator()(::beast::IP::Endpoint const& endpoint) const + operator()(::beast::ip::Endpoint const& endpoint) const { return ::beast::Uhash<>{}(endpoint); } @@ -249,12 +243,12 @@ namespace boost { * boost::hash support. */ template <> -struct hash<::beast::IP::Endpoint> +struct hash<::beast::ip::Endpoint> { hash() = default; std::size_t - operator()(::beast::IP::Endpoint const& endpoint) const + operator()(::beast::ip::Endpoint const& endpoint) const { return ::beast::Uhash<>{}(endpoint); } 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 c20fe2522c..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; } @@ -295,6 +294,20 @@ public: return runner_->arg(); } +protected: + /** + * Lets a suite compose other suites (e.g. an aggregator that reruns a + * group of related suites under its own name) via `SuiteInfo::run`. + * + * @return The runner this suite is executing under. + */ + Runner& + runner() const + { + return *runner_; + } + +public: /** * DEPRECATED * @return `true` if the test condition indicates success(a false value) 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 5514e0e77b..c78643d6c3 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -25,6 +25,7 @@ struct Sections static constexpr auto kLedgerHistory = "ledger_history"; static constexpr auto kLedgerReplay = "ledger_replay"; static constexpr auto kLedgerTxTables = "ledger_tx_tables"; + static constexpr auto kMaxSubscriptionsPerConnection = "max_subscriptions_per_connection"; static constexpr auto kMaxTransactions = "max_transactions"; static constexpr auto kNetworkId = "network_id"; static constexpr auto kNetworkQuorum = "network_quorum"; @@ -118,10 +119,13 @@ struct Keys static constexpr auto kLogInterval = "log_interval"; static constexpr auto kMaxDivergedTime = "max_diverged_time"; static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store"; + static constexpr auto kMaxTrustedCount = "max_trusted_count"; static constexpr auto kMaxUnknownTime = "max_unknown_time"; + static constexpr auto kMaxUntrustedCount = "max_untrusted_count"; 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/consensus/Consensus.h b/include/xrpl/consensus/Consensus.h index f9d5f7ef02..4c48e7f268 100644 --- a/include/xrpl/consensus/Consensus.h +++ b/include/xrpl/consensus/Consensus.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -1579,7 +1580,13 @@ Consensus::updateOurPositions(std::unique_ptr const& JLOG(j_.info()) << ss.str(); CLOG(clog) << ss.str(); - for (auto const& [t, v] : closeTimeVotes) + // Walk the votes highest-time first so that, among close times tied + // for the most votes, the earliest wins. The smaller value is the + // safer choice: without close-time consensus this round, the winner + // only updates our position for the next proposal, and a too-early + // time is bounded below by the prior ledger's close time. Only the + // tie-break changes; the bin with the most votes still wins. + for (auto const& [t, v] : std::views::reverse(closeTimeVotes)) { JLOG(j_.debug()) << "CCTime: seq " << static_cast(previousLedger_.seq()) + 1 << ": " diff --git a/include/xrpl/consensus/ConsensusTypes.h b/include/xrpl/consensus/ConsensusTypes.h index 4dac2d9912..56739527a1 100644 --- a/include/xrpl/consensus/ConsensusTypes.h +++ b/include/xrpl/consensus/ConsensusTypes.h @@ -8,7 +8,9 @@ #include #include +#include #include +#include #include namespace xrpl { @@ -189,6 +191,75 @@ struct ConsensusCloseTimes NetClock::time_point self; }; +/** + * Offset of the network's close time relative to ours, using a weighted median. + * + * Treats the sample set as `{self x 1}` merged with `{t x w}` for each + * `(t, w)` in `times.peers`, in time order, and returns `(median - self)` + * in whole seconds. Uses the lower weighted median: the median is the + * earliest time at which the running weight reaches half the total, so an + * even total whose halfway point falls between two bins resolves to the + * earlier bin. + * + * @param times Our own close time and the weighted close times of peers. + * @return Weighted median of all close times minus our own, in whole seconds. + */ +inline std::chrono::seconds +medianCloseOffset(ConsensusCloseTimes const& times) +{ + using namespace std::chrono; + using time_point = NetClock::time_point; + + std::int64_t totalWeight = 1; + for (auto const& [_, w] : times.peers) + totalWeight += w; + + std::int64_t const halfWeight = (totalWeight + 1) / 2; + + std::optional median{}; + std::int64_t tally = 0; + bool selfPlaced = false; + + // Accumulate weight in time order; the first bin to reach halfWeight is + // the (lower) weighted median. Returns true once that bin is found. + auto step = [&](time_point t, std::int64_t w) { + XRPL_ASSERT(tally < halfWeight, "xrpl::medianCloseOffset::step : median not yet found"); + tally += w; + if (tally >= halfWeight) + { + median = t; + return true; + } + return false; + }; + + for (auto const& [t, w] : times.peers) + { + if (!selfPlaced && times.self <= t) + { + selfPlaced = true; + if (step(times.self, 1)) + break; + } + if (step(t, w)) + break; + } + if (!selfPlaced && !median) + step(times.self, 1); + + if (!median) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::medianCloseOffset : median not found"); + median = times.self; + // LCOV_EXCL_STOP + } + + return duration_cast( + duration{median->time_since_epoch().count()} - + duration{times.self.time_since_epoch().count()}); +} + /** * Whether we have or don't have a consensus */ 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..0c544c9aa5 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -1,16 +1,17 @@ #pragma once #include +#include #include #include -#include - #include #include +#include #include #include -#include +#include +#include namespace beast { class Journal; @@ -44,7 +45,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)}; }; @@ -68,7 +69,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcStart(std::string const& method, std::uint64_t requestId) = 0; + rpcStart(std::string_view method, std::uint64_t requestId) = 0; /** * Log successful finish of RPC call @@ -77,7 +78,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcFinish(std::string const& method, std::uint64_t requestId) = 0; + rpcFinish(std::string_view method, std::uint64_t requestId) = 0; /** * Log errored RPC call @@ -86,7 +87,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcError(std::string const& method, std::uint64_t requestId) = 0; + rpcError(std::string_view method, std::uint64_t requestId) = 0; /** * Log queued job @@ -149,12 +150,22 @@ public: }; PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir); +setupPerfLog(Section const& section, std::filesystem::path const& configDir); +/** + * @param methodNames The RPC methods to count, one counter per name. Reported + * as JSON keys that borrow each name and read it as a C string, which is + * why the parameter type requires one that reaches its terminating null. + * The names must outlive the returned object, which holds views of them. + * The range itself need not: it is copied. + * Passed in rather than looked up here, so that this layer needs no + * knowledge of the dispatch table. + */ std::unique_ptr makePerfLog( PerfLog::Setup const& setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop); @@ -162,7 +173,7 @@ template auto measureDurationAndLog( Func&& func, - std::string const& actionDescription, + std::string_view actionDescription, std::chrono::duration maxDelay, beast::Journal const& journal) { diff --git a/include/xrpl/core/ServiceRegistry.h b/include/xrpl/core/ServiceRegistry.h index 2747ecd9e8..000bdaa7fa 100644 --- a/include/xrpl/core/ServiceRegistry.h +++ b/include/xrpl/core/ServiceRegistry.h @@ -18,9 +18,9 @@ namespace xrpl { namespace node_store { class Database; } // namespace node_store -namespace Resource { +namespace resource { class Manager; -} // namespace Resource +} // namespace resource namespace perf { class PerfLog; } // namespace perf @@ -160,7 +160,7 @@ public: virtual PeerReservationTable& getPeerReservations() = 0; - virtual Resource::Manager& + virtual resource::Manager& getResourceManager() = 0; // Storage services 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 47ad3ac1e0..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. @@ -623,6 +595,7 @@ class ValueConstIterator : public ValueIteratorBase public: using size_t = unsigned int; using difference_type = int; + using value_type = Value const; using reference = Value const&; using pointer = Value const*; using SelfType = ValueConstIterator; @@ -687,6 +660,7 @@ class ValueIterator : public ValueIteratorBase public: using size_t = unsigned int; using difference_type = int; + using value_type = Value; using reference = Value&; using pointer = Value*; using SelfType = ValueIterator; diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index dc4361136d..b9aa87ae52 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -49,12 +49,6 @@ public: bool operator==(const_iterator const& other) const; - bool - operator!=(const_iterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 11aadf4e92..3fe17d6eef 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -59,12 +59,6 @@ private: return lhs.txId_ == rhs.txId_; } - friend bool - operator!=(Key const& lhs, Key const& rhs) - { - return !(lhs == rhs); - } - [[nodiscard]] uint256 const& getAccount() const { diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index 233719cdeb..eb70b3b6a3 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -59,12 +59,6 @@ public: bool operator==(ConstIterator const& other) const; - bool - operator!=(ConstIterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e518008..bb0817673c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace xrpl { @@ -35,6 +36,11 @@ enum class SkipEntry : bool { No = false, Yes }; // //------------------------------------------------------------------------------ +/** + * Whether an expiration check should be inclusive or exclusive. + */ +enum class ExpiryComparison { Inclusive, Exclusive }; + /** * Determines whether the given expiration time has passed. * @@ -54,11 +60,16 @@ enum class SkipEntry : bool { No = false, Yes }; * * @param view The ledger whose parent time is used as the clock. * @param exp The optional expiration time we want to check. + * @param comparison Whether the boundary is inclusive (`now >= exp`, the + * default) or exclusive (`now > exp`). * * @return `true` if `exp` is in the past; `false` otherwise. */ [[nodiscard]] bool -hasExpired(ReadView const& view, std::optional const& exp); +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison = ExpiryComparison::Inclusive); // Note, depth parameter is used to limit the recursion depth [[nodiscard]] bool @@ -68,6 +79,13 @@ isVaultPseudoAccountFrozen( MPTIssue const& mptShare, std::uint8_t depth); +[[nodiscard]] bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth); + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, @@ -75,6 +93,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); @@ -161,7 +199,10 @@ dirLink( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -172,7 +213,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. @@ -185,7 +227,10 @@ canWithdraw( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -195,20 +240,25 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. * * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * destination account (sfDestination). Credentials, if any, are taken from the + * transaction's sfCredentialIDs field. * * - Checks that the receiver account exists. * - If the receiver requires a destination tag, check that one exists, even * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials in sfCredentialIDs to already exist in the + * ledger, and returns an internal error otherwise. Validate them + * beforehand with credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ diff --git a/include/xrpl/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/entries/AMMEntry.h b/include/xrpl/ledger/entries/AMMEntry.h new file mode 100644 index 0000000000..0c1c0fe629 --- /dev/null +++ b/include/xrpl/ledger/entries/AMMEntry.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AMMEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AMMEntry( + Asset const& issue1, + Asset const& issue2, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amm(issue1, issue2), view, j) + { + } + + explicit AMMEntry( + uint256 const& ammID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amm(ammID), view, j) + { + } +}; + +using AMMEntryR = AMMEntry; +using AMMEntryW = AMMEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/AccountRootEntry.h b/include/xrpl/ledger/entries/AccountRootEntry.h new file mode 100644 index 0000000000..5555ea8c50 --- /dev/null +++ b/include/xrpl/ledger/entries/AccountRootEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AccountRootEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AccountRootEntry( + AccountID const& id, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::account(id), view, j) + { + } +}; + +using AccountRootEntryR = AccountRootEntry; +using AccountRootEntryW = AccountRootEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/AmendmentsEntry.h b/include/xrpl/ledger/entries/AmendmentsEntry.h new file mode 100644 index 0000000000..ce4dca7e80 --- /dev/null +++ b/include/xrpl/ledger/entries/AmendmentsEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AmendmentsEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AmendmentsEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amendments(), view, j) + { + } +}; + +using AmendmentsEntryR = AmendmentsEntry; +using AmendmentsEntryW = AmendmentsEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/BridgeEntry.h b/include/xrpl/ledger/entries/BridgeEntry.h new file mode 100644 index 0000000000..a51fbe0c12 --- /dev/null +++ b/include/xrpl/ledger/entries/BridgeEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class BridgeEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit BridgeEntry( + STXChainBridge const& bridge, + STXChainBridge::ChainType chainType, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::bridge(bridge, chainType), view, j) + { + } +}; + +using BridgeEntryR = BridgeEntry; +using BridgeEntryW = BridgeEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/CheckEntry.h b/include/xrpl/ledger/entries/CheckEntry.h new file mode 100644 index 0000000000..bf6a188ff5 --- /dev/null +++ b/include/xrpl/ledger/entries/CheckEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class CheckEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit CheckEntry( + AccountID const& id, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::check(id, seq), view, j) + { + } + + explicit CheckEntry( + uint256 const& checkID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::check(checkID), view, j) + { + } +}; + +using CheckEntryR = CheckEntry; +using CheckEntryW = CheckEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/CredentialEntry.h b/include/xrpl/ledger/entries/CredentialEntry.h new file mode 100644 index 0000000000..4b6a3386a1 --- /dev/null +++ b/include/xrpl/ledger/entries/CredentialEntry.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class CredentialEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit CredentialEntry( + AccountID const& subject, + AccountID const& issuer, + Slice const& credType, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::credential(subject, issuer, credType), view, j) + { + } + + explicit CredentialEntry( + uint256 const& credentialID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::credential(credentialID), view, j) + { + } +}; + +using CredentialEntryR = CredentialEntry; +using CredentialEntryW = CredentialEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DIDEntry.h b/include/xrpl/ledger/entries/DIDEntry.h new file mode 100644 index 0000000000..ee3eaf8153 --- /dev/null +++ b/include/xrpl/ledger/entries/DIDEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class DIDEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DIDEntry( + AccountID const& account, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::did(account), view, j) + { + } +}; + +using DIDEntryR = DIDEntry; +using DIDEntryW = DIDEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DelegateEntry.h b/include/xrpl/ledger/entries/DelegateEntry.h new file mode 100644 index 0000000000..de908d4dcd --- /dev/null +++ b/include/xrpl/ledger/entries/DelegateEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class DelegateEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DelegateEntry( + AccountID const& account, + AccountID const& authorizedAccount, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::delegate(account, authorizedAccount), view, j) + { + } +}; + +using DelegateEntryR = DelegateEntry; +using DelegateEntryW = DelegateEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DepositPreauthEntry.h b/include/xrpl/ledger/entries/DepositPreauthEntry.h new file mode 100644 index 0000000000..783363084d --- /dev/null +++ b/include/xrpl/ledger/entries/DepositPreauthEntry.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +template +class DepositPreauthEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DepositPreauthEntry( + AccountID const& owner, + AccountID const& preauthorized, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(owner, preauthorized), view, j) + { + } + + explicit DepositPreauthEntry( + AccountID const& owner, + std::set> const& authCreds, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(owner, authCreds), view, j) + { + } + + explicit DepositPreauthEntry( + uint256 const& preauthID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(preauthID), view, j) + { + } +}; + +using DepositPreauthEntryR = DepositPreauthEntry; +using DepositPreauthEntryW = DepositPreauthEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DirectoryNodeEntry.h b/include/xrpl/ledger/entries/DirectoryNodeEntry.h new file mode 100644 index 0000000000..b57bec7348 --- /dev/null +++ b/include/xrpl/ledger/entries/DirectoryNodeEntry.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class DirectoryNodeEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DirectoryNodeEntry( + AccountID const& id, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ownerDir(id), view, j) + { + } + + /** + * Resolve a specific page of the directory rooted at @p root. + */ + explicit DirectoryNodeEntry( + uint256 const& root, + std::uint64_t index, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::page(root, index), view, j) + { + } +}; + +using DirectoryNodeEntryR = DirectoryNodeEntry; +using DirectoryNodeEntryW = DirectoryNodeEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/EscrowEntry.h b/include/xrpl/ledger/entries/EscrowEntry.h new file mode 100644 index 0000000000..9eb1418a4c --- /dev/null +++ b/include/xrpl/ledger/entries/EscrowEntry.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class EscrowEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit EscrowEntry( + AccountID const& src, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::escrow(src, seq), view, j) + { + } +}; + +using EscrowEntryR = EscrowEntry; +using EscrowEntryW = EscrowEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/FeeSettingsEntry.h b/include/xrpl/ledger/entries/FeeSettingsEntry.h new file mode 100644 index 0000000000..66d61892e5 --- /dev/null +++ b/include/xrpl/ledger/entries/FeeSettingsEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class FeeSettingsEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit FeeSettingsEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::feeSettings(), view, j) + { + } +}; + +using FeeSettingsEntryR = FeeSettingsEntry; +using FeeSettingsEntryW = FeeSettingsEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LedgerHashesEntry.h b/include/xrpl/ledger/entries/LedgerHashesEntry.h new file mode 100644 index 0000000000..ab54d820a1 --- /dev/null +++ b/include/xrpl/ledger/entries/LedgerHashesEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LedgerHashesEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LedgerHashesEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::skip(), view, j) + { + } +}; + +using LedgerHashesEntryR = LedgerHashesEntry; +using LedgerHashesEntryW = LedgerHashesEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LoanBrokerEntry.h b/include/xrpl/ledger/entries/LoanBrokerEntry.h new file mode 100644 index 0000000000..77e6b660ed --- /dev/null +++ b/include/xrpl/ledger/entries/LoanBrokerEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LoanBrokerEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LoanBrokerEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loanBroker(owner, seq), view, j) + { + } + + explicit LoanBrokerEntry( + uint256 const& loanBrokerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loanBroker(loanBrokerID), view, j) + { + } +}; + +using LoanBrokerEntryR = LoanBrokerEntry; +using LoanBrokerEntryW = LoanBrokerEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LoanEntry.h b/include/xrpl/ledger/entries/LoanEntry.h new file mode 100644 index 0000000000..124c5915fc --- /dev/null +++ b/include/xrpl/ledger/entries/LoanEntry.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LoanEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LoanEntry( + uint256 const& loanBrokerID, + SeqProxy const& loanSeq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loan(loanBrokerID, loanSeq), view, j) + { + } + + explicit LoanEntry( + uint256 const& loanID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loan(loanID), view, j) + { + } +}; + +using LoanEntryR = LoanEntry; +using LoanEntryW = LoanEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/MPTokenEntry.h b/include/xrpl/ledger/entries/MPTokenEntry.h new file mode 100644 index 0000000000..dcd6c5adb1 --- /dev/null +++ b/include/xrpl/ledger/entries/MPTokenEntry.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class MPTokenEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit MPTokenEntry( + MPTID const& issuanceID, + AccountID const& holder, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(issuanceID, holder), view, j) + { + } + + explicit MPTokenEntry( + uint256 const& issuanceKey, + AccountID const& holder, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(issuanceKey, holder), view, j) + { + } + + explicit MPTokenEntry( + uint256 const& mptokenKey, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(mptokenKey), view, j) + { + } +}; + +using MPTokenEntryR = MPTokenEntry; +using MPTokenEntryW = MPTokenEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h b/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h new file mode 100644 index 0000000000..52dac53217 --- /dev/null +++ b/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class MPTokenIssuanceEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit MPTokenIssuanceEntry( + std::uint32_t seq, + AccountID const& issuer, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(makeMptID(seq, issuer)), view, j) + { + } + + explicit MPTokenIssuanceEntry( + MPTID const& issuanceID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(issuanceID), view, j) + { + } + + explicit MPTokenIssuanceEntry( + uint256 const& issuanceKey, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(issuanceKey), view, j) + { + } +}; + +using MPTokenIssuanceEntryR = MPTokenIssuanceEntry; +using MPTokenIssuanceEntryW = MPTokenIssuanceEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NFTokenOfferEntry.h b/include/xrpl/ledger/entries/NFTokenOfferEntry.h new file mode 100644 index 0000000000..a2e71652cd --- /dev/null +++ b/include/xrpl/ledger/entries/NFTokenOfferEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NFTokenOfferEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NFTokenOfferEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenOffer(owner, seq), view, j) + { + } + + explicit NFTokenOfferEntry( + uint256 const& offerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenOffer(offerID), view, j) + { + } +}; + +using NFTokenOfferEntryR = NFTokenOfferEntry; +using NFTokenOfferEntryW = NFTokenOfferEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NFTokenPageEntry.h b/include/xrpl/ledger/entries/NFTokenPageEntry.h new file mode 100644 index 0000000000..11701f17ea --- /dev/null +++ b/include/xrpl/ledger/entries/NFTokenPageEntry.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NFTokenPageEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NFTokenPageEntry( + Keylet const& page, + uint256 const& token, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenPage(page, token), view, j) + { + } +}; + +using NFTokenPageEntryR = NFTokenPageEntry; +using NFTokenPageEntryW = NFTokenPageEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NegativeUNLEntry.h b/include/xrpl/ledger/entries/NegativeUNLEntry.h new file mode 100644 index 0000000000..9c61058622 --- /dev/null +++ b/include/xrpl/ledger/entries/NegativeUNLEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NegativeUNLEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NegativeUNLEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::negativeUNL(), view, j) + { + } +}; + +using NegativeUNLEntryR = NegativeUNLEntry; +using NegativeUNLEntryW = NegativeUNLEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/OfferEntry.h b/include/xrpl/ledger/entries/OfferEntry.h new file mode 100644 index 0000000000..2180d3c89c --- /dev/null +++ b/include/xrpl/ledger/entries/OfferEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class OfferEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit OfferEntry( + AccountID const& id, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::offer(id, seq), view, j) + { + } + + explicit OfferEntry( + uint256 const& offerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::offer(offerID), view, j) + { + } +}; + +using OfferEntryR = OfferEntry; +using OfferEntryW = OfferEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/OracleEntry.h b/include/xrpl/ledger/entries/OracleEntry.h new file mode 100644 index 0000000000..69013a2745 --- /dev/null +++ b/include/xrpl/ledger/entries/OracleEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class OracleEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit OracleEntry( + AccountID const& account, + std::uint32_t documentID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::oracle(account, documentID), view, j) + { + } +}; + +using OracleEntryR = OracleEntry; +using OracleEntryW = OracleEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/PayChannelEntry.h b/include/xrpl/ledger/entries/PayChannelEntry.h new file mode 100644 index 0000000000..3540474633 --- /dev/null +++ b/include/xrpl/ledger/entries/PayChannelEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class PayChannelEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit PayChannelEntry( + AccountID const& src, + AccountID const& dst, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::payChannel(src, dst, seq), view, j) + { + } +}; + +using PayChannelEntryR = PayChannelEntry; +using PayChannelEntryW = PayChannelEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/PermissionedDomainEntry.h b/include/xrpl/ledger/entries/PermissionedDomainEntry.h new file mode 100644 index 0000000000..0668a8f8b6 --- /dev/null +++ b/include/xrpl/ledger/entries/PermissionedDomainEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class PermissionedDomainEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit PermissionedDomainEntry( + AccountID const& account, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::permissionedDomain(account, seq), view, j) + { + } + + explicit PermissionedDomainEntry( + uint256 const& domainID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::permissionedDomain(domainID), view, j) + { + } +}; + +using PermissionedDomainEntryR = PermissionedDomainEntry; +using PermissionedDomainEntryW = PermissionedDomainEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/RippleStateEntry.h b/include/xrpl/ledger/entries/RippleStateEntry.h new file mode 100644 index 0000000000..f9bd8aa703 --- /dev/null +++ b/include/xrpl/ledger/entries/RippleStateEntry.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class RippleStateEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit RippleStateEntry( + AccountID const& id0, + AccountID const& id1, + Currency const& currency, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::trustLine(id0, id1, currency), view, j) + { + } + + explicit RippleStateEntry( + AccountID const& id, + Issue const& issue, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::trustLine(id, issue), view, j) + { + } +}; + +using RippleStateEntryR = RippleStateEntry; +using RippleStateEntryW = RippleStateEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SLEBase.h b/include/xrpl/ledger/entries/SLEBase.h new file mode 100644 index 0000000000..182e03b55d --- /dev/null +++ b/include/xrpl/ledger/entries/SLEBase.h @@ -0,0 +1,503 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { + +// Concept to distinguish read-only vs writable view types +template +concept IsWritableView = std::derived_from; + +namespace detail { + +/** + * Resolves a keylet for a read-only entry. + * + * ReadView::read() on an ApplyView returns the underlying ledger's entry + * whenever the view is not already tracking one, while peek() installs the + * view's own copy and returns that. A read-only entry built with read() + * would therefore hold an SLE that goes stale the moment anything peeks the + * same key and modifies it. Resolve through peek() whenever the view really is + * an ApplyView, so every entry over that view shares one SLE. + * + * @note The const_cast is what makes reaching ApplyView::peek() possible, and + * it is defined behavior only when the view really is a non-const + * object that the caller merely observes through a const reference. + * That holds for every production view today, but it is not a + * guarantee the codebase makes: the unit tests already build + * genuinely const ApplyView-derived objects (`Sandbox const` in + * Directory_test.cpp and View_test.cpp, `PaymentSandbox const` in + * TheoreticalQuality_test.cpp and View_test.cpp). Constructing a + * read-only entry over one of those would be undefined behavior, so + * do not, until #8069 removes the cast -- by giving ApplyView a + * const-qualified peek(), which needs no amendment because + * Action::Cache is invisible to apply(), visit() and metadata. + * + * @note Consequently a "read-only" entry over an ApplyView is not free of + * side effects: peek() installs an Action::Cache entry in the apply + * state table. That is benign for transaction metadata -- Cache entries + * are skipped in ApplyStateTable::apply(), ::visit() and in metadata + * generation -- but it does cost one deep SLE copy on first touch. + */ +inline SLE::const_pointer +resolveEntry(ReadView const& view, Keylet const& key) +{ + // Safe only for a view that is not itself a const object -- see the + // note above. The entry holds a const reference because it does not + // modify the view, not because the view is const. + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) + if (auto const applyView = dynamic_cast(const_cast(&view))) + return applyView->peek(key); + return view.read(key); +} + +} // namespace detail + +/** + * View-parameterized base class for all ledger entries. + * + * SLEBase — read-only: holds shared_ptr + ReadView const& + * SLEBase — writable: holds shared_ptr + ApplyView& + Keylet, + * plus insert/update/erase operations + * + * Write-only members are gated by `requires` clauses, providing compile-time + * guarantees that read-only entries cannot mutate state. + * + * @tparam EntryType the ledger entry type this entry is statically bound to. + * Derived per-type entries pass their own type (e.g. ltACCOUNT_ROOT); the + * generic ReadOnlySLE / WritableSLE aliases leave it at ltANY, which opts out + * of the static type check. Binding the type here is what keeps an entry for + * one entry type from being constructed or converted from another -- see the + * converting constructor below. + * + * Derived classes should provide domain-specific accessors that hide + * implementation details of the underlying ledger entry format. + */ +template +class SLEBase +{ +public: + static constexpr bool kIsWritable = IsWritableView; + + // The ledger entry type this entry is bound to, and whether that binding + // is meaningful (ltANY means "any type", i.e. no static check). + static constexpr LedgerEntryType kEntryType = EntryType; + static constexpr bool kIsTyped = (EntryType != ltANY); + + // SLE pointer type: mutable for writable views, const for read-only + using SlePtrType = std::conditional_t; + + // View reference type: ApplyView& for writable, ReadView const& for + // read-only + using ViewRefType = std::conditional_t; + + // Non-virtual by design: these entries are parameterized on the view and + // entry type, never used polymorphically through a base pointer. A vptr + // would be 8 bytes of pure overhead on a type meant to be as cheap as the + // shared_ptr it wraps. See the static_assert below the class. + // + // The destructor is public because the ReadOnlySLE / WritableSLE aliases + // name this class directly and are used as value types. Since it is not + // virtual, never delete a derived entry through an SLEBase*. + ~SLEBase() = default; + + SLEBase(SLEBase const&) + requires(!kIsWritable) + = default; + SLEBase(SLEBase&&) = default; + SLEBase& + operator=(SLEBase const&) = delete; + SLEBase& + operator=(SLEBase&&) = delete; + SLEBase() = delete; + + // --- Constructors that adopt/resolve an SLE (public so the ReadOnlySLE / + // WritableSLE aliases and the per-type entries can be built directly + // from a keylet, or -- read-only only -- from an already-fetched + // SLE). --- + + /** + * Constructor for read-only context (adopt an already-fetched SLE). + * + * There is deliberately no writable equivalent: a writable entry needs + * a Keylet so that newSLE() can still build an entry when none exists, + * and that cannot be recovered from a null SLE. + */ + explicit SLEBase( + SLE::const_pointer sle, + ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires(!kIsWritable) + : view_(view), sle_(std::move(sle)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || !sle_ || sle_->getType() == kEntryType, + "xrpl::SLEBase::SLEBase : adopted SLE matches bound entry type"); + } + + /** + * Constructor for read-only context (read from view by keylet) + */ + explicit SLEBase( + Keylet const& key, + ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires(!kIsWritable) + : view_(view), sle_(detail::resolveEntry(view, key)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || key.type == kEntryType, + "xrpl::SLEBase::SLEBase : keylet matches bound entry type"); + } + + /** + * Converting constructor: writable → read-only. + * + * Enables implicit conversion from SLEBase to + * SLEBase, so functions taking ReadOnlySLE const& can accept + * WritableSLE. + * + * Constrained to the same entry type (or to a ltANY target, i.e. widening + * a typed entry to a generic ReadOnlySLE). The constraint is load-bearing: + * this constructor is inherited into every per-type entry, and unconstrained + * it would bind any writable entry that slices to SLEBase, so an OfferEntryW + * would convert to an AccountRootEntryR with no cast at the call site. + */ + template + SLEBase(SLEBase const& other) + requires(!kIsWritable && IsWritableView && + (OtherType == EntryType || EntryType == ltANY)) + : view_(other.readView()), sle_(other.rawSle()), j_(other.journal()) + { + } + + /** + * Constructor for writable context (peek from view by keylet) + */ + explicit SLEBase( + Keylet const& key, + ApplyView& view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires kIsWritable + : view_(view), key_(key), sle_(view_.peek(key)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || key.type == kEntryType, + "xrpl::SLEBase::SLEBase : keylet matches bound entry type"); + } + + /** + * Constructor for writable context, for call sites that hold an + * ApplyViewContext (peek from ctx.view by keylet). + * + * ctx.tx is not retained: this exists purely so transactors can pass the + * context they already have instead of spelling out ctx.view. If an entry + * ever needs the applying transaction, store it here rather than adding + * another overload. + */ + explicit SLEBase( + Keylet const& key, + ApplyViewContext const& ctx, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires kIsWritable + : SLEBase(key, ctx.view, j) + { + } + + // --- Common interface (always available) --- + + /** + * Returns true if the ledger entry exists + */ + [[nodiscard]] bool + exists() const + { + return sle_ != nullptr; + } + + /** + * Explicit conversion to bool for convenient existence checking + */ + explicit + operator bool() const + { + return exists(); + } + + /** + * Returns the underlying SLE for read access. + * + * Prefer operator-> / operator* for field access; this is for the call + * sites that need the shared_ptr itself. + */ + [[nodiscard]] SLE::const_pointer + rawSle() const + { + return sle_; + } + + /** + * Returns the ledger entry type of this entry. + * + * For a per-type entry this is kEntryType, known at compile time and + * valid whether or not the entry exists. Only the generic ReadOnlySLE / + * WritableSLE aliases have to read it back out of the SLE. + * + * @throws std::logic_error for a generic (ltANY) entry if exists() is + * false. + */ + [[nodiscard]] LedgerEntryType + type() const + { + if constexpr (kIsTyped) + { + return kEntryType; + } + else + { + if (!exists()) + Throw("xrpl::SLEBase::type : entry does not exist"); + return sle_->getType(); + } + } + + /** + * Returns the keylet identifying this entry. + * + * Writable entries keep the keylet they were built from, so it is valid + * even before newSLE(). Read-only entries derive it from the SLE, which + * must therefore exist. + * + * @throws std::logic_error for a read-only entry if exists() is false. + */ + [[nodiscard]] Keylet + keylet() const + { + if constexpr (kIsWritable) + { + return key_; + } + else + { + if (!exists()) + Throw("xrpl::SLEBase::keylet : entry does not exist"); + // Take the type from the SLE, not from kEntryType: the adopt-SLE + // constructor's type check is assert-only, so a Release build can + // be holding an SLE whose type disagrees with the binding, and the + // SLE is the one telling the truth. + return Keylet(sle_->getType(), sle_->key()); + } + } + + /** + * Returns the ledger key of this entry. + * + * @throws std::logic_error same as keylet(): for read-only entries, + * if exists() is false. + */ + [[nodiscard]] uint256 + key() const + { + return keylet().key; + } + + /** + * Returns the read view (always available; ApplyView inherits ReadView) + */ + [[nodiscard]] ReadView const& + readView() const + { + return view_; + } + + /** + * Const dereference operators (always available) + * + * @throws std::logic_error if exists() is false. + */ + STLedgerEntry const* + operator->() const + { + if (!exists()) + Throw("xrpl::SLEBase::operator-> : entry does not exist"); + return sle_.get(); + } + + STLedgerEntry const& + operator*() const + { + if (!exists()) + Throw("xrpl::SLEBase::operator* : entry does not exist"); + return *sle_; + } + + // --- Writable interface (compile-time gated) --- + // + // Everything that hands out mutable access (or mutates) is non-const, so + // that a `FooEntryW const&` is as inert as a `FooEntryR`. Use readView() + // when a const entry only needs to inspect the view. + + /** + * Returns the underlying SLE for write access. + * + * Prefer operator-> / operator* for field access; this is for the call + * sites that need the shared_ptr itself. + */ + [[nodiscard]] SlePtrType const& + mutableRawSle() + requires kIsWritable + { + return sle_; + } + + /** + * Returns the apply view for write operations + */ + [[nodiscard]] ApplyView& + applyView() + requires kIsWritable + { + return view_; + } + + /** + * Mutable dereference operators + * + * @throws std::logic_error if exists() is false. + */ + STLedgerEntry* + operator->() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::operator-> : entry does not exist"); + return sle_.get(); + } + + STLedgerEntry& + operator*() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::operator* : entry does not exist"); + return *sle_; + } + + /** + * Inserts the entry into the view. + * + * @throws std::logic_error if exists() is false. + */ + void + insert() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::insert : entry does not exist"); + view_.insert(sle_); + } + + /** + * Erases the entry from the view. + * + * Drops the SLE afterwards, so the entry reports !exists() and any + * further use throws here rather than either throwing from deep inside + * ApplyStateTable or -- worse -- silently succeeding. For an + * entry that already existed, ApplyStateTable::erase keeps holding this + * exact SLE and builds the DeletedNode's FinalFields from it, so a write + * through the entry after erase() would land in transaction metadata + * with no diagnostic at all. + * + * @throws std::logic_error if exists() is false. + */ + void + erase() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::erase : entry does not exist"); + view_.erase(sle_); + sle_ = nullptr; + } + + /** + * @throws std::logic_error if exists() is false. + */ + void + update() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::update : entry does not exist"); + view_.update(sle_); + } + + /** + * @throws std::logic_error if exists() is true: newSLE() would otherwise + * silently discard the SLE already held. + */ + void + newSLE() + requires kIsWritable + { + if (exists()) + Throw("xrpl::SLEBase::newSLE : entry already exists"); + sle_ = std::make_shared(key_); + } + + [[nodiscard]] beast::Journal + journal() const + { + return j_; + } + +protected: + ViewRefType view_; + + // Keylet is only meaningful for writable views, which need it to build an + // SLE that does not exist yet; read-only entries derive it from the SLE. + struct Empty + { + }; + + // No default member initializer: Keylet is not default-constructible, so + // every writable constructor must initialize key_ explicitly. + [[no_unique_address]] + std::conditional_t key_; + + SlePtrType sle_{}; + beast::Journal j_; +}; + +/** + * Generic (any-entry-type) SLE entries. + * + * Use these when the concrete ledger entry type is not known at a given site; + * otherwise prefer the per-type entries (e.g. AccountRootEntry.h), which + * additionally enforce the entry type at compile time. + * + * SLE::const_pointer / SLE::const_ref -> ReadOnlySLE + * SLE::pointer / SLE::ref -> WritableSLE + */ +using ReadOnlySLE = SLEBase; +using WritableSLE = SLEBase; + +static_assert( + !std::is_polymorphic_v && !std::is_polymorphic_v, + "SLEBase must stay a thin value type; it must not acquire a vtable"); + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SignerListEntry.h b/include/xrpl/ledger/entries/SignerListEntry.h new file mode 100644 index 0000000000..f6f881cd69 --- /dev/null +++ b/include/xrpl/ledger/entries/SignerListEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class SignerListEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit SignerListEntry( + AccountID const& account, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::signerList(account), view, j) + { + } +}; + +using SignerListEntryR = SignerListEntry; +using SignerListEntryW = SignerListEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SponsorshipEntry.h b/include/xrpl/ledger/entries/SponsorshipEntry.h new file mode 100644 index 0000000000..98445479d9 --- /dev/null +++ b/include/xrpl/ledger/entries/SponsorshipEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class SponsorshipEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit SponsorshipEntry( + AccountID const& sponsor, + AccountID const& sponsee, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::sponsorship(sponsor, sponsee), view, j) + { + } +}; + +using SponsorshipEntryR = SponsorshipEntry; +using SponsorshipEntryW = SponsorshipEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/TicketEntry.h b/include/xrpl/ledger/entries/TicketEntry.h new file mode 100644 index 0000000000..16d9a9f3d9 --- /dev/null +++ b/include/xrpl/ledger/entries/TicketEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class TicketEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit TicketEntry( + AccountID const& id, + SeqProxy const& ticketSeq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ticket(id, ticketSeq), view, j) + { + } + + explicit TicketEntry( + uint256 const& ticketID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ticket(ticketID), view, j) + { + } +}; + +using TicketEntryR = TicketEntry; +using TicketEntryW = TicketEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/VaultEntry.h b/include/xrpl/ledger/entries/VaultEntry.h new file mode 100644 index 0000000000..897a9117b1 --- /dev/null +++ b/include/xrpl/ledger/entries/VaultEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class VaultEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit VaultEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::vault(owner, seq), view, j) + { + } + + explicit VaultEntry( + uint256 const& vaultID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::vault(vaultID), view, j) + { + } +}; + +using VaultEntryR = VaultEntry; +using VaultEntryW = VaultEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h b/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h new file mode 100644 index 0000000000..9cec1fcec9 --- /dev/null +++ b/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class XChainOwnedClaimIDEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit XChainOwnedClaimIDEntry( + STXChainBridge const& bridge, + std::uint64_t seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::xChainClaimID(bridge, seq), view, j) + { + } +}; + +using XChainOwnedClaimIDEntryR = XChainOwnedClaimIDEntry; +using XChainOwnedClaimIDEntryW = XChainOwnedClaimIDEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h b/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h new file mode 100644 index 0000000000..e9494e702f --- /dev/null +++ b/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class XChainOwnedCreateAccountClaimIDEntry + : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit XChainOwnedCreateAccountClaimIDEntry( + STXChainBridge const& bridge, + std::uint64_t seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::xChainCreateAccountClaimID(bridge, seq), view, j) + { + } +}; + +using XChainOwnedCreateAccountClaimIDEntryR = XChainOwnedCreateAccountClaimIDEntry; +using XChainOwnedCreateAccountClaimIDEntryW = XChainOwnedCreateAccountClaimIDEntry; + +} // namespace xrpl 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/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 9f54e53769..062443cd92 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -241,10 +243,25 @@ escrowUnlockApplyHelper( auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kParityRate) { - // compute transfer fee, if any - auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.asset(), true); - // compute balance to transfer - finalAmt = amount.value() - xferFee; + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + XRPL_ASSERT( + lockedRate >= kParityRate, + "xrpl::escrowUnlockApplyHelper : lockedRate is at least parity"); + // MPTs are integral, so round the delivered amount down and + // charge any fractional transfer fee to the escrowed amount. + auto const delivered = + mulRatio(amount.mpt(), kParityRate.value, lockedRate.value, false); + finalAmt = STAmount(amount.asset(), delivered.value()); + } + else + { + // compute transfer fee, if any + auto const xferFee = + amount.value() - divideRound(amount, lockedRate, amount.asset(), true); + // compute balance to transfer + finalAmt = amount.value() - xferFee; + } } return unlockEscrowMPT( ctx.view, diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 8e0d11cccb..3887b10120 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,83 @@ 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 +{ + Number assetsTotalDelta; + Number debtTotalDelta; +}; + +// Instant interest recognition (pre-LendingProtocolV1_1): interest is +// recognized into AssetsTotal/DebtTotal immediately, at origination. +namespace instant_recognition { + +// LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); + +// LoanSet origination: would recognizing this loan's interest push +// Vault.AssetsTotal past Vault.AssetsMaximum? +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue); + +// LoanManage impair/unimpair/default: the vault's exposure to this loan +Number +loanVaultExposure(SLE::const_ref loanSle); + +// LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace instant_recognition + +// Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal +// are principal-only, interest is recognized only as it's actually paid. +namespace cash_basis { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested); + +Number +loanVaultExposure(SLE::const_ref loanSle); + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace cash_basis + +// Public dispatchers: pick cash_basis:: if featureLendingProtocolV1_1 is +// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is +// VaultVersion::CashBasis, else instant_recognition::. These are the only entry points +// transactors call. +AccountingDeltas +loanOriginationDeltas( + SLE::const_ref vaultSle, + Number const& principalRequested, + Number const& interestDue); + +bool +loanOriginationExceedsVaultMaximum( + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue); + +Number +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle); + +AccountingDeltas +loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts); + namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests 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 1bd1663314..acbcb8ec04 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,14 +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 @@ -37,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. */ @@ -51,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 @@ -107,4 +170,133 @@ sharesToAssetsWithdraw( [[nodiscard]] bool isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance); +/** + * Resolves a Vault's LEVersion, the single point every accounting touch + * point should call to determine which recognition model (instant interest + * recognition vs. cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * activated never have sfLEVersion set, which resolves here to + * VaultVersion::Legacy. + * + * @param vault The vault SLE. + * + * @return The Vault's LEVersion, or VaultVersion::Legacy if the field is + * absent. + */ +[[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/AutoSocket.h b/include/xrpl/net/AutoSocket.h index b98885959d..d090247388 100644 --- a/include/xrpl/net/AutoSocket.h +++ b/include/xrpl/net/AutoSocket.h @@ -67,16 +67,16 @@ public: return socket_->next_layer(); } - beast::IP::Endpoint + beast::ip::Endpoint localEndpoint() { - return beast::IP::fromAsio(lowestLayer().local_endpoint()); + return beast::ip::fromAsio(lowestLayer().local_endpoint()); } - beast::IP::Endpoint + beast::ip::Endpoint remoteEndpoint() { - return beast::IP::fromAsio(lowestLayer().remote_endpoint()); + return beast::ip::fromAsio(lowestLayer().remote_endpoint()); } lowest_layer_type& 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/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index 33a2e27939..9ba81a7323 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -92,7 +92,7 @@ public: void importDatabase(Database& source) override { - importInternal(*backend_.get(), source); + importInternal(*backend_, source); } void diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/Varint.h similarity index 91% rename from include/xrpl/nodestore/detail/varint.h rename to include/xrpl/nodestore/detail/Varint.h index afbf71cdea..5474cdc8b4 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/Varint.h @@ -13,18 +13,18 @@ namespace xrpl::node_store { // https://developers.google.com/protocol-buffers/docs/encoding#varints // field tag -struct varint; +struct Varint; -// Metafuncton to return largest +// Metafunction to return largest // possible size of T represented as varint. // T must be unsigned template > -struct varint_traits; +struct VarintTraits; template -struct varint_traits +struct VarintTraits { - explicit varint_traits() = default; + explicit VarintTraits() = default; static constexpr std::size_t kMax = ((8 * sizeof(T)) + 6) / 7; }; @@ -104,7 +104,7 @@ writeVarint(void* p0, std::size_t v) template void read(nudb::detail::istream& is, std::size_t& u) - requires(std::is_same_v) + requires(std::is_same_v) { auto p0 = is(1); auto p1 = p0; @@ -118,7 +118,7 @@ read(nudb::detail::istream& is, std::size_t& u) template void write(nudb::detail::ostream& os, std::size_t t) - requires(std::is_same_v) + requires(std::is_same_v) { writeVarint(os.data(sizeVarint(t)), t); } diff --git a/include/xrpl/nodestore/detail/codec.h b/include/xrpl/nodestore/detail/codec.h index a3dfa7c944..47ad2da50a 100644 --- a/include/xrpl/nodestore/detail/codec.h +++ b/include/xrpl/nodestore/detail/codec.h @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include @@ -59,7 +59,7 @@ lz4Compress(void const* in, std::size_t inSize, BufferFactory&& bf) using std::runtime_error; using namespace nudb::detail; std::pair result; - std::array::kMax> vi{}; + std::array::kMax> vi{}; auto const n = writeVarint(vi.data(), inSize); auto const outMax = LZ4_compressBound(inSize); auto* out = reinterpret_cast(bf(n + outMax)); @@ -240,7 +240,7 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) auto* out = reinterpret_cast(bf(result.second)); result.first = out; ostream os(out, result.second); - write(os, type); + write(os, type); write(os, mask); write(os, vh.data(), n * 32); return result; @@ -252,13 +252,13 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) auto* out = reinterpret_cast(bf(result.second)); result.first = out; ostream os(out, result.second); - write(os, type); + write(os, type); write(os, vh.data(), n * 32); return result; } } - std::array::kMax> vi{}; + std::array::kMax> vi{}; static constexpr std::size_t kCodecType = 1; auto const vn = writeVarint(vi.data(), kCodecType); diff --git a/include/xrpl/peerfinder/Config.h b/include/xrpl/peerfinder/Config.h index 9ff0d342c3..fa2d2d78e8 100644 --- a/include/xrpl/peerfinder/Config.h +++ b/include/xrpl/peerfinder/Config.h @@ -9,7 +9,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { struct PeerLimitConfig { @@ -26,9 +26,10 @@ struct Config /** * The largest number of public peer slots to allow. * This includes both inbound and outbound, but does not include - * fixed peers. + * fixed peers. A configuration built by `makeConfig` always holds + * `maxPeers == inPeers + outPeers`. */ - std::size_t maxPeers{Tuning::kDefaultMaxPeers}; + std::size_t maxPeers{tuning::kDefaultMaxPeers}; /** * The number of automatic outbound connections to maintain. @@ -100,7 +101,7 @@ struct Config onWrite(beast::PropertyStream::Map& map) const; /** - * Make PeerFinder::Config from peer limit and server mode parameters. + * Make peer_finder::Config from peer limit and server mode parameters. */ static Config makeConfig( @@ -160,4 +161,4 @@ to_string(Result result) noexcept return "unknown"; } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/PeerfinderManager.h b/include/xrpl/peerfinder/PeerfinderManager.h index ed683520c1..bb03d85537 100644 --- a/include/xrpl/peerfinder/PeerfinderManager.h +++ b/include/xrpl/peerfinder/PeerfinderManager.h @@ -15,7 +15,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Maintains a set of IP addresses used for getting into the network. @@ -68,17 +68,17 @@ public: * file, along with the set of corresponding IP addresses. */ virtual void - addFixedPeer(std::string_view name, std::vector const& addresses) = 0; + addFixedPeer(std::string_view name, std::vector const& addresses) = 0; /** - * Add a set of strings as fallback IP::Endpoint sources. + * Add a set of strings as fallback ip::Endpoint sources. * @param name A label used for diagnostics. */ virtual void addFallbackStrings(std::string const& name, std::vector const& strings) = 0; /** - * Add a URL as a fallback location to obtain IP::Endpoint sources. + * Add a URL as a fallback location to obtain ip::Endpoint sources. * @param name A label used for diagnostics. */ /* VFALCO NOTE Unimplemented @@ -95,8 +95,8 @@ public: */ virtual std::pair, Result> newInboundSlot( - beast::IP::Endpoint const& localEndpoint, - beast::IP::Endpoint const& remoteEndpoint) = 0; + beast::ip::Endpoint const& localEndpoint, + beast::ip::Endpoint const& remoteEndpoint) = 0; /** * Create a new outbound slot with the specified remote endpoint. @@ -104,7 +104,7 @@ public: * Usually this is because of a duplicate connection. */ virtual std::pair, Result> - newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) = 0; + newOutboundSlot(beast::ip::Endpoint const& remoteEndpoint) = 0; /** * Called when mtENDPOINTS is received. @@ -145,7 +145,7 @@ public: * @return `true` if the connection should be kept */ virtual bool - onConnected(std::shared_ptr const& slot, beast::IP::Endpoint const& localEndpoint) = 0; + onConnected(std::shared_ptr const& slot, beast::ip::Endpoint const& localEndpoint) = 0; /** * Request an active slot type. @@ -162,7 +162,7 @@ public: /** * Return a set of addresses we should connect to. */ - virtual std::vector + virtual std::vector autoconnect() = 0; virtual std::vector, std::vector>> @@ -176,4 +176,4 @@ public: oncePerSecond() = 0; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/Slot.h b/include/xrpl/peerfinder/Slot.h index 9db39ac94c..58e094afc4 100644 --- a/include/xrpl/peerfinder/Slot.h +++ b/include/xrpl/peerfinder/Slot.h @@ -7,7 +7,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Properties and state associated with a peer to peer overlay connection. @@ -52,13 +52,13 @@ public: /** * The remote endpoint of socket. */ - [[nodiscard]] virtual beast::IP::Endpoint const& + [[nodiscard]] virtual beast::ip::Endpoint const& remoteEndpoint() const = 0; /** * The local endpoint of the socket, when known. */ - [[nodiscard]] virtual std::optional const& + [[nodiscard]] virtual std::optional const& localEndpoint() const = 0; [[nodiscard]] virtual std::optional @@ -72,4 +72,4 @@ public: publicKey() const = 0; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/Types.h b/include/xrpl/peerfinder/Types.h index 9e82d9d65c..1327f2564f 100644 --- a/include/xrpl/peerfinder/Types.h +++ b/include/xrpl/peerfinder/Types.h @@ -8,14 +8,14 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { using clock_type = beast::AbstractClock; /** * Represents a set of addresses. */ -using IPAddresses = std::vector; +using IPAddresses = std::vector; //------------------------------------------------------------------------------ @@ -26,10 +26,10 @@ struct Endpoint { Endpoint() = default; - Endpoint(beast::IP::Endpoint ep, std::uint32_t hops); + Endpoint(beast::ip::Endpoint ep, std::uint32_t hops); std::uint32_t hops = 0; - beast::IP::Endpoint address; + beast::ip::Endpoint address; }; inline bool @@ -43,4 +43,4 @@ operator<(Endpoint const& lhs, Endpoint const& rhs) */ using Endpoints = std::vector; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Bootcache.h b/include/xrpl/peerfinder/detail/Bootcache.h index 2141a374fa..453d9c22d2 100644 --- a/include/xrpl/peerfinder/detail/Bootcache.h +++ b/include/xrpl/peerfinder/detail/Bootcache.h @@ -14,7 +14,7 @@ #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Stores IP addresses useful for gaining initial connections. @@ -65,7 +65,7 @@ private: }; using left_t = boost::bimaps:: - unordered_set_of, std::equal_to<>>; + unordered_set_of, std::equal_to<>>; using right_t = boost::bimaps::multiset_of>; using map_type = boost::bimap; using value_type = map_type::value_type; @@ -73,11 +73,11 @@ private: struct Transform { using first_argument_type = map_type::right_map::const_iterator::value_type const&; - using result_type = beast::IP::Endpoint const&; + using result_type = beast::ip::Endpoint const&; explicit Transform() = default; - beast::IP::Endpoint const& + beast::ip::Endpoint const& operator()(map_type::right_map::const_iterator::value_type const& v) const { return v.get_left(); @@ -121,7 +121,7 @@ public: size() const; /** - * IP::Endpoint iterators that traverse in decreasing valence. + * ip::Endpoint iterators that traverse in decreasing valence. */ /** @{ */ [[nodiscard]] const_iterator @@ -146,25 +146,25 @@ public: * Add a newly-learned address to the cache. */ bool - insert(beast::IP::Endpoint const& endpoint); + insert(beast::ip::Endpoint const& endpoint); /** * Add a staticallyconfigured address to the cache. */ bool - insertStatic(beast::IP::Endpoint const& endpoint); + insertStatic(beast::ip::Endpoint const& endpoint); /** * Called when an outbound connection handshake completes. */ void - onSuccess(beast::IP::Endpoint const& endpoint); + onSuccess(beast::ip::Endpoint const& endpoint); /** * Called when an outbound connection attempt fails to handshake. */ void - onFailure(beast::IP::Endpoint const& endpoint); + onFailure(beast::ip::Endpoint const& endpoint); /** * Stores the cache in the persistent database on a timer. @@ -189,4 +189,4 @@ private: flagForUpdate(); }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Checker.h b/include/xrpl/peerfinder/detail/Checker.h index 28ec83adb1..e1ac1d44e0 100644 --- a/include/xrpl/peerfinder/detail/Checker.h +++ b/include/xrpl/peerfinder/detail/Checker.h @@ -11,7 +11,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Tests remote listening sockets to make sure they are connectable. @@ -104,7 +104,7 @@ public: */ template void - asyncConnect(beast::IP::Endpoint const& endpoint, Handler&& handler); + asyncConnect(beast::ip::Endpoint const& endpoint, Handler&& handler); private: void @@ -179,7 +179,7 @@ Checker::wait() template template void -Checker::asyncConnect(beast::IP::Endpoint const& endpoint, Handler&& handler) +Checker::asyncConnect(beast::ip::Endpoint const& endpoint, Handler&& handler) { auto const op = std::make_shared>(*this, ioContext_, std::forward(handler)); @@ -202,4 +202,4 @@ Checker::remove(BasicAsyncOp& op) cond_.notify_all(); } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Counts.h b/include/xrpl/peerfinder/detail/Counts.h index ce78eadce4..035103463e 100644 --- a/include/xrpl/peerfinder/detail/Counts.h +++ b/include/xrpl/peerfinder/detail/Counts.h @@ -10,7 +10,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Direction of a slot count adjustment. @@ -50,7 +50,7 @@ public: // Must be handshaked and in the right state XRPL_ASSERT( s.state() == Slot::State::Connected || s.state() == Slot::State::Accept, - "xrpl::PeerFinder::Counts::can_activate : valid input state"); + "xrpl::peer_finder::Counts::can_activate : valid input state"); if (s.fixed() || s.reserved()) return true; @@ -67,9 +67,9 @@ public: [[nodiscard]] std::size_t attemptsNeeded() const { - if (attempts_ >= Tuning::kMaxConnectAttempts) + if (attempts_ >= tuning::kMaxConnectAttempts) return 0; - return Tuning::kMaxConnectAttempts - attempts_; + return tuning::kMaxConnectAttempts - attempts_; } /** @@ -295,7 +295,7 @@ private: switch (s.state()) { case Slot::State::Accept: - XRPL_ASSERT(s.inbound(), "xrpl::PeerFinder::Counts::adjust : input is inbound"); + XRPL_ASSERT(s.inbound(), "xrpl::peer_finder::Counts::adjust : input is inbound"); acceptCount_ += n; break; @@ -303,7 +303,7 @@ private: case Slot::State::Connected: XRPL_ASSERT( !s.inbound(), - "xrpl::PeerFinder::Counts::adjust : input is not " + "xrpl::peer_finder::Counts::adjust : input is not " "inbound"); attempts_ += n; break; @@ -331,7 +331,7 @@ private: // LCOV_EXCL_START default: - UNREACHABLE("xrpl::PeerFinder::Counts::adjust : invalid input state"); + UNREACHABLE("xrpl::peer_finder::Counts::adjust : invalid input state"); break; // LCOV_EXCL_STOP }; @@ -391,4 +391,4 @@ private: int closingCount_{0}; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Fixed.h b/include/xrpl/peerfinder/detail/Fixed.h index 6754ec6dbd..5a52fd7c3e 100644 --- a/include/xrpl/peerfinder/detail/Fixed.h +++ b/include/xrpl/peerfinder/detail/Fixed.h @@ -7,7 +7,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Metadata for a Fixed slot. @@ -36,8 +36,8 @@ public: void failure(clock_type::time_point const& now) { - failures_ = std::min(failures_ + 1, Tuning::kConnectionBackoff.size() - 1); - when_ = now + std::chrono::minutes(Tuning::kConnectionBackoff[failures_]); + failures_ = std::min(failures_ + 1, tuning::kConnectionBackoff.size() - 1); + when_ = now + std::chrono::minutes(tuning::kConnectionBackoff[failures_]); } /** @@ -55,4 +55,4 @@ private: std::size_t failures_{0}; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Handouts.h b/include/xrpl/peerfinder/detail/Handouts.h index cb5fd7f850..c20d4b2139 100644 --- a/include/xrpl/peerfinder/detail/Handouts.h +++ b/include/xrpl/peerfinder/detail/Handouts.h @@ -12,7 +12,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { namespace detail { @@ -28,7 +28,7 @@ template std::size_t handoutOne(Target& t, HopContainer& h) { - XRPL_ASSERT(!t.full(), "xrpl::PeerFinder::detail::handoutOne : target is not full"); + XRPL_ASSERT(!t.full(), "xrpl::peer_finder::detail::handoutOne : target is not full"); for (auto it = h.begin(); it != h.end(); ++it) { auto const& e = *it; @@ -95,7 +95,7 @@ public: [[nodiscard]] bool full() const { - return list_.size() >= Tuning::kRedirectEndpointCount; + return list_.size() >= tuning::kRedirectEndpointCount; } [[nodiscard]] SlotImp::ptr const& @@ -124,7 +124,7 @@ private: template RedirectHandouts::RedirectHandouts(SlotImp::ptr slot) : slot_(std::move(slot)) { - list_.reserve(Tuning::kRedirectEndpointCount); + list_.reserve(tuning::kRedirectEndpointCount); } template @@ -138,7 +138,7 @@ RedirectHandouts::tryInsert(Endpoint const& ep) // addresses in a peer HTTP handshake instead of // the tmENDPOINTS message. // - if (ep.hops > Tuning::kMaxHops) + if (ep.hops > tuning::kMaxHops) return false; // Don't send them our address @@ -181,7 +181,7 @@ public: [[nodiscard]] bool full() const { - return list_.size() >= Tuning::kNumberOfEndpoints; + return list_.size() >= tuning::kNumberOfEndpoints; } void @@ -210,7 +210,7 @@ private: template SlotHandouts::SlotHandouts(SlotImp::ptr slot) : slot_(std::move(slot)) { - list_.reserve(Tuning::kNumberOfEndpoints); + list_.reserve(tuning::kNumberOfEndpoints); } template @@ -220,7 +220,7 @@ SlotHandouts::tryInsert(Endpoint const& ep) if (full()) return false; - if (ep.hops > Tuning::kMaxHops) + if (ep.hops > tuning::kMaxHops) return false; if (slot_->recent.filter(ep.address, ep.hops)) @@ -259,9 +259,9 @@ class ConnectHandouts public: // Keeps track of addresses we have made outgoing connections // to, for the purposes of not connecting to them too frequently. - using Squelches = beast::aged_set; + using Squelches = beast::aged_set; - using list_type = std::vector; + using list_type = std::vector; private: std::size_t needed_; @@ -274,7 +274,7 @@ public: template bool - tryInsert(beast::IP::Endpoint const& endpoint); + tryInsert(beast::ip::Endpoint const& endpoint); [[nodiscard]] bool empty() const @@ -316,13 +316,13 @@ ConnectHandouts::ConnectHandouts(std::size_t needed, Squelches& squelches) template bool -ConnectHandouts::tryInsert(beast::IP::Endpoint const& endpoint) +ConnectHandouts::tryInsert(beast::ip::Endpoint const& endpoint) { if (full()) return false; // Make sure the address isn't already in our list - if (std::ranges::any_of(list_, [&endpoint](beast::IP::Endpoint const& other) { + if (std::ranges::any_of(list_, [&endpoint](beast::ip::Endpoint const& other) { // Ignore port for security reasons return other.address() == endpoint.address(); })) @@ -341,4 +341,4 @@ ConnectHandouts::tryInsert(beast::IP::Endpoint const& endpoint) return true; } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Livecache.h b/include/xrpl/peerfinder/detail/Livecache.h index cac284d1cc..ec797065e5 100644 --- a/include/xrpl/peerfinder/detail/Livecache.h +++ b/include/xrpl/peerfinder/detail/Livecache.h @@ -29,7 +29,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { template class Livecache; @@ -188,10 +188,10 @@ class Livecache : protected detail::LivecacheBase { private: using cache_type = beast::aged_map< - beast::IP::Endpoint, + beast::ip::Endpoint, Element, std::chrono::steady_clock, - std::less, + std::less, Allocator>; beast::Journal journal_; @@ -220,8 +220,8 @@ public: // but not given out (since they would exceed maxHops). They // are used for automatic connection attempts. // - using Histogram = std::array; - using lists_type = std::array; + using Histogram = std::array; + using lists_type = std::array; template struct Transform @@ -400,7 +400,7 @@ Livecache::expire() { std::size_t n(0); typename cache_type::time_point const expired( - cache_.clock().now() - Tuning::kLiveCacheSecondsToLive); + cache_.clock().now() - tuning::kLiveCacheSecondsToLive); for (auto iter(cache_.chronological.begin()); iter != cache_.chronological.end() && iter.when() <= expired;) { @@ -427,8 +427,8 @@ Livecache::insert(Endpoint const& ep) // when redirecting. // XRPL_ASSERT( - ep.hops <= (Tuning::kMaxHops + 1), - "xrpl::PeerFinder::Livecache::insert : maximum input hops"); + ep.hops <= (tuning::kMaxHops + 1), + "xrpl::peer_finder::Livecache::insert : maximum input hops"); auto result = cache_.emplace(ep.address, ep); Element& e(result.first->second); if (result.second) @@ -468,7 +468,7 @@ void Livecache::onWrite(beast::PropertyStream::Map& map) { typename cache_type::time_point const expired( - cache_.clock().now() - Tuning::kLiveCacheSecondsToLive); + cache_.clock().now() - tuning::kLiveCacheSecondsToLive); map["size"] = size(); map["hist"] = hops.histogram(); beast::PropertyStream::Set set("entries", map); @@ -527,8 +527,8 @@ void Livecache::HopsT::insert(Element& e) { XRPL_ASSERT( - e.endpoint.hops <= Tuning::kMaxHops + 1, - "xrpl::PeerFinder::Livecache::HopsT::insert : maximum input hops"); + e.endpoint.hops <= tuning::kMaxHops + 1, + "xrpl::peer_finder::Livecache::HopsT::insert : maximum input hops"); // This has security implications without a shuffle lists_[e.endpoint.hops].push_front(e); ++hist_[e.endpoint.hops]; @@ -539,8 +539,8 @@ void Livecache::HopsT::reinsert(Element& e, std::uint32_t numHops) { XRPL_ASSERT( - numHops <= Tuning::kMaxHops + 1, - "xrpl::PeerFinder::Livecache::HopsT::reinsert : maximum hops input"); + numHops <= tuning::kMaxHops + 1, + "xrpl::peer_finder::Livecache::HopsT::reinsert : maximum hops input"); auto& list = lists_[e.endpoint.hops]; list.erase(list.iterator_to(e)); @@ -561,4 +561,4 @@ Livecache::HopsT::remove(Element& e) list.erase(list.iterator_to(e)); } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Logic.h b/include/xrpl/peerfinder/detail/Logic.h index c623263884..4821054280 100644 --- a/include/xrpl/peerfinder/detail/Logic.h +++ b/include/xrpl/peerfinder/detail/Logic.h @@ -43,7 +43,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * The Logic for maintaining the list of Slot addresses. @@ -57,7 +57,7 @@ public: // Maps remote endpoints to slots. Since a slot has a // remote endpoint upon construction, this holds all counts_. // - using Slots = std::map>; + using Slots = std::map>; beast::Journal journal; clock_type& clock; @@ -81,7 +81,7 @@ private: Counts counts_; // A list of slots that should always be connected - std::map fixed_; + std::map fixed_; public: // Live livecache from mtENDPOINTS messages @@ -96,7 +96,7 @@ public: // The addresses (but not port) we are connected to. This includes // outgoing connection attempts. Note that this set can contain // duplicates (since the port is not set) - std::multiset connectedAddresses; + std::multiset connectedAddresses; // Set of public keys belonging to active peers std::set keys; @@ -170,13 +170,13 @@ public: } void - addFixedPeer(std::string_view name, beast::IP::Endpoint const& ep) + addFixedPeer(std::string_view name, beast::ip::Endpoint const& ep) { - addFixedPeer(name, std::vector{ep}); + addFixedPeer(name, std::vector{ep}); } void - addFixedPeer(std::string_view name, std::vector const& addresses) + addFixedPeer(std::string_view name, std::vector const& addresses) { std::scoped_lock const _(lock); @@ -213,8 +213,8 @@ public: // Called when the Checker completes a connectivity test void checkComplete( - beast::IP::Endpoint const& remoteAddress, - beast::IP::Endpoint const& checkedAddress, + beast::ip::Endpoint const& remoteAddress, + beast::ip::Endpoint const& checkedAddress, boost::system::error_code ec) { if (ec == boost::asio::error::operation_aborted) @@ -256,8 +256,8 @@ public: std::pair newInboundSlot( - beast::IP::Endpoint const& localEndpoint, - beast::IP::Endpoint const& remoteEndpoint) + beast::ip::Endpoint const& localEndpoint, + beast::ip::Endpoint const& remoteEndpoint) { JLOG(journal.debug()) << std::left << std::setw(18) << "Logic accept" << remoteEndpoint << " on local " << localEndpoint; @@ -293,7 +293,7 @@ public: // Remote address must not already exist XRPL_ASSERT( result.second, - "xrpl::PeerFinder::Logic::new_inbound_slot : remote endpoint " + "xrpl::peer_finder::Logic::new_inbound_slot : remote endpoint " "inserted"); // Add to the connected address list connectedAddresses.emplace(remoteEndpoint.address()); @@ -306,7 +306,7 @@ public: // Can't check for self-connect because we don't know the local endpoint std::pair - newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) + newOutboundSlot(beast::ip::Endpoint const& remoteEndpoint) { JLOG(journal.debug()) << std::left << std::setw(18) << "Logic connect " << remoteEndpoint; @@ -329,7 +329,7 @@ public: // Remote address must not already exist XRPL_ASSERT( result.second, - "xrpl::PeerFinder::Logic::new_outbound_slot : remote endpoint " + "xrpl::peer_finder::Logic::new_outbound_slot : remote endpoint " "inserted"); // Add to the connected address list @@ -342,7 +342,7 @@ public: } bool - onConnected(SlotImp::ptr const& slot, beast::IP::Endpoint const& localEndpoint) + onConnected(SlotImp::ptr const& slot, beast::ip::Endpoint const& localEndpoint) { beast::WrappedSink sink{journal.sink(), slot->prefix()}; beast::Journal const journal{sink}; @@ -354,7 +354,7 @@ public: // The object must exist in our table XRPL_ASSERT( slots.contains(slot->remoteEndpoint()), - "xrpl::PeerFinder::Logic::onConnected : valid slot input"); + "xrpl::peer_finder::Logic::onConnected : valid slot input"); // Assign the local endpoint now that it's known slot->localEndpoint(localEndpoint); @@ -365,7 +365,7 @@ public: { XRPL_ASSERT( iter->second->localEndpoint() == slot->remoteEndpoint(), - "xrpl::PeerFinder::Logic::onConnected : local and remote " + "xrpl::peer_finder::Logic::onConnected : local and remote " "endpoints do match"); JLOG(journal.warn()) << "Logic dropping as self connect"; return false; @@ -393,11 +393,11 @@ public: // The object must exist in our table XRPL_ASSERT( slots.contains(slot->remoteEndpoint()), - "xrpl::PeerFinder::Logic::activate : valid slot input"); + "xrpl::peer_finder::Logic::activate : valid slot input"); // Must be accepted or connected XRPL_ASSERT( slot->state() == Slot::State::Accept || slot->state() == Slot::State::Connected, - "xrpl::PeerFinder::Logic::activate : valid slot state"); + "xrpl::peer_finder::Logic::activate : valid slot state"); // Check for duplicate connection by key if (keys.contains(key)) @@ -425,7 +425,7 @@ public: { [[maybe_unused]] bool const inserted = keys.insert(key).second; // Public key must not already exist - XRPL_ASSERT(inserted, "xrpl::PeerFinder::Logic::activate : public key inserted"); + XRPL_ASSERT(inserted, "xrpl::peer_finder::Logic::activate : public key inserted"); } // Change state and update counts @@ -443,7 +443,7 @@ public: if (iter == fixed_.end()) { logicError( - "PeerFinder::Logic::activate(): remote_endpoint " + "peer_finder::Logic::activate(): remote_endpoint " "missing from fixed_"); } @@ -476,10 +476,10 @@ public: // VFALCO TODO This should add the returned addresses to the // squelch list in one go once the list is built, // rather than having each module add to the squelch list. - std::vector + std::vector autoconnect() { - std::vector none; + std::vector none; std::scoped_lock const _(lock); @@ -635,7 +635,7 @@ public: // either. ipv6 has a slightly more compact string // representation of 0, so use that for self entries. ep.address = - beast::IP::Endpoint(beast::IP::AddressV6()).atPort(config_.listeningPort); + beast::ip::Endpoint(beast::ip::AddressV6()).atPort(config_.listeningPort); for (auto& t : targets) t.insert(ep); } @@ -656,7 +656,7 @@ public: result.emplace_back(slot, list); } - whenBroadcast = now + Tuning::kSecondsPerMessage; + whenBroadcast = now + tuning::kSecondsPerMessage; } return result; @@ -675,7 +675,7 @@ public: entry.second->expire(); // Expire the recent attempts table - beast::expire(squelches, Tuning::kRecentAttemptDuration); + beast::expire(squelches, tuning::kRecentAttemptDuration); bootcache.periodicActivity(); } @@ -692,7 +692,7 @@ public: Endpoint& ep(*iter); // Enforce hop limit - if (ep.hops > Tuning::kMaxHops) + if (ep.hops > tuning::kMaxHops) { JLOG(journal.debug()) << std::left << std::setw(18) << "Endpoints drop " << ep.address << " for excess hops " << ep.hops; @@ -754,10 +754,10 @@ public: beast::Journal const journal{sink}; // If we're sent too many endpoints, sample them at random: - if (list.size() > Tuning::kNumberOfEndpointsMax) + if (list.size() > tuning::kNumberOfEndpointsMax) { std::shuffle(list.begin(), list.end(), defaultPrng()); - list.resize(Tuning::kNumberOfEndpointsMax); + list.resize(tuning::kNumberOfEndpointsMax); } JLOG(journal.trace()) << "Endpoints contained " << list.size() @@ -768,12 +768,12 @@ public: // The object must exist in our table XRPL_ASSERT( slots.contains(slot->remoteEndpoint()), - "xrpl::PeerFinder::Logic::onEndpoints : valid slot input"); + "xrpl::peer_finder::Logic::onEndpoints : valid slot input"); // Must be handshaked! XRPL_ASSERT( slot->state() == Slot::State::Active, - "xrpl::PeerFinder::Logic::onEndpoints : valid slot state"); + "xrpl::peer_finder::Logic::onEndpoints : valid slot state"); clock_type::time_point const now(clock.now()); @@ -785,7 +785,7 @@ public: for (auto const& ep : list) { - XRPL_ASSERT(ep.hops, "xrpl::PeerFinder::Logic::onEndpoints : nonzero hops"); + XRPL_ASSERT(ep.hops, "xrpl::peer_finder::Logic::onEndpoints : nonzero hops"); slot->recent.insert(ep.address, ep.hops); @@ -837,7 +837,7 @@ public: bootcache.insert(ep.address); } - slot->whenAcceptEndpoints = now + Tuning::kSecondsPerMessage; + slot->whenAcceptEndpoints = now + tuning::kSecondsPerMessage; } //-------------------------------------------------------------------------- @@ -851,7 +851,7 @@ public: if (iter == slots.end()) { logicError( - "PeerFinder::Logic::remove(): remote_endpoint " + "peer_finder::Logic::remove(): remote_endpoint " "missing from slots_"); } @@ -866,7 +866,7 @@ public: if (iter == keys.end()) { logicError( - "PeerFinder::Logic::remove(): public_key missing " + "peer_finder::Logic::remove(): public_key missing " "from keys_"); } @@ -879,7 +879,7 @@ public: if (iter == connectedAddresses.end()) { logicError( - "PeerFinder::Logic::remove(): remote_endpoint " + "peer_finder::Logic::remove(): remote_endpoint " "address missing from connectedAddresses_"); } @@ -907,7 +907,7 @@ public: if (iter == fixed_.end()) { logicError( - "PeerFinder::Logic::on_closed(): remote_endpoint " + "peer_finder::Logic::on_closed(): remote_endpoint " "missing from fixed_"); } @@ -943,7 +943,7 @@ public: // LCOV_EXCL_START default: UNREACHABLE( - "xrpl::PeerFinder::Logic::on_closed : invalid slot " + "xrpl::peer_finder::Logic::on_closed : invalid slot " "state"); break; // LCOV_EXCL_STOP @@ -968,17 +968,17 @@ public: // Returns `true` if the address matches a fixed slot address // Must have the lock held bool - fixed(beast::IP::Endpoint const& endpoint) const + fixed(beast::ip::Endpoint const& endpoint) const { return std::ranges::any_of( fixed_, [&endpoint](auto const& entry) { return entry.first == endpoint; }); } // Returns `true` if the address matches a fixed slot address - // Note that this does not use the port information in the IP::Endpoint + // Note that this does not use the port information in the ip::Endpoint // Must have the lock held bool - fixed(beast::IP::Address const& address) const + fixed(beast::ip::Address const& address) const { return std::ranges::any_of( fixed_, [&address](auto const& entry) { return entry.first.address() == address; }); @@ -1097,9 +1097,9 @@ public: // //-------------------------------------------------------------------------- - // Returns true if the IP::Endpoint contains no invalid data. + // Returns true if the ip::Endpoint contains no invalid data. bool - isValidAddress(beast::IP::Endpoint const& address) + isValidAddress(beast::ip::Endpoint const& address) { if (isUnspecified(address)) return false; @@ -1220,7 +1220,7 @@ Logic::onRedirects( { std::scoped_lock const _(lock); std::size_t n = 0; - for (; first != last && n < Tuning::kMaxRedirects; ++first, ++n) + for (; first != last && n < tuning::kMaxRedirects; ++first, ++n) bootcache.insert(beast::IPAddressConversion::fromAsio(*first)); if (n > 0) { @@ -1229,4 +1229,4 @@ Logic::onRedirects( } } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/SlotImp.h b/include/xrpl/peerfinder/detail/SlotImp.h index 35c61b13cf..db86183f64 100644 --- a/include/xrpl/peerfinder/detail/SlotImp.h +++ b/include/xrpl/peerfinder/detail/SlotImp.h @@ -12,7 +12,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { class SlotImp : public Slot { @@ -21,13 +21,13 @@ public: // inbound SlotImp( - beast::IP::Endpoint const& localEndpoint, - beast::IP::Endpoint remoteEndpoint, + beast::ip::Endpoint const& localEndpoint, + beast::ip::Endpoint remoteEndpoint, bool fixed, clock_type& clock); // outbound - SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clock); + SlotImp(beast::ip::Endpoint remoteEndpoint, bool fixed, clock_type& clock); bool inbound() const override @@ -53,13 +53,13 @@ public: return state_; } - beast::IP::Endpoint const& + beast::ip::Endpoint const& remoteEndpoint() const override { return remoteEndpoint_; } - std::optional const& + std::optional const& localEndpoint() const override { return localEndpoint_; @@ -93,13 +93,13 @@ public: } void - localEndpoint(beast::IP::Endpoint const& endpoint) + localEndpoint(beast::ip::Endpoint const& endpoint) { localEndpoint_ = endpoint; } void - remoteEndpoint(beast::IP::Endpoint const& endpoint) + remoteEndpoint(beast::ip::Endpoint const& endpoint) { remoteEndpoint_ = endpoint; } @@ -140,20 +140,20 @@ public: * sending a slot the same address too frequently. */ void - insert(beast::IP::Endpoint const& ep, std::uint32_t hops); + insert(beast::ip::Endpoint const& ep, std::uint32_t hops); /** * Returns `true` if we should not send endpoint to the slot. */ bool - filter(beast::IP::Endpoint const& ep, std::uint32_t hops); + filter(beast::ip::Endpoint const& ep, std::uint32_t hops); private: void expire(); friend class SlotImp; - beast::aged_unordered_map cache_; + beast::aged_unordered_map cache_; } recent; void @@ -167,8 +167,8 @@ private: bool const fixed_; bool reserved_; State state_; - beast::IP::Endpoint remoteEndpoint_; - std::optional localEndpoint_; + beast::ip::Endpoint remoteEndpoint_; + std::optional localEndpoint_; std::optional publicKey_; static std::int32_t constexpr kUnknownPort = -1; @@ -196,4 +196,4 @@ public: clock_type::time_point whenAcceptEndpoints; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Source.h b/include/xrpl/peerfinder/detail/Source.h index 5cdb535bdd..09aa4e216a 100644 --- a/include/xrpl/peerfinder/detail/Source.h +++ b/include/xrpl/peerfinder/detail/Source.h @@ -7,7 +7,7 @@ #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * A static or dynamic source of peer addresses. @@ -46,4 +46,4 @@ public: fetch(Results& results, beast::Journal journal) = 0; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/SourceStrings.h b/include/xrpl/peerfinder/detail/SourceStrings.h index 325a024764..e9783c775f 100644 --- a/include/xrpl/peerfinder/detail/SourceStrings.h +++ b/include/xrpl/peerfinder/detail/SourceStrings.h @@ -6,7 +6,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Provides addresses from a static set of strings. @@ -22,4 +22,4 @@ public: make(std::string const& name, Strings const& strings); }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Store.h b/include/xrpl/peerfinder/detail/Store.h index 9393ef6c2b..1f9352c6ec 100644 --- a/include/xrpl/peerfinder/detail/Store.h +++ b/include/xrpl/peerfinder/detail/Store.h @@ -6,7 +6,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Abstract persistence for PeerFinder data. @@ -17,7 +17,7 @@ public: virtual ~Store() = default; // load the bootstrap cache - using load_callback = std::function; + using load_callback = std::function; virtual std::size_t load(load_callback const& cb) = 0; @@ -26,11 +26,11 @@ public: { explicit Entry() = default; - beast::IP::Endpoint endpoint; + beast::ip::Endpoint endpoint; int valence{}; }; virtual void save(std::vector const& v) = 0; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/detail/Tuning.h b/include/xrpl/peerfinder/detail/Tuning.h index ea4637dd9d..b4ccfae167 100644 --- a/include/xrpl/peerfinder/detail/Tuning.h +++ b/include/xrpl/peerfinder/detail/Tuning.h @@ -9,7 +9,7 @@ * Heuristically tuned constants. */ /** @{ */ -namespace xrpl::PeerFinder::Tuning { +namespace xrpl::peer_finder::tuning { //--------------------------------------------------------- // @@ -111,5 +111,5 @@ constexpr std::chrono::seconds kLiveCacheSecondsToLive(30); // Note that we ignore the port for purposes of comparison. constexpr std::chrono::seconds kRecentAttemptDuration(60); -} // namespace xrpl::PeerFinder::Tuning +} // namespace xrpl::peer_finder::tuning /** @} */ diff --git a/include/xrpl/peerfinder/make_Manager.h b/include/xrpl/peerfinder/make_Manager.h index 5da372e588..e514734d8b 100644 --- a/include/xrpl/peerfinder/make_Manager.h +++ b/include/xrpl/peerfinder/make_Manager.h @@ -10,7 +10,7 @@ #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * @brief Create a new Manager. @@ -33,4 +33,4 @@ makeManager( Store& store, beast::insight::Collector::ptr const& collector); -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index d49920201e..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; @@ -246,7 +237,15 @@ message TMGetObjectByHash { message TMLedgerNode { required bytes nodedata = 1; - optional bytes nodeid = 2; // missing for ledger base data + + // Used when protocol version <2.3. Not set for ledger base data. + optional bytes nodeid = 2; + + // Used when protocol version >=2.3. Neither value is set for ledger base data. + oneof reference { + bytes id = 3; // Set for inner nodes. + uint32 depth = 4; // Set for leaf nodes. + } } enum TMLedgerInfoType { @@ -293,14 +292,15 @@ message TMLedgerData { } message TMPing { + // Previously used - don't reuse. + reserved 3, 4; + enum pingType { ptPING = 0; // we want a reply ptPONG = 1; // this is a reply } required pingType type = 1; - optional uint32 seq = 2; // detect stale replies, ensure other side is reading - optional uint64 pingTime = 3; // know when we think we sent the ping - optional uint64 netTime = 4; + optional uint32 seq = 2; // detect stale replies, ensure other side is reading } message TMSquelch { diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index a3666c7960..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -47,7 +47,7 @@ ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccoun /** * Validate the amount. - * If validZero is false and amount is beast::zero then invalid amount. + * If validZero is false and amount is beast::kZero then invalid amount. * Return error code if invalid amount. * If pair then validate amount's issue matches one of the pair's issue. */ @@ -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/ApiVersion.h b/include/xrpl/protocol/ApiVersion.h index c3292e6074..b52e705b38 100644 --- a/include/xrpl/protocol/ApiVersion.h +++ b/include/xrpl/protocol/ApiVersion.h @@ -33,7 +33,7 @@ namespace xrpl { * Command line Requests use apiCommandLineVersion. */ -namespace RPC { +namespace rpc { template static constexpr std::integral_constant kApiVersion = {}; @@ -60,7 +60,7 @@ static_assert(kApiMaximumValidVersion >= kApiMaximumSupportedVersion); inline void setVersion(json::Value& parent, unsigned int apiVersion, bool betaEnabled) { - XRPL_ASSERT(apiVersion != kApiInvalidVersion, "xrpl::RPC::setVersion : input is valid"); + XRPL_ASSERT(apiVersion != kApiInvalidVersion, "xrpl::rpc::setVersion : input is valid"); auto& retObj = parent[jss::version] = json::ValueType::Object; @@ -99,12 +99,12 @@ setVersion(json::Value& parent, unsigned int apiVersion, bool betaEnabled) inline unsigned int getAPIVersionNumber(json::Value const& jv, bool betaEnabled) { - static json::Value const kMinVersion(RPC::kApiMinimumSupportedVersion); + static json::Value const kMinVersion(rpc::kApiMinimumSupportedVersion); json::Value const maxVersion( - betaEnabled ? RPC::kApiBetaVersion : RPC::kApiMaximumSupportedVersion); + betaEnabled ? rpc::kApiBetaVersion : rpc::kApiMaximumSupportedVersion); if (!jv.isObject() || !jv.isMember(jss::api_version)) - return RPC::kApiVersionIfUnspecified; + return rpc::kApiVersionIfUnspecified; try { @@ -113,33 +113,33 @@ getAPIVersionNumber(json::Value const& jv, bool betaEnabled) { case json::ValueType::Int: if (rawVersion.asInt() < 0) - return RPC::kApiInvalidVersion; + return rpc::kApiInvalidVersion; [[fallthrough]]; case json::ValueType::UInt: { auto const apiVersion = rawVersion.asUInt(); if (apiVersion < kMinVersion || apiVersion > maxVersion) - return RPC::kApiInvalidVersion; + return rpc::kApiInvalidVersion; return apiVersion; } default: - return RPC::kApiInvalidVersion; + return rpc::kApiInvalidVersion; } } catch (...) { - return RPC::kApiInvalidVersion; + return rpc::kApiInvalidVersion; } } -} // namespace RPC +} // namespace rpc template void forApiVersions(Fn const& fn, Args&&... args) requires // (MaxVer >= MinVer) && // - (MinVer >= RPC::kApiMinimumSupportedVersion) && // - (RPC::kApiMaximumValidVersion >= MaxVer) && requires { + (MinVer >= rpc::kApiMinimumSupportedVersion) && // + (rpc::kApiMaximumValidVersion >= MaxVer) && requires { fn(std::integral_constant{}, std::forward(args)...); fn(std::integral_constant{}, std::forward(args)...); } @@ -158,11 +158,11 @@ template void forAllApiVersions(Fn const& fn, Args&&... args) requires requires { - forApiVersions( + forApiVersions( fn, std::forward(args)...); } { - forApiVersions( + forApiVersions( fn, std::forward(args)...); } 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/BuildInfo.h b/include/xrpl/protocol/BuildInfo.h index 18ba20f23c..c3f90d8f9f 100644 --- a/include/xrpl/protocol/BuildInfo.h +++ b/include/xrpl/protocol/BuildInfo.h @@ -8,7 +8,7 @@ * Versioning information for this build. */ // VFALCO The namespace is deprecated -namespace xrpl::BuildInfo { +namespace xrpl::build_info { /** * Server version. @@ -84,4 +84,4 @@ isXrpldVersion(std::uint64_t version); bool isNewerVersion(std::uint64_t version); -} // namespace xrpl::BuildInfo +} // namespace xrpl::build_info diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index ecf7970aba..5c52fb0ba3 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -6,6 +6,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include #include @@ -301,6 +302,87 @@ verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 co NotTEC checkEncryptedAmountFormat(STObject const& object); +/** + * @brief Checks whether a holder's issuer mirror is encrypted under the + * issuance's currently registered issuer key. + * + * Verifies that the holder's issuer mirror epoch matches the active issuer key + * epoch on the issuance. An absent mirror epoch defaults to epoch 0. A holder without an issuer + * mirror is considered stale, as there is no key anchor for future re-encryptions. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the MPToken's issuer mirror is current. false if stale. + */ +[[nodiscard]] bool +isIssuerMirrorCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Checks whether a holder's auditor mirror is encrypted under the + * issuance's currently registered auditor key. + * + * Verifies that the holder's auditor mirror epoch matches the active auditor key + * epoch on the issuance. An absent mirror epoch defaults to epoch 0. An issuance + * without an auditor key requires no auditor mirror and is considered current. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the auditor mirror is current or not required. + */ +[[nodiscard]] bool +isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Checks whether each mirror a holder is required to have is encrypted + * under the issuance's currently registered ElGamal keys. + * + * Verifies that both the issuer mirror and the auditor mirror (if required) + * are current. This serves as a combined check, ensuring all necessary + * holder mirror epochs match the active key epochs on the issuance. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the required mirrors are current. + */ +[[nodiscard]] bool +areMirrorsCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Set the holder's issuer mirror epoch to match the issuance's current issuer key epoch. + * + * Call this after writing the issuer mirror ciphertext under the issuance's + * currently registered issuer key, so that the mirror reads as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's auditor mirror epoch to match the issuance's current auditor key epoch. + * + * Call this after writing the auditor mirror ciphertext under the issuance's + * currently registered auditor key. Does nothing when the holder has no auditor mirror. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's MPToken mirror epochs to match the issuance's current key epochs. + * + * Call this after writing mirror ciphertexts under the issuance's currently + * registered keys, so that the mirrors read as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setMirrorEpochs(SLE const& issuance, SLE& mptoken); + /** * @brief Verifies revealed amount encryptions for all recipients. * diff --git a/include/xrpl/protocol/ErrorCodes.h b/include/xrpl/protocol/ErrorCodes.h index 8ac7c8c58f..465b6d711f 100644 --- a/include/xrpl/protocol/ErrorCodes.h +++ b/include/xrpl/protocol/ErrorCodes.h @@ -167,7 +167,7 @@ enum WarningCodeI { // VFALCO NOTE these should probably not be in the RPC namespace. -namespace RPC { +namespace rpc { /** * Maps an rpc error code to its token, default message, and HTTP status. @@ -337,7 +337,7 @@ containsError(json::Value const& json); int errorCodeHttpStatus(ErrorCodeI code); -} // namespace RPC +} // namespace rpc /** * Returns a single string with the contents of an RPC error. 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/Indexes.h b/include/xrpl/protocol/Indexes.h index 07493da0bd..0836cffaf7 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -21,8 +22,6 @@ #include namespace xrpl { - -class SeqProxy; /** * Keylet computation functions. * @@ -123,7 +122,7 @@ trustLine(AccountID const& id, Issue const& issue) noexcept */ /** @{ */ Keylet -offer(AccountID const& id, std::uint32_t seq) noexcept; +offer(AccountID const& id, SeqProxy const& seq) noexcept; inline Keylet offer(uint256 const& key) noexcept @@ -136,7 +135,7 @@ offer(uint256 const& key) noexcept * The initial directory page for a specific quality */ Keylet -quality(Keylet const& k, std::uint64_t q) noexcept; +quality(Keylet const& k, std::uint64_t const q) noexcept; /** * The directory for the next lower quality @@ -149,10 +148,7 @@ next(Keylet const& k); */ /** @{ */ Keylet -ticket(AccountID const& id, std::uint32_t ticketSeq); - -Keylet -ticket(AccountID const& id, SeqProxy ticketSeq); +ticket(AccountID const& id, SeqProxy const& ticketSeq); inline Keylet ticket(uint256 const& key) @@ -178,7 +174,7 @@ sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept; */ /** @{ */ Keylet -check(AccountID const& id, std::uint32_t seq) noexcept; +check(AccountID const& id, SeqProxy const& seq) noexcept; inline Keylet check(uint256 const& key) noexcept @@ -225,10 +221,10 @@ ownerDir(AccountID const& id) noexcept; */ /** @{ */ Keylet -page(uint256 const& root, std::uint64_t index = 0) noexcept; +page(uint256 const& root, std::uint64_t const index = 0) noexcept; inline Keylet -page(Keylet const& root, std::uint64_t index = 0) noexcept +page(Keylet const& root, std::uint64_t const index = 0) noexcept { XRPL_ASSERT(root.type == ltDIR_NODE, "xrpl::keylet::page : valid root type"); return page(root.key, index); @@ -239,13 +235,13 @@ page(Keylet const& root, std::uint64_t index = 0) noexcept * An escrow entry */ Keylet -escrow(AccountID const& src, std::uint32_t seq) noexcept; +escrow(AccountID const& src, SeqProxy const& seq) noexcept; /** * A PaymentChannel */ Keylet -payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; +payChannel(AccountID const& src, AccountID const& dst, SeqProxy const& seq) noexcept; /** * NFT page keylets @@ -276,7 +272,7 @@ nftokenPage(Keylet const& k, uint256 const& token); * An offer from an account to buy or sell an NFT */ Keylet -nftokenOffer(AccountID const& owner, std::uint32_t seq); +nftokenOffer(AccountID const& owner, SeqProxy const& seq); inline Keylet nftokenOffer(uint256 const& offer) @@ -316,17 +312,17 @@ bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType); // `seq` is stored as `sfXChainClaimID` in the object Keylet -xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq); +xChainClaimID(STXChainBridge const& bridge, std::uint64_t const seq); // `seq` is stored as `sfXChainAccountCreateCount` in the object Keylet -xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq); +xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t const seq); Keylet did(AccountID const& account) noexcept; Keylet -oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; +oracle(AccountID const& account, std::uint32_t const documentID) noexcept; Keylet credential(AccountID const& subject, AccountID const& issuer, Slice const& credType) noexcept; @@ -337,9 +333,6 @@ credential(uint256 const& key) noexcept return {ltCREDENTIAL, key}; } -Keylet -mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept; - Keylet mptokenIssuance(MPTID const& issuanceID) noexcept; @@ -362,7 +355,7 @@ Keylet mptoken(uint256 const& issuanceKey, AccountID const& holder) noexcept; Keylet -vault(AccountID const& owner, std::uint32_t seq) noexcept; +vault(AccountID const& owner, SeqProxy const& seq) noexcept; inline Keylet vault(uint256 const& vaultKey) @@ -371,7 +364,7 @@ vault(uint256 const& vaultKey) } Keylet -loanBroker(AccountID const& owner, std::uint32_t seq) noexcept; +loanBroker(AccountID const& owner, SeqProxy const& seq) noexcept; inline Keylet loanBroker(uint256 const& key) @@ -380,7 +373,7 @@ loanBroker(uint256 const& key) } Keylet -loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept; +loan(uint256 const& loanBrokerID, SeqProxy const& loanSeq) noexcept; inline Keylet loan(uint256 const& key) @@ -389,7 +382,7 @@ loan(uint256 const& key) } Keylet -permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; +permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept; Keylet permissionedDomain(uint256 const& domainID) noexcept; @@ -407,12 +400,6 @@ getQualityNext(uint256 const& uBase); std::uint64_t getQuality(uint256 const& uBase); -uint256 -getTicketIndex(AccountID const& account, std::uint32_t uSequence); - -uint256 -getTicketIndex(AccountID const& account, SeqProxy ticketSeq); - template // NOLINTNEXTLINE(cppcoreguidelines-pro-type-member-init) struct KeyletDesc @@ -426,6 +413,6 @@ struct KeyletDesc extern std::array, 6> const kDirectAccountKeylets; MPTID -makeMptID(std::uint32_t sequence, AccountID const& account); +makeMptID(std::uint32_t const sequence, AccountID const& account); } // namespace xrpl diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 7c504f6bdd..68205e27e6 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -190,17 +190,6 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ \ - LEDGER_OBJECT(MPTokenIssuanceMutable, \ - LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ - LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ - LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ - LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ - LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ - LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ - LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ - LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ - \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ @@ -294,6 +283,17 @@ getAllLedgerFlags() #pragma pop_macro("TO_MAP") #pragma pop_macro("ALL_LEDGER_FLAGS") +// MPTokenIssuance ImmutableFlags (sfImmutableFlags) +inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002; +inline constexpr std::uint32_t lsifMPTRequireAuth = 0x00000004; +inline constexpr std::uint32_t lsifMPTCanEscrow = 0x00000008; +inline constexpr std::uint32_t lsifMPTCanTrade = 0x00000010; +inline constexpr std::uint32_t lsifMPTCanTransfer = 0x00000020; +inline constexpr std::uint32_t lsifMPTCanClawback = 0x00000040; +inline constexpr std::uint32_t lsifMPTCanHoldConfidentialBalance = 0x00000080; +inline constexpr std::uint32_t lsifMPTMetadata = 0x00010000; +inline constexpr std::uint32_t lsifMPTTransferFee = 0x00020000; + //------------------------------------------------------------------------------ /** 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/MultiApiJson.h b/include/xrpl/protocol/MultiApiJson.h index 9a4882ec55..a0029fa491 100644 --- a/include/xrpl/protocol/MultiApiJson.h +++ b/include/xrpl/protocol/MultiApiJson.h @@ -188,6 +188,6 @@ struct MultiApiJson // Wrapper for Json for all supported API versions. using MultiApiJson = - detail::MultiApiJson; + detail::MultiApiJson; } // namespace xrpl diff --git a/include/xrpl/protocol/NFTSyntheticSerializer.h b/include/xrpl/protocol/NFTSyntheticSerializer.h deleted file mode 100644 index bef05b9a8f..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/PathAsset.h b/include/xrpl/protocol/PathAsset.h index ebf6fb68a4..02de9aa7df 100644 --- a/include/xrpl/protocol/PathAsset.h +++ b/include/xrpl/protocol/PathAsset.h @@ -5,9 +5,11 @@ #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -121,9 +123,32 @@ operator==(PathAsset const& lhs, PathAsset const& rhs) template void -hash_append(Hasher& h, PathAsset const& pathAsset) +hash_append(Hasher& h, PathAsset const& pathAsset) noexcept { - std::visit([&](T const& e) { hash_append(h, e); }, pathAsset.value()); + using beast::hash_append; + using Variant = std::remove_cvref_t; + + static_assert( + std::variant_size_v < 0xFFu, + "PathAsset's discriminant must fit in a byte, leaving 0xFF reserved."); + + // std::visit is not noexcept: it throws bad_variant_access when the variant + // is valueless_by_exception. + if (pathAsset.value().valueless_by_exception()) [[unlikely]] + { + hash_append(h, static_cast(0xFFu)); + return; + } + + hash_append(h, static_cast(pathAsset.value().index())); + std::visit( + [&](T const& e) noexcept { + static_assert( + noexcept(hash_append(h, e)), + "Every PathAsset alternative must be nothrow-hashable."); + hash_append(h, e); + }, + pathAsset.value()); } inline bool 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 e83e1c97b6..61f246c752 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 { @@ -139,7 +141,7 @@ tenthBipsOfValue(T value, TenthBips bips) return value * bips.value() / kTenthBipsPerUnity.value(); } -namespace Lending { +namespace lending { /** * The maximum management fee rate allowed by a loan broker in 1/10 bips. * @@ -236,7 +238,7 @@ static constexpr int kLoanPaymentsPerFeeIncrement = 5; * without an amendment */ static constexpr int kLoanMaximumPaymentsPerTransaction = 100; -} // namespace Lending +} // namespace lending /** * The maximum length of a URI inside an NFT @@ -316,6 +318,58 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; */ constexpr std::uint8_t kVaultMaximumIouScale = 18; +/** + * Vault ledger-entry schema versions. Assigned to newly created + * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before + * activation are left without LEVersion (implicit legacy version 0, + * instant interest recognition). + */ +enum class VaultVersion : uint8_t { + Legacy = 0, + 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 @@ -486,11 +540,21 @@ constexpr std::size_t kEcConvertBackProofLength = */ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE; +/** + * Length of compact equality proof. + */ +constexpr std::size_t kEcEqualityProofLength = 128; + /** * Extra base fee multiplier charged to confidential MPT transactions. */ 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/PublicKey.h b/include/xrpl/protocol/PublicKey.h index 98301af487..833078d741 100644 --- a/include/xrpl/protocol/PublicKey.h +++ b/include/xrpl/protocol/PublicKey.h @@ -260,7 +260,7 @@ calcAccountID(PublicKey const& pk); inline std::string getFingerprint( - beast::IP::Endpoint const& address, + beast::ip::Endpoint const& address, std::optional const& publicKey = std::nullopt, std::optional const& id = std::nullopt) { 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 ad87d106c4..dcbd08170e 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -90,7 +90,11 @@ public: operator=(STObject&& other); STObject(SOTemplate const& type, SField const& name); - STObject(SOTemplate const& type, SerialIter& sit, SField const& name); + STObject( + SOTemplate const& type, + SerialIter& sit, + SField const& name, + bool requireCanonicalOrder = false); STObject(SerialIter& sit, SField const& name, int depth = 0); STObject(SerialIter&& sit, SField const& name); explicit STObject(SField const& name); @@ -123,7 +127,7 @@ public: set(SOTemplate const&); bool - set(SerialIter& u, int depth = 0); + set(SerialIter& u, int depth = 0, bool requireCanonicalOrder = false); [[nodiscard]] SerializedTypeID getSType() const override; @@ -428,8 +432,6 @@ public: bool operator==(STObject const& o) const; - bool - operator!=(STObject const& o) const; class FieldErr; @@ -663,36 +665,6 @@ public: return !lhs.engaged() || *lhs == *rhs; } - friend bool - operator!=(OptionalProxy const& lhs, std::nullopt_t) noexcept - { - return !(lhs == std::nullopt); - } - - friend bool - operator!=(std::nullopt_t, OptionalProxy const& rhs) noexcept - { - return !(rhs == std::nullopt); - } - - friend bool - operator!=(OptionalProxy const& lhs, optional_type const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(optional_type const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(OptionalProxy const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - // Emulate std::optional::value_or [[nodiscard]] value_type valueOr(value_type val) const; @@ -1198,12 +1170,6 @@ STObject::setFieldH160(SField const& field, BaseUInt<160, Tag> const& v) } } -inline bool -STObject::operator!=(STObject const& o) const -{ - return !(*this == o); -} - template V STObject::getFieldByValue(SField const& field) const diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index 23f4e653c4..b91d899071 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -11,6 +12,8 @@ #include #include +#include +#include #include #include #include @@ -64,7 +67,7 @@ public: PathAsset const& asset, AccountID const& issuer); - [[nodiscard]] auto + [[nodiscard]] std::uint32_t getNodeType() const; [[nodiscard]] bool @@ -111,14 +114,22 @@ public: bool operator==(STPathElement const& t) const; - bool - operator!=(STPathElement const& t) const; - private: static std::size_t getHash(STPathElement const& element); }; +template +void +hash_append(Hasher& h, STPathElement const& e) noexcept +{ + using beast::hash_append; + hash_append(h, (e.getNodeType() & STPathElement::TypeAccount) != 0u); + hash_append(h, e.getAccountID()); + hash_append(h, e.getPathAsset()); + hash_append(h, e.getIssuerID()); +} + class STPath final : public CountedObject { std::vector path_; @@ -171,6 +182,17 @@ public: reserve(size_t s); }; +template +void +hash_append(Hasher& h, STPath const& p) noexcept +{ + using beast::hash_append; + for (auto const& e : p) + { + hash_append(h, e); + } +} + //------------------------------------------------------------------------------ // A set of zero or more payment paths @@ -178,11 +200,38 @@ class STPathSet final : public STBase, public CountedObject { std::vector value_; + /** + * Deduplication index over `value_`, for pathfinding. + * The use of a std::unique_ptr is intentional as it + * only requires 8 additional bytes of storage for the pointer + * as opposed to 64 bytes with an optional. This keeps the size + * of the STPathSet to within the `STVar::kMaxSize` limit of 72 bytes. + */ + std::unique_ptr> seen_; + public: + struct DeduplicationTag + { + }; + STPathSet() = default; + /** + * Deduplication tagged constructor. + * Use when you want to ensure that the STPathSet does not contain duplicate paths. + */ + explicit STPathSet(DeduplicationTag); STPathSet(SField const& n); STPathSet(SerialIter& sit, SField const& name); + STPathSet(STPathSet const& other); + STPathSet(STPathSet&&) = default; + + STPathSet& + operator=(STPathSet const& other); + STPathSet& + operator=(STPathSet&&) = default; + + ~STPathSet() override = default; void add(Serializer& s) const override; @@ -192,6 +241,16 @@ public: [[nodiscard]] SerializedTypeID getSType() const override; + /** + * @brief assembleAdd adds a path to the set by combining a base path and a tail element. + * + * @param base The base path. + * @param tail The tail element. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks Requires the STPathSet to be constructed with the DeduplicationTag. The return value + * indicates whether the combined path was inserted (true) or rejected as a duplicate (false). + * It is fine for callers to ignore the return value. + */ bool assembleAdd(STPath const& base, STPathElement const& tail); @@ -205,9 +264,6 @@ public: std::vector::const_reference operator[](std::vector::size_type n) const; - std::vector::reference - operator[](std::vector::size_type n); - [[nodiscard]] std::vector::const_iterator begin() const; @@ -220,11 +276,37 @@ public: [[nodiscard]] bool empty() const; - void + /** + * @brief pushBack adds a path to the set. + * + * @param e The path to add. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + */ + bool pushBack(STPath const& e); + /** + * @brief emplaceBack adds a path to the set. + * + * @param args The arguments to construct the path with. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + * @note The path is constructed before the duplicate check, so on a false + * return the constructed path is discarded and any argument + * forwarded as an rvalue is left in a moved-from state. Use + * pushBack when the caller needs to keep its path on rejection. + */ template - void + bool emplaceBack(Args&&... args); private: @@ -233,6 +315,22 @@ private: STBase* move(std::size_t n, void* buf) override; + /** + * @brief Append a path via `append`, then register it in the deduplication index. + * + * @param append Invoked with `value_`; must append exactly one path to it. + * @return true if the path was kept, false if it was a duplicate and was rolled back. + * @remarks Appends to the vector before touching the index, so that a failed allocation + * there leaves both containers untouched rather than leaving the index holding + * a path the vector does not. If the index insert reports a duplicate, or + * throws, the append is rolled back so the two containers stay consistent; in + * the throwing case the exception propagates. With no index (constructed + * without the DeduplicationTag) the append is unconditional. + */ + template + bool + appendUnique(Append&& append); + friend class detail::STVar; }; @@ -324,7 +422,7 @@ inline STPathElement::STPathElement( hashValue_ = getHash(*this); } -inline auto +inline std::uint32_t STPathElement::getNodeType() const { return type_; @@ -417,12 +515,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)) @@ -515,12 +607,6 @@ STPathSet::operator[](std::vector::size_type n) const return value_[n]; } -inline std::vector::reference -STPathSet::operator[](std::vector::size_type n) -{ - return value_[n]; -} - inline std::vector::const_iterator STPathSet::begin() const { @@ -545,17 +631,50 @@ STPathSet::empty() const return value_.empty(); } -inline void +template +inline bool +STPathSet::appendUnique(Append&& append) +{ + // Append to the vector first, so that a failed allocation there leaves both + // containers untouched rather than leaving the index holding a path the + // vector does not. + append(value_); + + if (seen_ == nullptr) + { + return true; + } + + try + { + if (!seen_->insert(value_.back()).second) + { + // Already present: roll back the append. + value_.pop_back(); + return false; + } + } + catch (...) + { + // The index insert failed, so roll back the append to keep the vector + // and the index consistent. + value_.pop_back(); + throw; + } + return true; +} + +inline bool STPathSet::pushBack(STPath const& e) { - value_.push_back(e); + return appendUnique([&](auto& value) { value.push_back(e); }); } template -inline void +inline bool STPathSet::emplaceBack(Args&&... args) { - value_.emplace_back(std::forward(args)...); + return appendUnique([&](auto& value) { value.emplace_back(std::forward(args)...); }); } } // namespace xrpl diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index d329d42eee..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 @@ -93,12 +95,6 @@ public: [[nodiscard]] SeqProxy getSeqProxy() const; - /** - * Returns the first non-zero value of (Sequence, TicketSequence). - */ - [[nodiscard]] std::uint32_t - getSeqValue() const; - [[nodiscard]] boost::container::flat_set getMentionedAccounts() const; @@ -111,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. */ @@ -126,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& @@ -168,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/STValidation.h b/include/xrpl/protocol/STValidation.h index 444fdfa600..f70e971f87 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -54,6 +54,22 @@ class STValidation final : public STObject, public CountedObject NetClock::time_point seenTime_; public: + /** + * @struct DeserializeOptions + * @brief Options controlling deserialization of a STValidation. + + * @var DeserializeOptions::checkSignature + * Whether to verify the data was signed properly + * + * @var DeserializeOptions::requireCanonicalOrder + * Whether to require the fields to be in canonical order + */ + struct DeserializeOptions + { + bool checkSignature; + bool requireCanonicalOrder; + }; + /** * Construct a STValidation from a peer from serialized data. * @@ -64,12 +80,12 @@ public: * that signed the validation. For manifest based * validators, this should be the NodeID of the master * public key. - * @param checkSignature Whether to verify the data was signed properly + * @param options Options controlling deserialization * * @note Throws if the object is not valid */ template - STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature); + STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options); /** * Construct, sign and trust a new STValidation issued by this node. @@ -108,6 +124,13 @@ public: [[nodiscard]] NodeID const& getNodeID() const noexcept; + /** + * Whether this validation carries a good signature. + * + * Reports false if the signature cannot be checked at all, so a caller + * cannot tell that apart from a bad signature. Either way the validation is + * unusable, and the reason is logged. Only a computed answer is remembered. + */ [[nodiscard]] bool isValid() const noexcept; @@ -163,8 +186,8 @@ private: }; template -STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature) - : STObject(validationFormat(), sit, sfValidation) +STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options) + : STObject(validationFormat(), sit, sfValidation, options.requireCanonicalOrder) , signingPubKey_([this]() { auto const spk = getFieldVL(sfSigningPubKey); @@ -175,7 +198,7 @@ STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool ch }()) , nodeID_(lookupNodeID(signingPubKey_)) { - if (checkSignature && !isValid()) + if (options.checkSignature && !isValid()) { JLOG(debugLog().error()) << "Invalid signature in validation: " << getJson(JsonOptions::Values::None); diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index e6a97be0e7..3686d123d6 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -53,14 +53,29 @@ public: operator=(SeqProxy const& other) = default; /** - * Factory function to return a sequence-based SeqProxy + * Factory function to return a sequence-based SeqProxy. + * Outside of tests, this function should only be used for "secondary" transaction sequences, + * e.g. `sfOfferSequence`, or sequence fields in an existing ledger object. DO NOT use this for + * the "primary" sequence of a transaction, `sfSequence`. */ static constexpr SeqProxy - sequence(std::uint32_t v) + rawSequence(std::uint32_t v) { return SeqProxy{Type::Seq, v}; } + /** + * Factory function to return a ticket-based SeqProxy. + * Outside of tests, this function should only be used for "secondary" transaction sequences, + * e.g. `sfOfferSequence`, or sequence fields in an existing ledger object. DO NOT use this for + * the "primary" ticket sequence of a transaction, `sfTicketSequence`. + */ + static constexpr SeqProxy + rawTicket(std::uint32_t v) + { + return SeqProxy{Type::Ticket, v}; + } + [[nodiscard]] constexpr std::uint32_t value() const { @@ -108,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..997199629a 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,101 @@ private: Blob data_; public: + /** + * A header is never longer than this. The encoder fills a buffer of this + * size and writes only the bytes it used. + */ + static constexpr int kMaxNumberOfBytesInHeader = 3; + + // A field whose size varies is stored as a header holding its length, then + // the field data. The header is 1, 2 or 3 bytes long. Nothing outside it says + // which, so the decoder reads the first byte and its value says how long the + // header is: + // + // 0 ... 192 kMin/kMaxValueOfFirstByteFor1ByteHeader + // 193 ... 240 kMin/kMaxValueOfFirstByteFor2ByteHeader + // 241 ... 254 kMin/kMaxValueOfFirstByteFor3ByteHeader + // 255 belongs to no header + // + // Each range starts one past the end of the range before it. + + static constexpr int kMinValueOfFirstByteFor1ByteHeader = 0; + static constexpr int kMaxValueOfFirstByteFor1ByteHeader = 192; + + static constexpr int kMinValueOfFirstByteFor2ByteHeader = + kMaxValueOfFirstByteFor1ByteHeader + 1; + static constexpr int kMaxValueOfFirstByteFor2ByteHeader = 240; + + static constexpr int kMinValueOfFirstByteFor3ByteHeader = + kMaxValueOfFirstByteFor2ByteHeader + 1; + + static constexpr int kMaxValueOfFirstByteFor3ByteHeader = 254; + + // A length x too big for one byte is split across the header. For 2 bytes: + // + // first byte = 193 + (x - 193) / 256 + // second byte = (x - 193) % 256 + // + // so 300 is stored as 193, 107. For 3 bytes it is the same, from 241, with + // the remainder split across two bytes: 20,000 is stored as 241, 29, 95. + + static constexpr int kNumberOfValuesInOneByte = 256; + static constexpr int kNumberOfValuesInTwoBytes = + kNumberOfValuesInOneByte * kNumberOfValuesInOneByte; + + // Each header length therefore covers a range of field lengths: + // + // 0 ... 192 kMin/kMaxValueOfLengthFor1ByteHeader + // 193 ... 12,480 kMin/kMaxValueOfLengthFor2ByteHeader + // 12,481 ... 918,744 kMin/kMaxValueOfLengthFor3ByteHeader + // + // The encoder always uses the shortest header that fits. + + /** + * A 1 byte header holds the length in the byte itself, so both ends of + * this range are the same numbers as the first byte's own range. + */ + static constexpr int kMinValueOfLengthFor1ByteHeader = kMinValueOfFirstByteFor1ByteHeader; + static constexpr int kMaxValueOfLengthFor1ByteHeader = kMaxValueOfFirstByteFor1ByteHeader; + + static constexpr int kMinValueOfLengthFor2ByteHeader = kMaxValueOfLengthFor1ByteHeader + 1; + + /** + * 48 values of the first byte mean a 2 byte header, and each of them covers + * 256 lengths. The 48 is worked out from the two range ends above, so it + * stays right if either of them changes. + */ + static constexpr int kMaxValueOfLengthFor2ByteHeader = kMinValueOfLengthFor2ByteHeader + + ((kMaxValueOfFirstByteFor2ByteHeader - kMaxValueOfFirstByteFor1ByteHeader) * + kNumberOfValuesInOneByte) - + 1; + + static constexpr int kMinValueOfLengthFor3ByteHeader = kMaxValueOfLengthFor2ByteHeader + 1; + + /** + * 14 values of the first byte mean a 3 byte header, and each of them covers + * 65,536 lengths. Counted the same way, that gives the largest length any + * header can state. + * + * Nothing is accepted or rejected against this. The assertion below uses it + * to check that every length the encoder writes is one a header can state. + */ + static constexpr int kMaxRepresentableLength = kMinValueOfLengthFor3ByteHeader + + ((kMaxValueOfFirstByteFor3ByteHeader - kMaxValueOfFirstByteFor2ByteHeader) * + kNumberOfValuesInTwoBytes) - + 1; + + /** + * The largest length the encoder will write. This is the one number here + * that is picked rather than worked out. The decoder accepts nothing above + * it, so both sides agree on the same set of lengths. + */ + static constexpr int kMaxValueOfLengthFor3ByteHeader = 918744; + + static_assert( + kMaxValueOfLengthFor3ByteHeader <= kMaxRepresentableLength, + "a length the encoder writes must be one a header can state"); + explicit Serializer(int n = 256) { data_.reserve(n); @@ -61,7 +157,7 @@ public: // assemble functions int - add8(unsigned char i); + add8(unsigned char byteValue); int add16(std::uint16_t i); @@ -265,33 +361,95 @@ 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_; - } + /** + * Works out how long a header is, from its first byte. + * + * Each overload of decodeVLLength below reads one header length, so call + * this first to learn which of them to call. + * + * @param firstByte First byte of the header, as read from the stream. + * @return How many bytes the whole header takes, counting firstByte: 1, 2 + * or 3. + * @throws std::overflow_error if firstByte is the one value that starts no + * header. + */ static int - decodeLengthLength(int b1); + decodeLengthLength(std::byte firstByte); + + /** + * Reads the field length out of a 1 byte header. + * + * @param firstByte The single header byte, which is the length itself. + * @return Field length in bytes, from kMinValueOfLengthFor1ByteHeader to + * kMaxValueOfLengthFor1ByteHeader. + * @throws std::overflow_error if firstByte is big enough to mean a longer + * header, in which case it is not a length by itself. + */ static int - decodeVLLength(int b1); + decodeVLLength(std::byte firstByte); + + /** + * Reads the field length out of a 2 byte header. + * + * @param firstByte First header byte. Its value means a 2 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the rest of the length. + * @return Field length in bytes, from kMinValueOfLengthFor2ByteHeader to + * kMaxValueOfLengthFor2ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 2 byte header. + */ static int - decodeVLLength(int b1, int b2); + decodeVLLength(std::byte firstByte, std::byte secondByte); + + /** + * Reads the field length out of a 3 byte header. + * + * @param firstByte First header byte. Its value means a 3 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the middle part of the + * length. + * @param thirdByte Third header byte, holding the low part. + * @return Field length in bytes, from kMinValueOfLengthFor3ByteHeader to + * kMaxValueOfLengthFor3ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 3 byte header, or if the three bytes together state a length above + * kMaxValueOfLengthFor3ByteHeader, which the encoder would not write back. + */ static int - decodeVLLength(int b1, int b2, int b3); + decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte); private: + /** + * Works out how many bytes the header needs for the given length. + * + * This deliberately repeats the width choice addEncoded makes, so that + * addVL's assertion can compare the two. It has no other caller; do not + * reach for it as a utility. + * + * @param length Field length in bytes. + * @return How many header bytes it needs: 1, 2 or 3. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ static int - encodeLengthLength(int length); // length to encode length + encodeLengthLength(int length); + + /** + * Appends the length header for a field of the given length. + * + * The field's own data is not written; the caller appends it next. + * + * @param length Field length in bytes. + * @return Offset within this Serializer at which the header was written. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ int addEncoded(int length); }; @@ -400,9 +558,15 @@ public: void getFieldID(int& type, int& name); - // Returns the size of the VL if the - // next object is a VL. Advances the iterator - // to the beginning of the VL. + /** + * Reads the length header at the read position and steps past it. + * + * @return Field length in bytes. The iterator is left on the first byte of + * the field data. + * @throws std::overflow_error if the header states a length the encoder could + * not have written. + * @throws std::runtime_error if the data runs out before the header does. + */ int getVLDataLength(); 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 730d021254..5702b01d1d 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -129,8 +129,11 @@ enum TEMcodes : TERUnderlyingType { temARRAY_TOO_LARGE, temBAD_TRANSFER_FEE, temINVALID_INNER_BATCH, + temBAD_MPT, temBAD_CIPHERTEXT, + temINVALID_BYTECODE, + temTEMP_DISABLED, }; //------------------------------------------------------------------------------ @@ -179,6 +182,8 @@ enum TEFcodes : TERUnderlyingType { tefINVALID_LEDGER_FIX_TYPE, tefNO_DST_PARTIAL, tefBAD_PATH_COUNT, + tefNO_BYTECODE, + tefBYTECODE_NOT_INCLUDED, }; //------------------------------------------------------------------------------ @@ -370,6 +375,8 @@ enum TECcodes : TERUnderlyingType { tecNO_DELEGATE_PERMISSION = 198, tecBAD_PROOF = 199, tecNO_SPONSOR_PERMISSION = 200, + tecOUT_OF_GAS = 201, + tecBYTECODE_REJECTED = 202, }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 0afdebb898..40edf2239b 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -152,7 +152,14 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; \ TRANSACTION(MPTokenIssuanceSet, \ TF_FLAG(tfMPTLock, 0x00000001) \ - TF_FLAG(tfMPTUnlock, 0x00000002), \ + TF_FLAG(tfMPTUnlock, 0x00000002) \ + TF_FLAG(tfMPTSetCanLock, 0x00000004) \ + TF_FLAG(tfMPTSetRequireAuth, 0x00000008) \ + TF_FLAG(tfMPTSetCanEscrow, 0x00000010) \ + TF_FLAG(tfMPTSetCanTrade, 0x00000020) \ + TF_FLAG(tfMPTSetCanTransfer, 0x00000040) \ + TF_FLAG(tfMPTSetCanClawback, 0x00000080) \ + TF_FLAG(tfMPTSetCanHoldConfidentialBalance, 0x00000100), \ MASK_ADJ(0)) \ \ TRANSACTION(NFTokenCreateOffer, \ @@ -356,38 +363,26 @@ inline constexpr FlagValue tfMPTPaymentMask = ~(tfUniversal | tfPartialPayment); inline constexpr FlagValue tfTrustSetPermissionMask = ~(tfUniversal | tfSetfAuth | tfSetFreeze | tfClearFreeze); -// MPTokenIssuanceCreate MutableFlags: -// Indicating specific fields or flags may be changed after issuance. -inline constexpr FlagValue tmfMPTCanEnableCanLock = lsmfMPTCanEnableCanLock; -inline constexpr FlagValue tmfMPTCanEnableRequireAuth = lsmfMPTCanEnableRequireAuth; -inline constexpr FlagValue tmfMPTCanEnableCanEscrow = lsmfMPTCanEnableCanEscrow; -inline constexpr FlagValue tmfMPTCanEnableCanTrade = lsmfMPTCanEnableCanTrade; -inline constexpr FlagValue tmfMPTCanEnableCanTransfer = lsmfMPTCanEnableCanTransfer; -inline constexpr FlagValue tmfMPTCanEnableCanClawback = lsmfMPTCanEnableCanClawback; -inline constexpr FlagValue tmfMPTCanMutateMetadata = lsmfMPTCanMutateMetadata; -inline constexpr FlagValue tmfMPTCanMutateTransferFee = lsmfMPTCanMutateTransferFee; -inline constexpr FlagValue tmfMPTCannotEnableCanHoldConfidentialBalance = - lsmfMPTCannotEnableCanHoldConfidentialBalance; -inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = - ~(tmfMPTCanEnableCanLock | tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanEscrow | - tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee | - tmfMPTCannotEnableCanHoldConfidentialBalance); +// MPTokenIssuanceCreate / MPTokenIssuanceSet ImmutableFlags: +// Defines the immutable fields and flags specific to MPTokenIssuance. +inline constexpr FlagValue tifMPTCanLock = lsifMPTCanLock; +inline constexpr FlagValue tifMPTRequireAuth = lsifMPTRequireAuth; +inline constexpr FlagValue tifMPTCanEscrow = lsifMPTCanEscrow; +inline constexpr FlagValue tifMPTCanTrade = lsifMPTCanTrade; +inline constexpr FlagValue tifMPTCanTransfer = lsifMPTCanTransfer; +inline constexpr FlagValue tifMPTCanClawback = lsifMPTCanClawback; +inline constexpr FlagValue tifMPTMetadata = lsifMPTMetadata; +inline constexpr FlagValue tifMPTTransferFee = lsifMPTTransferFee; +inline constexpr FlagValue tifMPTCanHoldConfidentialBalance = lsifMPTCanHoldConfidentialBalance; +inline constexpr FlagValue tifMPTokenIssuanceImmutableMask = + ~(tifMPTCanLock | tifMPTRequireAuth | tifMPTCanEscrow | tifMPTCanTrade | tifMPTCanTransfer | + tifMPTCanClawback | tifMPTMetadata | tifMPTTransferFee | tifMPTCanHoldConfidentialBalance); -// MPTokenIssuanceSet MutableFlags: -// Enable mutable capability flags. These flags are one-way: once enabled, -// the corresponding capability cannot be disabled by MPTokenIssuanceSet. - -inline constexpr FlagValue tmfMPTSetCanLock = 0x00000001; -inline constexpr FlagValue tmfMPTSetRequireAuth = 0x00000002; -inline constexpr FlagValue tmfMPTSetCanEscrow = 0x00000004; -inline constexpr FlagValue tmfMPTSetCanTrade = 0x00000008; -inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000010; -inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000020; -inline constexpr FlagValue tmfMPTSetCanHoldConfidentialBalance = 0x00000040; -inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = - ~(tmfMPTSetCanLock | tmfMPTSetRequireAuth | tmfMPTSetCanEscrow | tmfMPTSetCanTrade | - tmfMPTSetCanTransfer | tmfMPTSetCanClawback | tmfMPTSetCanHoldConfidentialBalance); +// MPTokenIssuanceSet set of flags that is used to enable capabilities on an MPTokenIssuance. +// Used as `txFlags & tfMPTokenIssuanceSetEnableFlagMask` to extract the capability-enabling bits. +inline constexpr FlagValue tfMPTokenIssuanceSetEnableFlagMask = tfMPTSetCanLock | + tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | + tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance; // Prior to fixRemoveNFTokenAutoTrustLine, transfer of an NFToken between accounts allowed a // TrustLine to be added to the issuer of that token without explicit permission from that issuer. @@ -430,8 +425,7 @@ inline constexpr FlagValue tfDepositSubTx = ASF_FLAG(asfDefaultRipple, 8) \ ASF_FLAG(asfDepositAuth, 9) \ ASF_FLAG(asfAuthorizedNFTokenMinter, 10) \ - /* 11 is reserved for Hooks amendment */ \ - /* ASF_FLAG(asfTshCollect, 11) */ \ + /* 11 is unused */ \ ASF_FLAG(asfDisallowIncomingNFTokenOffer, 12) \ ASF_FLAG(asfDisallowIncomingCheck, 13) \ ASF_FLAG(asfDisallowIncomingPayChan, 14) \ diff --git a/include/xrpl/protocol/TxSettings.h b/include/xrpl/protocol/TxSettings.h new file mode 100644 index 0000000000..8ea249856a --- /dev/null +++ b/include/xrpl/protocol/TxSettings.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include + +namespace xrpl { + +enum class Delegation { Delegable, NotDelegable }; + +/** + * Operations a transaction is permitted to perform, as a bitfield. + * + * These are declared per-transaction in transactions.macro (via + * TxSettings::privileges) and enforced in InvariantCheck.cpp. + */ +enum class Privilege : std::uint16_t { + NoPriv = 0x0000, // The transaction can not do any of the enumerated operations + CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. + CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, + // which implies createAcct + MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object + MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT + // object, but does not have to + OverrideFreeze = 0x0010, // The transaction can override some freeze rules + ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT + CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance + DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance + MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT + // object (except by issuer) + MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT + // object (except by issuer) + MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. + MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault + MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. +}; + +// The inner static_cast is not redundant: the underlying type is narrower than +// `int`, so the operands integer-promote and the result has to be narrowed back. +// safeCast rejects that narrowing, but every input bit is a Privilege bit by +// construction, so the result is always representable. +constexpr Privilege +operator|(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) | safeCast(rhs))); +} + +constexpr Privilege +operator&(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) & safeCast(rhs))); +} + +/** + * Per-transaction metadata declared in transactions.macro. + * + * Every member has a default, so a transaction only needs to name the settings + * that differ from the common case. See the documentation at the top of + * transactions.macro for the authoring syntax. + * + * This is deliberately not a constexpr-friendly type: amendment identifiers are + * runtime-initialized `extern uint256 const` globals (see Feature.h), so a + * TxSettings can only be built at runtime. + */ +struct TxSettings +{ + /** + * Whether an account may delegate this transaction to another account. + */ + Delegation delegable{Delegation::NotDelegable}; + + /** + * The amendment gating this transaction, or uint256{} if always available. + */ + // The `{}` looks redundant, because BaseUInt's default constructor already + // zeroes the value. It is not: without a default member initializer here, + // every partial designated initializer in transactions.macro trips the + // missing-designated-field-initializers warning, which the build treats as + // an error. + // NOLINTNEXTLINE(readability-redundant-member-init) + uint256 amendment{}; + + /** + * Operations this transaction is permitted to perform. + */ + Privilege privileges{Privilege::NoPriv}; +}; + +} // namespace xrpl diff --git a/include/xrpl/protocol/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/XChainAttestations.h b/include/xrpl/protocol/XChainAttestations.h index 8f1c7a4ce3..ed8ffeb88e 100644 --- a/include/xrpl/protocol/XChainAttestations.h +++ b/include/xrpl/protocol/XChainAttestations.h @@ -20,7 +20,7 @@ namespace xrpl { -namespace Attestations { +namespace attestations { struct AttestationBase { @@ -227,7 +227,7 @@ struct CmpByCreateCount } }; -}; // namespace Attestations +}; // namespace attestations // Result when checking when two attestation match. enum class AttestationMatch { @@ -241,7 +241,7 @@ enum class AttestationMatch { struct XChainClaimAttestation { - using TSignedAttestation = Attestations::AttestationClaim; + using TSignedAttestation = attestations::AttestationClaim; static SField const& arrayFieldName; AccountID keyAccount; @@ -297,7 +297,7 @@ struct XChainClaimAttestation struct XChainCreateAccountAttestation { - using TSignedAttestation = Attestations::AttestationCreateAccount; + using TSignedAttestation = attestations::AttestationCreateAccount; static SField const& arrayFieldName; AccountID keyAccount; diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 12026f3d09..72a310546e 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -34,10 +34,11 @@ concept ValidConstructSTArgs = // and includes a small-object allocation optimization. class STVar { -private: +public: // The largest "small object" we can accommodate static constexpr std::size_t kMaxSize = 72; +private: alignas(std::max_align_t) std::byte d_[kMaxSize] = {}; STBase* p_ = nullptr; @@ -152,10 +153,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 4f1fac82da..e63a7f515d 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,10 +15,14 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) +XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) +XRPL_FEATURE(ConfidentialMPTKeyRotation, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_4_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocolV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(ConfidentialTransfer, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) @@ -59,7 +63,6 @@ XRPL_FIX (PreviousTxnID, Supported::Yes, VoteBehavior::DefaultNo XRPL_FIX (XChainRewardRounding, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (EmptyDID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PriceOracle, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FIX (AMMOverflowOffer, Supported::Yes, VoteBehavior::DefaultYes) XRPL_FIX (FillOrKill, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(XChainBridge, Supported::Yes, VoteBehavior::DefaultNo) @@ -100,6 +103,7 @@ XRPL_RETIRE_FIX(1578) XRPL_RETIRE_FIX(1623) XRPL_RETIRE_FIX(1781) XRPL_RETIRE_FIX(AmendmentMajorityCalc) +XRPL_RETIRE_FIX(AMMOverflowOffer) XRPL_RETIRE_FIX(CheckThreading) XRPL_RETIRE_FIX(DisallowIncomingV1) XRPL_RETIRE_FIX(InnerObjTemplate) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2..04b390a7a4 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -309,6 +309,11 @@ LEDGER_ENTRY(ltFEE_SETTINGS, 0x0073, FeeSettings, fee, ({ {sfBaseFeeDrops, SoeOptional}, {sfReserveBaseDrops, SoeOptional}, {sfReserveIncrementDrops, SoeOptional}, + // Smart Escrow fields + {sfGasLimit, SoeOptional}, + {sfBytecodeSizeLimit, SoeOptional}, + {sfGasPrice, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, {sfPreviousTxnLgrSeq, SoeOptional}, })) @@ -339,6 +344,8 @@ LEDGER_ENTRY(ltESCROW, 0x0075, Escrow, escrow, ({ {sfCondition, SoeOptional}, {sfCancelAfter, SoeOptional}, {sfFinishAfter, SoeOptional}, + {sfBytecode, SoeOptional}, + {sfData, SoeOptional}, {sfSourceTag, SoeOptional}, {sfDestinationTag, SoeOptional}, {sfOwnerNode, SoeRequired}, @@ -404,10 +411,12 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeDefault}, + {sfImmutableFlags, SoeDefault}, {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, + {sfIssuerKeyEpoch, SoeOptional}, + {sfAuditorKeyEpoch, SoeOptional}, {sfConfidentialOutstandingAmount, SoeDefault}, })) @@ -427,6 +436,8 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfConfidentialBalanceVersion, SoeDefault}, {sfIssuerEncryptedBalance, SoeOptional}, {sfAuditorEncryptedBalance, SoeOptional}, + {sfIssuerKeyMirrorEpoch, SoeOptional}, + {sfAuditorKeyMirrorEpoch, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, })) @@ -505,6 +516,10 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfShareMPTID, SoeRequired}, {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 4ef76c8b75..2cf35743ae 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -18,13 +18,16 @@ TYPED_SFIELD(sfMethod, UINT8, 2) TYPED_SFIELD(sfTransactionResult, UINT8, 3) TYPED_SFIELD(sfScale, UINT8, 4) TYPED_SFIELD(sfAssetScale, UINT8, 5) +TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) -TYPED_SFIELD(sfHookResult, UINT8, 18) +// 18 unused TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) +TYPED_SFIELD(sfContractResult, UINT8, 21) +TYPED_SFIELD(sfVaultKind, UINT8, 22) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -36,10 +39,7 @@ TYPED_SFIELD(sfDiscountedFee, UINT16, 6) // 16-bit integers (uncommon) TYPED_SFIELD(sfVersion, UINT16, 16) -TYPED_SFIELD(sfHookStateChangeCount, UINT16, 17) -TYPED_SFIELD(sfHookEmitCount, UINT16, 18) -TYPED_SFIELD(sfHookExecutionIndex, UINT16, 19) -TYPED_SFIELD(sfHookApiVersion, UINT16, 20) +// 17 to 20 unused TYPED_SFIELD(sfLedgerFixType, UINT16, 21) TYPED_SFIELD(sfManagementFeeRate, UINT16, 22) // 1/10 basis points (bips) @@ -90,14 +90,12 @@ TYPED_SFIELD(sfTicketSequence, UINT32, 41) TYPED_SFIELD(sfNFTokenTaxon, UINT32, 42) TYPED_SFIELD(sfMintedNFTokens, UINT32, 43) TYPED_SFIELD(sfBurnedNFTokens, UINT32, 44) -TYPED_SFIELD(sfHookStateCount, UINT32, 45) -TYPED_SFIELD(sfEmitGeneration, UINT32, 46) -// 47 reserved for Hooks +// 45 to 47 unused TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) -TYPED_SFIELD(sfMutableFlags, UINT32, 53) +TYPED_SFIELD(sfImmutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) TYPED_SFIELD(sfGracePeriod, UINT32, 56) @@ -119,6 +117,17 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) +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) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -136,9 +145,7 @@ TYPED_SFIELD(sfNFTokenOfferNode, UINT64, 12) TYPED_SFIELD(sfEmitBurden, UINT64, 13) // 64-bit integers (uncommon) -TYPED_SFIELD(sfHookOn, UINT64, 16) -TYPED_SFIELD(sfHookInstructionCount, UINT64, 17) -TYPED_SFIELD(sfHookReturnCode, UINT64, 18) +// 16 to 18 unused TYPED_SFIELD(sfReferenceCount, UINT64, 19) TYPED_SFIELD(sfXChainClaimID, UINT64, 20) TYPED_SFIELD(sfXChainAccountCreateCount, UINT64, 21) @@ -202,10 +209,7 @@ TYPED_SFIELD(sfPreviousPageMin, UINT256, 26) TYPED_SFIELD(sfNextPageMin, UINT256, 27) TYPED_SFIELD(sfNFTokenBuyOffer, UINT256, 28) TYPED_SFIELD(sfNFTokenSellOffer, UINT256, 29) -TYPED_SFIELD(sfHookStateKey, UINT256, 30) -TYPED_SFIELD(sfHookHash, UINT256, 31) -TYPED_SFIELD(sfHookNamespace, UINT256, 32) -TYPED_SFIELD(sfHookSetTxnID, UINT256, 33) +// 30 to 33 unused TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::kSmdPseudoAccount | SField::kSmdDefault) @@ -236,8 +240,10 @@ TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault) -// int32 +// 32-bit signed (common) TYPED_SFIELD(sfLoanScale, INT32, 1) +TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2) +TYPED_SFIELD(sfVMReturnCode, INT32, 3) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -259,15 +265,13 @@ TYPED_SFIELD(sfMinimumOffer, AMOUNT, 16) TYPED_SFIELD(sfRippleEscrow, AMOUNT, 17) TYPED_SFIELD(sfDeliveredAmount, AMOUNT, 18) TYPED_SFIELD(sfNFTokenBrokerFee, AMOUNT, 19) - -// Reserve 20 & 21 for Hooks. - +// 20 to 21 unused // currency amount (fees) TYPED_SFIELD(sfBaseFeeDrops, AMOUNT, 22) TYPED_SFIELD(sfReserveBaseDrops, AMOUNT, 23) TYPED_SFIELD(sfReserveIncrementDrops, AMOUNT, 24) -// currency amount (AMM) +// currency amount (more) TYPED_SFIELD(sfLPTokenOut, AMOUNT, 25) TYPED_SFIELD(sfLPTokenIn, AMOUNT, 26) TYPED_SFIELD(sfEPrice, AMOUNT, 27) @@ -277,6 +281,7 @@ TYPED_SFIELD(sfMinAccountCreateAmount, AMOUNT, 30) TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31) TYPED_SFIELD(sfFeeAmount, AMOUNT, 32) TYPED_SFIELD(sfMaxFee, AMOUNT, 33) +TYPED_SFIELD(sfFeeAmountDelta, AMOUNT, 34) // variable length (common) TYPED_SFIELD(sfPublicKey, VL, 1) @@ -301,10 +306,7 @@ TYPED_SFIELD(sfMasterSignature, VL, 18, SField::kSmdDefault, SFi TYPED_SFIELD(sfUNLModifyValidator, VL, 19) TYPED_SFIELD(sfValidatorToDisable, VL, 20) TYPED_SFIELD(sfValidatorToReEnable, VL, 21) -TYPED_SFIELD(sfHookStateData, VL, 22) -TYPED_SFIELD(sfHookReturnString, VL, 23) -TYPED_SFIELD(sfHookParameterName, VL, 24) -TYPED_SFIELD(sfHookParameterValue, VL, 25) +// 22 to 25 unused TYPED_SFIELD(sfDIDDocument, VL, 26) TYPED_SFIELD(sfData, VL, 27) TYPED_SFIELD(sfAssetClass, VL, 28) @@ -326,6 +328,7 @@ TYPED_SFIELD(sfAuditorEncryptedAmount, VL, 43) TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) TYPED_SFIELD(sfAmountCommitment, VL, 45) TYPED_SFIELD(sfBalanceCommitment, VL, 46) +TYPED_SFIELD(sfBytecode, VL, 47) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) @@ -342,7 +345,7 @@ TYPED_SFIELD(sfHolder, ACCOUNT, 11) TYPED_SFIELD(sfDelegate, ACCOUNT, 12) // account (uncommon) -TYPED_SFIELD(sfHookAccount, ACCOUNT, 16) +// 16 unused TYPED_SFIELD(sfOtherChainSource, ACCOUNT, 18) TYPED_SFIELD(sfOtherChainDestination, ACCOUNT, 19) TYPED_SFIELD(sfAttestationSignerAccount, ACCOUNT, 20) @@ -395,7 +398,7 @@ UNTYPED_SFIELD(sfMemo, OBJECT, 10) UNTYPED_SFIELD(sfSignerEntry, OBJECT, 11) UNTYPED_SFIELD(sfNFToken, OBJECT, 12) UNTYPED_SFIELD(sfEmitDetails, OBJECT, 13) -UNTYPED_SFIELD(sfHook, OBJECT, 14) +// 14 unused UNTYPED_SFIELD(sfPermission, OBJECT, 15) // inner object (uncommon) @@ -403,11 +406,7 @@ UNTYPED_SFIELD(sfSigner, OBJECT, 16) // 17 unused UNTYPED_SFIELD(sfMajority, OBJECT, 18) UNTYPED_SFIELD(sfDisabledValidator, OBJECT, 19) -UNTYPED_SFIELD(sfEmittedTxn, OBJECT, 20) -UNTYPED_SFIELD(sfHookExecution, OBJECT, 21) -UNTYPED_SFIELD(sfHookDefinition, OBJECT, 22) -UNTYPED_SFIELD(sfHookParameter, OBJECT, 23) -UNTYPED_SFIELD(sfHookGrant, OBJECT, 24) +// 20 to 24 unused UNTYPED_SFIELD(sfVoteEntry, OBJECT, 25) UNTYPED_SFIELD(sfAuctionSlot, OBJECT, 26) UNTYPED_SFIELD(sfAuthAccount, OBJECT, 27) @@ -435,16 +434,14 @@ UNTYPED_SFIELD(sfSufficient, ARRAY, 7) UNTYPED_SFIELD(sfAffectedNodes, ARRAY, 8) UNTYPED_SFIELD(sfMemos, ARRAY, 9) UNTYPED_SFIELD(sfNFTokens, ARRAY, 10) -UNTYPED_SFIELD(sfHooks, ARRAY, 11) +// 11 unused UNTYPED_SFIELD(sfVoteSlots, ARRAY, 12) UNTYPED_SFIELD(sfAdditionalBooks, ARRAY, 13) // array of objects (uncommon) UNTYPED_SFIELD(sfMajorities, ARRAY, 16) UNTYPED_SFIELD(sfDisabledValidators, ARRAY, 17) -UNTYPED_SFIELD(sfHookExecutions, ARRAY, 18) -UNTYPED_SFIELD(sfHookParameters, ARRAY, 19) -UNTYPED_SFIELD(sfHookGrants, ARRAY, 20) +// 18 to 20 unused UNTYPED_SFIELD(sfXChainClaimAttestations, ARRAY, 21) UNTYPED_SFIELD(sfXChainCreateAccountAttestations, ARRAY, 22) // 23 unused diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c00..cb3d5fd2b1 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,33 +64,28 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, {sfCancelAfter, SoeOptional}, {sfFinishAfter, SoeOptional}, - {sfDestinationTag, SoeOptional}, + {sfBytecode, SoeOptional}, + {sfData, SoeOptional}, })) /** This transaction type completes an existing escrow. */ #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, {sfCondition, SoeOptional}, {sfCredentialIDs, SoeOptional}, + {sfGas, SoeOptional}, })) @@ -79,9 +94,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +112,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +122,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +134,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +147,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +157,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +170,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +180,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +193,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +203,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +216,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +229,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +240,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +248,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +259,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +270,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +286,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +302,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +312,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +324,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +332,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +342,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +352,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +370,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +386,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +402,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +421,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +433,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +447,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +462,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +471,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +481,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +492,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +502,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +523,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +547,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +556,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +568,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +580,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +588,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +603,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +613,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,16 +625,18 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, {sfMaximumAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -713,9 +644,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,16 +658,14 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, })) @@ -744,9 +675,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +690,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +703,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +714,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +726,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +738,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +749,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +759,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +772,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -862,6 +785,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. */ @@ -869,9 +795,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -884,9 +811,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -897,9 +826,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -910,14 +840,17 @@ TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, # include #endif TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back tokens from a vault. */ @@ -925,9 +858,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -939,9 +873,9 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, + ({ + .amendment = featureBatchV1_1, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -954,9 +888,11 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, {sfData, SoeOptional}, @@ -971,9 +907,11 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, })) @@ -982,9 +920,10 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -994,13 +933,16 @@ TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back First Loss Capital from a Loan Broker to @@ -1009,9 +951,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, })) @@ -1021,9 +964,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, {sfCounterparty, SoeOptional}, @@ -1048,9 +993,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1059,12 +1005,14 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1073,9 +1021,11 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -1085,9 +1035,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1104,9 +1054,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1116,9 +1064,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1134,9 +1080,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1155,9 +1099,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1170,9 +1112,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1183,15 +1125,26 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, - {sfFeeAmount, SoeOptional}, + {sfFeeAmountDelta, SoeOptional}, {sfMaxFee, SoeOptional}, - {sfRemainingOwnerCount, SoeOptional}, + {sfRemainingOwnerCountDelta, SoeOptional}, +})) + +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialMPTKeyRotation}), + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfIssuerEncryptedAmount, SoeOptional}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfZKProof, SoeRequired}, })) /** This system-generated transaction type is used to update the status of the various amendments. @@ -1202,9 +1155,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1214,9 +1165,7 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1228,6 +1177,10 @@ TRANSACTION(ttFEE, 101, SetFee, {sfBaseFeeDrops, SoeOptional}, {sfReserveBaseDrops, SoeOptional}, {sfReserveIncrementDrops, SoeOptional}, + // Smart Escrow fields + {sfGasLimit, SoeOptional}, + {sfBytecodeSizeLimit, SoeOptional}, + {sfGasPrice, SoeOptional}, })) /** This system-generated transaction type is used to update the network's negative UNL @@ -1235,9 +1188,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca31..b294a846da 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -278,6 +278,7 @@ JSS(frozen_balances); // out: GatewayBalances JSS(full); // in: LedgerClearer, handlers/Ledger JSS(full_reply); // out: PathFind JSS(fullbelow_size); // out: GetCounts +JSS(gateway); // in: noripple_check JSS(git); // out: server_info JSS(good); // out: RPCVersion JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field @@ -481,6 +482,7 @@ JSS(ports); // out: NetworkOPs JSS(previous); // out: Reservations JSS(previous_ledger); // out: LedgerPropose JSS(price); // out: amm_info, AuctionSlot +JSS(problems); // out: noripple_check JSS(proof); // in: BookOffers JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus @@ -660,6 +662,7 @@ JSS(url); // in/out: Subscribe, Unsubscribe JSS(url_password); // in: Subscribe JSS(url_username); // in: Subscribe JSS(urlgravatar); // +JSS(user); // in: noripple_check JSS(username); // in: Subscribe JSS(validated); // out: NetworkOPs, RPCHelpers, AccountTx*, Tx JSS(validator_list_expires); // out: NetworkOps, ValidatorList diff --git a/include/xrpl/protocol_autogen/README.md b/include/xrpl/protocol_autogen/README.md index 608ffed085..ed649a05fc 100644 --- a/include/xrpl/protocol_autogen/README.md +++ b/include/xrpl/protocol_autogen/README.md @@ -23,6 +23,16 @@ By default, `CODEGEN_VENV_DIR` points to `.venv` in the project root. The `setup_code_gen` target creates a venv there and installs the required packages. The `code_gen` target then uses the venv's Python interpreter to run generation. +Generation is pure Python, so the same targets are also available as a +standalone project that needs neither the dependencies nor a compiler. This is +what CI uses, and it is handy if you only want to regenerate these files: + +```bash +cmake -S cmake/codegen -B build/codegen +cmake --build build/codegen --target setup_code_gen +cmake --build build/codegen --target code_gen +``` + ### Python Dependencies The code generation requires the following Python packages (installed by `setup_code_gen`): diff --git a/include/xrpl/protocol_autogen/ledger_entries/Escrow.h b/include/xrpl/protocol_autogen/ledger_entries/Escrow.h index 106d69722f..fc01c8b858 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Escrow.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Escrow.h @@ -174,6 +174,54 @@ public: return this->sle_->isFieldPresent(sfFinishAfter); } + /** + * @brief Get sfBytecode (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBytecode() const + { + if (hasBytecode()) + return this->sle_->at(sfBytecode); + return std::nullopt; + } + + /** + * @brief Check if sfBytecode is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBytecode() const + { + return this->sle_->isFieldPresent(sfBytecode); + } + + /** + * @brief Get sfData (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getData() const + { + if (hasData()) + return this->sle_->at(sfData); + return std::nullopt; + } + + /** + * @brief Check if sfData is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasData() const + { + return this->sle_->isFieldPresent(sfData); + } + /** * @brief Get sfSourceTag (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -453,6 +501,28 @@ public: return *this; } + /** + * @brief Set sfBytecode (SoeOptional) + * @return Reference to this builder for method chaining. + */ + EscrowBuilder& + setBytecode(std::decay_t const& value) + { + object_[sfBytecode] = value; + return *this; + } + + /** + * @brief Set sfData (SoeOptional) + * @return Reference to this builder for method chaining. + */ + EscrowBuilder& + setData(std::decay_t const& value) + { + object_[sfData] = value; + return *this; + } + /** * @brief Set sfSourceTag (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h b/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h index 21478f749d..826ad6983e 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h +++ b/include/xrpl/protocol_autogen/ledger_entries/FeeSettings.h @@ -213,6 +213,78 @@ public: return this->sle_->isFieldPresent(sfReserveIncrementDrops); } + /** + * @brief Get sfGasLimit (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getGasLimit() const + { + if (hasGasLimit()) + return this->sle_->at(sfGasLimit); + return std::nullopt; + } + + /** + * @brief Check if sfGasLimit is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasGasLimit() const + { + return this->sle_->isFieldPresent(sfGasLimit); + } + + /** + * @brief Get sfBytecodeSizeLimit (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBytecodeSizeLimit() const + { + if (hasBytecodeSizeLimit()) + return this->sle_->at(sfBytecodeSizeLimit); + return std::nullopt; + } + + /** + * @brief Check if sfBytecodeSizeLimit is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBytecodeSizeLimit() const + { + return this->sle_->isFieldPresent(sfBytecodeSizeLimit); + } + + /** + * @brief Get sfGasPrice (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getGasPrice() const + { + if (hasGasPrice()) + return this->sle_->at(sfGasPrice); + return std::nullopt; + } + + /** + * @brief Check if sfGasPrice is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasGasPrice() const + { + return this->sle_->isFieldPresent(sfGasPrice); + } + /** * @brief Get sfPreviousTxnID (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -375,6 +447,39 @@ public: return *this; } + /** + * @brief Set sfGasLimit (SoeOptional) + * @return Reference to this builder for method chaining. + */ + FeeSettingsBuilder& + setGasLimit(std::decay_t const& value) + { + object_[sfGasLimit] = value; + return *this; + } + + /** + * @brief Set sfBytecodeSizeLimit (SoeOptional) + * @return Reference to this builder for method chaining. + */ + FeeSettingsBuilder& + setBytecodeSizeLimit(std::decay_t const& value) + { + object_[sfBytecodeSizeLimit] = value; + return *this; + } + + /** + * @brief Set sfGasPrice (SoeOptional) + * @return Reference to this builder for method chaining. + */ + FeeSettingsBuilder& + setGasPrice(std::decay_t const& value) + { + object_[sfGasPrice] = value; + return *this; + } + /** * @brief Set sfPreviousTxnID (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index 874d779d09..c1601fc2fa 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -268,6 +268,54 @@ public: return this->sle_->isFieldPresent(sfAuditorEncryptedBalance); } + /** + * @brief Get sfIssuerKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerKeyMirrorEpoch() const + { + if (hasIssuerKeyMirrorEpoch()) + return this->sle_->at(sfIssuerKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfIssuerKeyMirrorEpoch); + } + + /** + * @brief Get sfAuditorKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorKeyMirrorEpoch() const + { + if (hasAuditorKeyMirrorEpoch()) + return this->sle_->at(sfAuditorKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfAuditorKeyMirrorEpoch); + } + /** * @brief Get sfHolderEncryptionKey (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -471,6 +519,28 @@ public: return *this; } + /** + * @brief Set sfIssuerKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setIssuerKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfIssuerKeyMirrorEpoch] = value; + return *this; + } + + /** + * @brief Set sfAuditorKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setAuditorKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfAuditorKeyMirrorEpoch] = value; + return *this; + } + /** * @brief Set sfHolderEncryptionKey (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8518a0fe14..74b5e3c4eb 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -256,27 +256,27 @@ public: } /** - * @brief Get sfMutableFlags (SoeDefault) + * @brief Get sfImmutableFlags (SoeDefault) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) - return this->sle_->at(sfMutableFlags); + if (hasImmutableFlags()) + return this->sle_->at(sfImmutableFlags); return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->sle_->isFieldPresent(sfMutableFlags); + return this->sle_->isFieldPresent(sfImmutableFlags); } /** @@ -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. @@ -557,13 +605,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeDefault) + * @brief Set sfImmutableFlags (SoeDefault) * @return Reference to this builder for method chaining. */ MPTokenIssuanceBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } @@ -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 2bf92b4f5d..389ffb4c46 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,102 @@ public: { return this->sle_->isFieldPresent(sfScale); } + + /** + * @brief Get sfLEVersion (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getLEVersion() const + { + if (hasLEVersion()) + return this->sle_->at(sfLEVersion); + return std::nullopt; + } + + /** + * @brief Check if sfLEVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasLEVersion() const + { + 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); + } }; /** @@ -508,6 +604,50 @@ public: return *this; } + /** + * @brief Set sfLEVersion (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setLEVersion(std::decay_t const& value) + { + object_[sfLEVersion] = value; + 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 dec7f733c9..d23e6409d9 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -19,9 +19,9 @@ class ConfidentialMPTConvertBuilder; * @brief Transaction: ConfidentialMPTConvert * * Type: ttCONFIDENTIAL_MPT_CONVERT (85) - * Delegable: Delegation::Delegable + * 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/ConfidentialMPTMirrorUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..1fc25bca99 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,266 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMirrorUpdateBuilder; + +/** + * @brief Transaction: ConfidentialMPTMirrorUpdate + * + * Type: ttCONFIDENTIAL_MPT_MIRROR_UPDATE (92) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMirrorUpdateBuilder to construct new transactions. + */ +class ConfidentialMPTMirrorUpdate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MIRROR_UPDATE; + + /** + * @brief Construct a ConfidentialMPTMirrorUpdate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMirrorUpdate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdate"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfHolder (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolder() const + { + if (hasHolder()) + { + return this->tx_->at(sfHolder); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolder is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolder() const + { + return this->tx_->isFieldPresent(sfHolder); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedAmount() const + { + if (hasIssuerEncryptedAmount()) + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTMirrorUpdate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTMirrorUpdateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMirrorUpdateBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMirrorUpdateBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTMirrorUpdateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMirrorUpdateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MIRROR_UPDATE) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMirrorUpdate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMirrorUpdate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMirrorUpdate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index b994e4ec07..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. @@ -58,6 +58,32 @@ public: return this->tx_->at(sfDestination); } + /** + * @brief Get sfDestinationTag (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestinationTag() const + { + if (hasDestinationTag()) + { + return this->tx_->at(sfDestinationTag); + } + return std::nullopt; + } + + /** + * @brief Check if sfDestinationTag is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestinationTag() const + { + return this->tx_->isFieldPresent(sfDestinationTag); + } + /** * @brief Get sfAmount (SoeRequired) * @note This field supports MPT (Multi-Purpose Token) amounts. @@ -149,29 +175,55 @@ public: } /** - * @brief Get sfDestinationTag (SoeOptional) + * @brief Get sfBytecode (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] - protocol_autogen::Optional - getDestinationTag() const + protocol_autogen::Optional + getBytecode() const { - if (hasDestinationTag()) + if (hasBytecode()) { - return this->tx_->at(sfDestinationTag); + return this->tx_->at(sfBytecode); } return std::nullopt; } /** - * @brief Check if sfDestinationTag is present. + * @brief Check if sfBytecode is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasDestinationTag() const + hasBytecode() const { - return this->tx_->isFieldPresent(sfDestinationTag); + return this->tx_->isFieldPresent(sfBytecode); + } + + /** + * @brief Get sfData (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getData() const + { + if (hasData()) + { + return this->tx_->at(sfData); + } + return std::nullopt; + } + + /** + * @brief Check if sfData is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasData() const + { + return this->tx_->isFieldPresent(sfData); } }; @@ -232,6 +284,17 @@ public: return *this; } + /** + * @brief Set sfDestinationTag (SoeOptional) + * @return Reference to this builder for method chaining. + */ + EscrowCreateBuilder& + setDestinationTag(std::decay_t const& value) + { + object_[sfDestinationTag] = value; + return *this; + } + /** * @brief Set sfAmount (SoeRequired) * @note This field supports MPT (Multi-Purpose Token) amounts. @@ -278,13 +341,24 @@ public: } /** - * @brief Set sfDestinationTag (SoeOptional) + * @brief Set sfBytecode (SoeOptional) * @return Reference to this builder for method chaining. */ EscrowCreateBuilder& - setDestinationTag(std::decay_t const& value) + setBytecode(std::decay_t const& value) { - object_[sfDestinationTag] = value; + object_[sfBytecode] = value; + return *this; + } + + /** + * @brief Set sfData (SoeOptional) + * @return Reference to this builder for method chaining. + */ + EscrowCreateBuilder& + setData(std::decay_t const& value) + { + object_[sfData] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 2476def5c2..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. @@ -146,6 +146,32 @@ public: { return this->tx_->isFieldPresent(sfCredentialIDs); } + + /** + * @brief Get sfGas (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getGas() const + { + if (hasGas()) + { + return this->tx_->at(sfGas); + } + return std::nullopt; + } + + /** + * @brief Check if sfGas is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasGas() const + { + return this->tx_->isFieldPresent(sfGas); + } }; /** @@ -249,6 +275,17 @@ public: return *this; } + /** + * @brief Set sfGas (SoeOptional) + * @return Reference to this builder for method chaining. + */ + EscrowFinishBuilder& + setGas(std::decay_t const& value) + { + object_[sfGas] = value; + return *this; + } + /** * @brief Build and return the EscrowFinish wrapper. * @param publicKey The public key for signing. 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 e6fece8354..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. @@ -178,29 +178,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } }; @@ -302,13 +302,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceCreateBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } 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 803868c640..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. @@ -163,29 +163,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } /** @@ -341,13 +341,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceSetBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h index 325d2d7fbd..6c858be721 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h @@ -21,7 +21,7 @@ class NFTokenAcceptOfferBuilder; * Type: ttNFTOKEN_ACCEPT_OFFER (29) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenAcceptOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h index ec423ea468..ac831bf45e 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h @@ -21,7 +21,7 @@ class NFTokenBurnBuilder; * Type: ttNFTOKEN_BURN (26) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenBurnBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h index 4c4fb1dc65..81f4f3a848 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h @@ -21,7 +21,7 @@ class NFTokenCancelOfferBuilder; * Type: ttNFTOKEN_CANCEL_OFFER (28) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCancelOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h index a535a578e0..683436f4fd 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h @@ -21,7 +21,7 @@ class NFTokenCreateOfferBuilder; * Type: ttNFTOKEN_CREATE_OFFER (27) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCreateOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h index 5af41eb3dd..5a4e3b5b1c 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h @@ -21,7 +21,7 @@ class NFTokenMintBuilder; * Type: ttNFTOKEN_MINT (25) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenMintBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h index 9b9701fed6..84f1e395d4 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h @@ -21,7 +21,7 @@ class NFTokenModifyBuilder; * Type: ttNFTOKEN_MODIFY (61) * Delegable: Delegation::Delegable * Amendment: featureDynamicNFT - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCancel.h b/include/xrpl/protocol_autogen/transactions/OfferCancel.h index 5e6010e0dd..3e52ebf24b 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCancel.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCancel.h @@ -21,7 +21,7 @@ class OfferCancelBuilder; * Type: ttOFFER_CANCEL (8) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index ffc1216297..774921d87a 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -21,7 +21,7 @@ class OfferCreateBuilder; * Type: ttOFFER_CREATE (7) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleDelete.h b/include/xrpl/protocol_autogen/transactions/OracleDelete.h index ebdc8fb7e9..e50b6f6b02 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleDelete.h +++ b/include/xrpl/protocol_autogen/transactions/OracleDelete.h @@ -21,7 +21,7 @@ class OracleDeleteBuilder; * Type: ttORACLE_DELETE (52) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleSet.h b/include/xrpl/protocol_autogen/transactions/OracleSet.h index 0ec6d5cad0..03e4ffc518 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleSet.h +++ b/include/xrpl/protocol_autogen/transactions/OracleSet.h @@ -21,7 +21,7 @@ class OracleSetBuilder; * Type: ttORACLE_SET (51) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Payment.h b/include/xrpl/protocol_autogen/transactions/Payment.h index 389900bf12..cb177a8d08 100644 --- a/include/xrpl/protocol_autogen/transactions/Payment.h +++ b/include/xrpl/protocol_autogen/transactions/Payment.h @@ -21,7 +21,7 @@ class PaymentBuilder; * Type: ttPAYMENT (0) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: CreateAcct | MayCreateMpt + * Privileges: Privilege::CreateAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h index 4c567b13f4..06892955db 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h @@ -21,7 +21,7 @@ class PaymentChannelClaimBuilder; * Type: ttPAYCHAN_CLAIM (15) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h index 0a513d575a..2a3aebca4c 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h @@ -21,7 +21,7 @@ class PaymentChannelCreateBuilder; * Type: ttPAYCHAN_CREATE (13) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h index 51210dd796..9a8c452b0b 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h @@ -21,7 +21,7 @@ class PaymentChannelFundBuilder; * Type: ttPAYCHAN_FUND (14) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelFundBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h index 3db921776c..1b16b13116 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h @@ -21,7 +21,7 @@ class PermissionedDomainDeleteBuilder; * Type: ttPERMISSIONED_DOMAIN_DELETE (63) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h index 3e352cad76..30832aec8c 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h @@ -21,7 +21,7 @@ class PermissionedDomainSetBuilder; * Type: ttPERMISSIONED_DOMAIN_SET (62) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetFee.h b/include/xrpl/protocol_autogen/transactions/SetFee.h index 177f39199b..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. @@ -254,6 +254,84 @@ public: { return this->tx_->isFieldPresent(sfReserveIncrementDrops); } + + /** + * @brief Get sfGasLimit (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getGasLimit() const + { + if (hasGasLimit()) + { + return this->tx_->at(sfGasLimit); + } + return std::nullopt; + } + + /** + * @brief Check if sfGasLimit is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasGasLimit() const + { + return this->tx_->isFieldPresent(sfGasLimit); + } + + /** + * @brief Get sfBytecodeSizeLimit (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBytecodeSizeLimit() const + { + if (hasBytecodeSizeLimit()) + { + return this->tx_->at(sfBytecodeSizeLimit); + } + return std::nullopt; + } + + /** + * @brief Check if sfBytecodeSizeLimit is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBytecodeSizeLimit() const + { + return this->tx_->isFieldPresent(sfBytecodeSizeLimit); + } + + /** + * @brief Get sfGasPrice (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getGasPrice() const + { + if (hasGasPrice()) + { + return this->tx_->at(sfGasPrice); + } + return std::nullopt; + } + + /** + * @brief Check if sfGasPrice is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasGasPrice() const + { + return this->tx_->isFieldPresent(sfGasPrice); + } }; /** @@ -386,6 +464,39 @@ public: return *this; } + /** + * @brief Set sfGasLimit (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SetFeeBuilder& + setGasLimit(std::decay_t const& value) + { + object_[sfGasLimit] = value; + return *this; + } + + /** + * @brief Set sfBytecodeSizeLimit (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SetFeeBuilder& + setBytecodeSizeLimit(std::decay_t const& value) + { + object_[sfBytecodeSizeLimit] = value; + return *this; + } + + /** + * @brief Set sfGasPrice (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SetFeeBuilder& + setGasPrice(std::decay_t const& value) + { + object_[sfGasPrice] = value; + return *this; + } + /** * @brief Build and return the SetFee wrapper. * @param publicKey The public key for signing. 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 0124da5e58..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. @@ -100,29 +100,29 @@ public: } /** - * @brief Get sfFeeAmount (SoeOptional) + * @brief Get sfFeeAmountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getFeeAmount() const + getFeeAmountDelta() const { - if (hasFeeAmount()) + if (hasFeeAmountDelta()) { - return this->tx_->at(sfFeeAmount); + return this->tx_->at(sfFeeAmountDelta); } return std::nullopt; } /** - * @brief Check if sfFeeAmount is present. + * @brief Check if sfFeeAmountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasFeeAmount() const + hasFeeAmountDelta() const { - return this->tx_->isFieldPresent(sfFeeAmount); + return this->tx_->isFieldPresent(sfFeeAmountDelta); } /** @@ -152,29 +152,29 @@ public: } /** - * @brief Get sfRemainingOwnerCount (SoeOptional) + * @brief Get sfRemainingOwnerCountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] - protocol_autogen::Optional - getRemainingOwnerCount() const + protocol_autogen::Optional + getRemainingOwnerCountDelta() const { - if (hasRemainingOwnerCount()) + if (hasRemainingOwnerCountDelta()) { - return this->tx_->at(sfRemainingOwnerCount); + return this->tx_->at(sfRemainingOwnerCountDelta); } return std::nullopt; } /** - * @brief Check if sfRemainingOwnerCount is present. + * @brief Check if sfRemainingOwnerCountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasRemainingOwnerCount() const + hasRemainingOwnerCountDelta() const { - return this->tx_->isFieldPresent(sfRemainingOwnerCount); + return this->tx_->isFieldPresent(sfRemainingOwnerCountDelta); } }; @@ -243,13 +243,13 @@ public: } /** - * @brief Set sfFeeAmount (SoeOptional) + * @brief Set sfFeeAmountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setFeeAmount(std::decay_t const& value) + setFeeAmountDelta(std::decay_t const& value) { - object_[sfFeeAmount] = value; + object_[sfFeeAmountDelta] = value; return *this; } @@ -265,13 +265,13 @@ public: } /** - * @brief Set sfRemainingOwnerCount (SoeOptional) + * @brief Set sfRemainingOwnerCountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setRemainingOwnerCount(std::decay_t const& value) + setRemainingOwnerCountDelta(std::decay_t const& value) { - object_[sfRemainingOwnerCount] = value; + object_[sfRemainingOwnerCountDelta] = value; return *this; } 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/resource/Charge.h b/include/xrpl/resource/Charge.h index 12ea548fd2..b5bb8dd52e 100644 --- a/include/xrpl/resource/Charge.h +++ b/include/xrpl/resource/Charge.h @@ -4,7 +4,7 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * A consumption charge. @@ -32,7 +32,7 @@ public: label() const; /** - * Return the cost of the charge in Resource::Manager units. + * Return the cost of the charge in resource::Manager units. */ [[nodiscard]] value_type cost() const; @@ -60,4 +60,4 @@ private: std::ostream& operator<<(std::ostream& os, Charge const& v); -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/Consumer.h b/include/xrpl/resource/Consumer.h index 9abcbffc82..01539e3a39 100644 --- a/include/xrpl/resource/Consumer.h +++ b/include/xrpl/resource/Consumer.h @@ -8,7 +8,7 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { struct Entry; class Logic; @@ -96,4 +96,4 @@ private: std::ostream& operator<<(std::ostream& os, Consumer const& v); -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/Disposition.h b/include/xrpl/resource/Disposition.h index cd5bceafa5..28dd4edf62 100644 --- a/include/xrpl/resource/Disposition.h +++ b/include/xrpl/resource/Disposition.h @@ -1,6 +1,6 @@ #pragma once -namespace xrpl::Resource { +namespace xrpl::resource { /** * The disposition of a consumer after applying a load charge. @@ -24,4 +24,4 @@ enum class Disposition { Drop }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/Fees.h b/include/xrpl/resource/Fees.h index 5001b504d6..06e9f56c22 100644 --- a/include/xrpl/resource/Fees.h +++ b/include/xrpl/resource/Fees.h @@ -2,7 +2,7 @@ #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * Schedule of fees charged for imposing load on the server. @@ -13,6 +13,7 @@ extern Charge const kFeeRequestNoReply; // A request that we cannot satisfy. extern Charge const kFeeInvalidSignature; // An object whose signature we had to check that failed. extern Charge const kFeeUselessData; // Data we have no use for. extern Charge const kFeeInvalidData; // Data we have to verify before rejecting. +extern Charge const kFeeMalformedData; // Data that no honest peer would send. // RPC loads extern Charge const kFeeMalformedRpc; // An RPC request that we can immediately tell is invalid. @@ -31,4 +32,4 @@ extern Charge const kFeeWarning; // The cost of receiving a warning. extern Charge const kFeeDrop; // The cost of being dropped for excess load. /** @} */ -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/Gossip.h b/include/xrpl/resource/Gossip.h index 4ad5852de0..0d8ccb100c 100644 --- a/include/xrpl/resource/Gossip.h +++ b/include/xrpl/resource/Gossip.h @@ -4,7 +4,7 @@ #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * Data format for exchanging consumption information across peers. @@ -21,10 +21,10 @@ struct Gossip explicit Item() = default; int balance{}; - beast::IP::Endpoint address; + beast::ip::Endpoint address; }; std::vector items; }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/README.md b/include/xrpl/resource/README.md index 545d4e9ca0..96b6c3d603 100644 --- a/include/xrpl/resource/README.md +++ b/include/xrpl/resource/README.md @@ -1,4 +1,4 @@ -# Resource::Manager +# resource::Manager The ResourceManager module has these responsibilities: @@ -36,7 +36,7 @@ to the general public. ## Consumer Types Consumers are placed into three classifications (as identified by the -Resource::Kind enumeration): +resource::Kind enumeration): - InBound, - OutBound, and @@ -72,6 +72,6 @@ drop connections to those IP addresses that occur commonly in the gossip. ## Access -In xrpld, the Application holds a unique instance of Resource::Manager, +In xrpld, the Application holds a unique instance of resource::Manager, which may be retrieved by calling the method `Application::getResourceManager()`. diff --git a/include/xrpl/resource/ResourceManager.h b/include/xrpl/resource/ResourceManager.h index 03aab60c75..267cfb16e3 100644 --- a/include/xrpl/resource/ResourceManager.h +++ b/include/xrpl/resource/ResourceManager.h @@ -14,7 +14,7 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * Tracks load and resource consumption. @@ -32,10 +32,10 @@ public: * IP if proxied. */ virtual Consumer - newInboundEndpoint(beast::IP::Endpoint const& address) = 0; + newInboundEndpoint(beast::ip::Endpoint const& address) = 0; virtual Consumer newInboundEndpoint( - beast::IP::Endpoint const& address, + beast::ip::Endpoint const& address, bool const proxy, std::string_view forwardedFor) = 0; @@ -43,13 +43,13 @@ public: * Create a new endpoint keyed by outbound IP address and port. */ virtual Consumer - newOutboundEndpoint(beast::IP::Endpoint const& address) = 0; + newOutboundEndpoint(beast::ip::Endpoint const& address) = 0; /** * Create a new unlimited endpoint keyed by forwarded IP. */ virtual Consumer - newUnlimitedEndpoint(beast::IP::Endpoint const& address) = 0; + newUnlimitedEndpoint(beast::ip::Endpoint const& address) = 0; /** * Extract packaged consumer information for export. @@ -78,4 +78,4 @@ public: std::unique_ptr makeManager(beast::insight::Collector::ptr const& collector, beast::Journal journal); -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Entry.h b/include/xrpl/resource/detail/Entry.h index 1336bda6ab..ec5a328b8b 100644 --- a/include/xrpl/resource/detail/Entry.h +++ b/include/xrpl/resource/detail/Entry.h @@ -12,7 +12,7 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { using clock_type = beast::AbstractClock; @@ -91,4 +91,4 @@ operator<<(std::ostream& os, Entry const& v) return os; } -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Import.h b/include/xrpl/resource/detail/Import.h index b19dbc4d1a..c5366146c1 100644 --- a/include/xrpl/resource/detail/Import.h +++ b/include/xrpl/resource/detail/Import.h @@ -5,7 +5,7 @@ #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * A set of imported consumer data from a gossip origin. @@ -32,4 +32,4 @@ struct Import std::vector items; }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Key.h b/include/xrpl/resource/detail/Key.h index a0f11422a7..180e868319 100644 --- a/include/xrpl/resource/detail/Key.h +++ b/include/xrpl/resource/detail/Key.h @@ -7,17 +7,17 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { // The consumer key struct Key { Kind kind; - beast::IP::Endpoint address; + beast::ip::Endpoint address; Key() = delete; - Key(Kind k, beast::IP::Endpoint addr) : kind(k), address(std::move(addr)) + Key(Kind k, beast::ip::Endpoint addr) : kind(k), address(std::move(addr)) { } @@ -47,4 +47,4 @@ struct Key }; }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Kind.h b/include/xrpl/resource/detail/Kind.h index ce2e0773cf..9af760d252 100644 --- a/include/xrpl/resource/detail/Kind.h +++ b/include/xrpl/resource/detail/Kind.h @@ -1,6 +1,6 @@ #pragma once -namespace xrpl::Resource { +namespace xrpl::resource { /** * Kind of consumer. @@ -12,4 +12,4 @@ namespace xrpl::Resource { */ enum class Kind { Inbound, Outbound, Unlimited }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Logic.h b/include/xrpl/resource/detail/Logic.h index 3f36ad84a3..aaaeb4fdd1 100644 --- a/include/xrpl/resource/detail/Logic.h +++ b/include/xrpl/resource/detail/Logic.h @@ -24,7 +24,7 @@ #include #include -namespace xrpl::Resource { +namespace xrpl::resource { class Logic { @@ -96,7 +96,7 @@ public: } Consumer - newInboundEndpoint(beast::IP::Endpoint const& address) + newInboundEndpoint(beast::ip::Endpoint const& address) { Entry* entry(nullptr); @@ -126,7 +126,7 @@ public: } Consumer - newOutboundEndpoint(beast::IP::Endpoint const& address) + newOutboundEndpoint(beast::ip::Endpoint const& address) { Entry* entry(nullptr); @@ -159,7 +159,7 @@ public: * enabled. */ Consumer - newUnlimitedEndpoint(beast::IP::Endpoint const& address) + newUnlimitedEndpoint(beast::ip::Endpoint const& address) { Entry* entry(nullptr); @@ -387,7 +387,7 @@ public: { std::scoped_lock const _(lock_); Entry& entry(iter->second); - XRPL_ASSERT(entry.refcount == 0, "xrpl::Resource::Logic::erase : entry not used"); + XRPL_ASSERT(entry.refcount == 0, "xrpl::resource::Logic::erase : entry not used"); inactive_.erase(inactive_.iteratorTo(entry)); table_.erase(iter); } @@ -421,7 +421,7 @@ public: default: // LCOV_EXCL_START UNREACHABLE( - "xrpl::Resource::Logic::release : invalid entry " + "xrpl::resource::Logic::release : invalid entry " "kind"); break; // LCOV_EXCL_STOP @@ -440,7 +440,7 @@ public: static_assert( kFeeLogAsWarn > kFeeLogAsInfo && kFeeLogAsInfo > kFeeLogAsDebug && kFeeLogAsDebug > 10); - static auto kGetStream = [](Resource::Charge::value_type cost, beast::Journal& journal) { + static auto kGetStream = [](resource::Charge::value_type cost, beast::Journal& journal) { if (cost >= kFeeLogAsWarn) return journal.warn(); if (cost >= kFeeLogAsInfo) @@ -564,4 +564,4 @@ public: } }; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/resource/detail/Tuning.h b/include/xrpl/resource/detail/Tuning.h index 62f7fa3f9d..d631aaddba 100644 --- a/include/xrpl/resource/detail/Tuning.h +++ b/include/xrpl/resource/detail/Tuning.h @@ -2,7 +2,7 @@ #include -namespace xrpl::Resource { +namespace xrpl::resource { /** * Tunable constants. @@ -26,4 +26,4 @@ static constexpr std::chrono::seconds kSecondsUntilExpiration{300}; // Number of seconds until imported gossip expires static constexpr std::chrono::seconds kGossipExpirationSeconds{30}; -} // namespace xrpl::Resource +} // namespace xrpl::resource diff --git a/include/xrpl/server/InfoSub.h b/include/xrpl/server/InfoSub.h index 2e9bd857c7..db76396dc2 100644 --- a/include/xrpl/server/InfoSub.h +++ b/include/xrpl/server/InfoSub.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,39 @@ namespace xrpl { // Operations that clients may wish to perform against the network // Master operational handler, server sequencer, network tracker +/** + * Maximum number of subscriptions a single client connection may hold at once. + * + * Applies to the account, real-time account, and account-history subscriptions + * tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding + * the disconnect-time cleanup of those sets. Book subscriptions are tracked + * separately (OrderBookDB) and are not counted here. Generous enough for + * legitimate power users such as block explorers. + */ +constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000; + +/** + * Whether adding @p additional subscriptions to a connection already holding + * @p current would exceed the cap. + * + * Pure arithmetic split out so it can be unit-tested without a live + * connection. The first term avoids underflow in the subtraction. + * + * @param current Subscriptions already tracked on the connection. + * @param additional Subscriptions a request would add. + * @param cap The effective per-connection cap. Defaults to the + * built-in limit; callers may pass a configured override. + * @return true if the request must be rejected to stay within the cap. + */ +[[nodiscard]] constexpr bool +exceedsSubscriptionCap( + std::size_t current, + std::size_t additional, + std::size_t cap = kMaxSubscriptionsPerConnection) +{ + return additional > cap || current > cap - additional; +} + class InfoSubRequest : public CountedObject { public: @@ -44,12 +78,12 @@ public: * map. * * @note Lifetime contract: every `InfoSub` instance MUST be destroyed - * before the backing `Source`. NetworkOPsImp shutdown drops all - * subscriber strong refs before its own teardown to satisfy this. + * before the backing `Source`. NetworkOPsImp shutdown drops all + * subscriber strong refs before its own teardown to satisfy this. * @note Thread-safety: per-instance state is guarded by `lock_`. The - * destructor reads tracking sets without taking `lock_` because - * the strong-pointer ref-count is zero at destruction time, so - * no other thread can be calling the public mutators. + * destructor reads tracking sets without taking `lock_` because + * the strong-pointer ref-count is zero at destruction time, so + * no other thread can be calling the public mutators. */ class InfoSub : public CountedObject { @@ -62,7 +96,7 @@ public: using ref = std::shared_ptr const&; - using Consumer = Resource::Consumer; + using Consumer = resource::Consumer; public: /** @@ -117,6 +151,34 @@ public: AccountID const& account, bool historyOnly) = 0; + /** + * Schedule the server-side teardown of a disconnecting connection's + * account subscriptions off the destructor thread. + * + * The implementation posts a low-priority JobQueue task that erases the + * entries in bounded chunks, so `~InfoSub` returns immediately instead + * of running the erase loop inline. The sets are taken by value so the + * job owns its copies and never references the destroyed `InfoSub`. + * Cleanup is keyed on `seq` (unique per connection), so deferring it + * cannot disturb a reconnected client reusing the same accounts. + * + * @param seq The disconnecting connection's unique subscription id. + * @param rtAccounts Real-time account subscriptions to remove. + * @param normalAccounts Normal account subscriptions to remove. + * @param historyAccounts Account-history subscriptions to remove. + * + * @note The implementing `Source` must outlive any job it posts. If the + * JobQueue is already stopping (process shutdown), the job is not + * enqueued; the cleanup is skipped because the server-side maps + * are about to be destroyed and no publishing can run. + */ + virtual void + scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) = 0; + // VFALCO TODO Document the bool return value virtual bool subLedger(ref ispListener, json::Value& jvResult) = 0; @@ -153,12 +215,12 @@ public: * @param ispListener The subscriber requesting removal. * @param book The order book to unsubscribe from. * @return true if the entry was present and removed, false if the - * subscriber was not subscribed to @p book. + * subscriber was not subscribed to @p book. * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. * @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead - * to avoid a redundant write-back to bookSubscriptions_ on a - * partially-destroyed object. + * to avoid a redundant write-back to bookSubscriptions_ on a + * partially-destroyed object. */ virtual bool unsubBook(ref ispListener, Book const&) = 0; @@ -173,9 +235,9 @@ public: * @param uListener The sequence number of the subscriber being torn down. * @param book The order book entry to remove. * @return true if the entry was present and removed, false otherwise - * (e.g., already removed by a concurrent RPC unsubscribe). + * (e.g., already removed by a concurrent RPC unsubscribe). * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. */ virtual bool unsubBookInternal(std::uint64_t uListener, Book const&) = 0; @@ -221,8 +283,8 @@ public: /** * Journal used by InfoSub for diagnostics that occur after the - * owning subsystem (e.g. application-level Logs) is the only - * surviving sink — primarily destructor-time cleanup failures. + * owning subsystem (e.g. application-level Logs) is the only + * surviving sink — primarily destructor-time cleanup failures. */ [[nodiscard]] virtual beast::Journal const& journal() const = 0; @@ -243,6 +305,56 @@ public: [[nodiscard]] std::uint64_t getSeq() const; + /** + * Return the number of subscriptions currently tracked on this + * connection. + * + * The combined size of the per-connection account, real-time account, and + * account-history subscription sets. `doSubscribe` reads this to enforce + * the per-connection subscription cap before admitting more. + * + * @return The total tracked subscription count for this connection. + * + * @note Thread-safe: takes `lock_` for the read; read-only. + */ + [[nodiscard]] std::size_t + totalSubscriptionCount() const; + + /** + * Enforce the cap and reserve a request's net-new accounts, atomically. + * + * Under one hold of `lock_`: count the net-new entries in the two sets, + * check the total against @p cap, and insert them only if it fits. + * All-or-nothing. Doing check and insert together stops two concurrent + * requests sharing an InfoSub (the admin subscribe-by-url path) from both + * passing the check before either records its accounts. The server-side + * maps are populated afterwards by subAccount, whose re-insert is a no-op. + * + * @param proposedAccounts Real-time (accounts_proposed) ids to reserve. + * @param normalAccounts Normal (accounts) ids to reserve. + * @param cap The effective per-connection cap. + * @return true if reserved; false if the request must be rejected. + * @note Thread-safe: takes `lock_`. + */ + [[nodiscard]] bool + tryReserveAccountSubscriptions( + hash_set const& proposedAccounts, + hash_set const& normalAccounts, + std::size_t cap); + + /** + * Whether this connection already tracks an account-history for @p account. + * + * `doSubscribe` reads this to charge the cap for an account_history_tx_stream + * only when it is net-new, matching the account branches. + * + * @param account The account an account_history_tx_stream would add. + * @return true if @p account is already in the account-history set. + * @note Thread-safe: takes `lock_`; read-only. + */ + [[nodiscard]] bool + hasAccountHistorySubscription(AccountID const& account) const; + void onSendEmpty(); @@ -302,7 +414,9 @@ public: getApiVersion() const noexcept; protected: - std::mutex lock_; + // Mutable so the read-only totalSubscriptionCount() accessor can lock it + // from a const method; locking semantics are otherwise unchanged. + mutable std::mutex lock_; private: Consumer consumer_; diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 710545271a..1b726f2c0c 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -43,12 +45,15 @@ namespace xrpl { dynamically generates the signatureless form when it needs to verify the signature. - An instance of ManifestCache stores, for each trusted validator, (a) its + An instance of ManifestCache stores, for each known validator, (a) its master public key, and (b) the most senior of all valid manifests it has seen for that validator, if any. On startup, the [validator_token] config entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" - received from xrpld peers. + received from xrpld peers, including ones for validators this node does not + trust. Manifests for untrusted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; trusted validators are + not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, along with a new manifest vouching for it (with a higher sequence number), @@ -164,6 +169,100 @@ struct Manifest std::string to_string(Manifest const& m); +/** + * Largest a valid manifest can be, in decoded bytes. + * + * A manifest has a fixed set of fields. Each is serialized as a field header + * (1-2 bytes), an optional length prefix (1 byte for these sizes), and the + * field body. Taking every field at its largest gives the maximum below, so + * anything larger cannot be a valid manifest. + * + * Field header + length + body = bytes + * sfVersion (U16) 2 0 2 4 + * sfSequence (U32) 1 0 4 5 + * sfPublicKey (33) 1 1 33 35 + * sfSigningPubKey (33) 1 1 33 35 + * sfSignature (72) 1 1 72 74 + * sfMasterSignature (72) 2 1 72 75 + * sfDomain (128) 1 1 128 130 + * ----- + * 358 + */ +constexpr std::size_t kMaxManifestBytes = 358; + +/** + * Largest a valid manifest can be, in base64 characters. + * + * base64 encodes 3 bytes as 4 characters, so this is the encoded form of + * @ref kMaxManifestBytes. Callers that receive a base64 manifest should + * reject anything longer than this before decoding, to avoid allocating + * memory for an oversized input. + */ +constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); + +/** + * Default number of untrusted manifests to store in cache and allowed + * in one Manifest message. + * + * Bounds unlisted validators two ways. In the cache, a manifest for a + * brand-new unlisted key is rejected once this many are held, so peer gossip + * cannot grow the cache without end. In a TMManifests message, this many are + * sent and processed, so a peer sending its whole cache cannot force unbounded + * work. + * + * Operators can override this with `[overlay] max_untrusted_count`. Both users + * read the configured value and fall back to this default. + */ +constexpr std::size_t kMaxUntrustedCount = 300; + +/** + * Default number of trusted manifests allowed in a Manifest message. + * Not used atm while creating the message, but used to calculate the higher limit on + * received message size. Introduced to maintain consistency. Future implementation + * will use this limit. + * + * Trusted manifests are never dropped: every one this node holds is sent, and + * every one received is processed, since dropping one would delay a validator + * key rotation. This count only sizes the largest message accepted, so it must + * stay above any realistic validator list. Cap can be increased in the config + * file if messages get rejected with actual trusted manifest count crossing + * configured(or else default) value. + * Operators can override this with `[overlay] max_trusted_count`. + */ +constexpr std::size_t kMaxTrustedCount = 300; + +/** + * Number of untrusted manifests to store in cache and allowed + * in one Manifest message.. + * + * Returns the operator's override when one is configured, otherwise + * @ref kMaxUntrustedCount. Config stores an override rather than the default + * itself because the core module cannot depend on this module. + * + * @param configured The value from `[overlay] max_untrusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +untrustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxUntrustedCount); +} + +/** + * Number of trusted manifests allowed in a Manifest message. + * + * Not a cap on how many are sent or processed; see @ref kMaxTrustedCount. + * but used to calculate the higher limit on received message size. + * + * @param configured The value from `[overlay] max_trusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +trustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxTrustedCount); +} + /** * Constructs Manifest from serialized string * @@ -172,7 +271,7 @@ to_string(Manifest const& m); * @return `std::nullopt` if string is invalid * * @note This does not verify manifest signatures. - * `Manifest::verify` should be called after constructing manifest. + * `Manifest::verify` should be called after constructing manifest. */ /** @{ */ std::optional @@ -207,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; @@ -225,30 +318,17 @@ loadValidatorToken( beast::Journal journal = beast::Journal(beast::Journal::getNullSink())); enum class ManifestDisposition { - /** - * Manifest is valid - */ - Accepted = 0, + Accepted = 0, ///< Manifest is valid - /** - * Sequence is too old - */ - Stale, + Stale, ///< Sequence is too old - /** - * The master key is not acceptable to us - */ - BadMasterKey, + BadMasterKey, ///< The master key is not acceptable to us - /** - * The ephemeral key is not acceptable to us - */ - BadEphemeralKey, + BadEphemeralKey, ///< The ephemeral key is not acceptable to us - /** - * Timely, but invalid signature - */ - Invalid + Invalid, ///< Timely, but invalid signature + + UntrustedCapacity ///< Unlisted and limit reached }; inline std::string @@ -266,11 +346,25 @@ to_string(ManifestDisposition m) return "badEphemeralKey"; case ManifestDisposition::Invalid: return "invalid"; + case ManifestDisposition::UntrustedCapacity: + return "untrustedCapacity"; default: return "unknown"; } } +/** + * Whether a manifest counts against the 'untrusted' cache cap. + * + * Passed to `ManifestCache::applyManifest` with no default, so every caller + * must choose. `Capped` is the safe, flood-resistant value; only listed or + * configured keys should use `Uncapped`. + */ +enum class ManifestRateLimitCapPolicy : std::uint8_t { + Capped, ///< Subject to the untrusted cap (unlisted peer gossip) + Uncapped ///< Bypasses the cap (listed/trusted or config manifests) +}; + class DatabaseCon; /** @@ -294,8 +388,51 @@ private: std::atomic seq_{0}; + /** + * Master keys of cached manifests for validators this node does not list. + * + * One entry per capped key in `map_`; its size enforces the cap below. + * A key is added when first cached under `Capped` and removed when it + * becomes listed (see `promoteToTrusted`) or an `Uncapped` update arrives, + * never re-added on de-listing. Uncapped keys are not tracked here. + */ + hash_set untrustedKeys_; + + /** + * Maximum number of untrusted master keys kept in the cache. + * + * Once reached, a manifest for a brand-new unlisted key is rejected. Set + * from the config, defaulting to @ref kMaxUntrustedCount. + */ + std::size_t const maxUntrustedCount_; + + /** + * Running count of manifests rejected because the untrusted cap was full. + * + * Drives throttled logging (see `kUntrustedRejectCount`). Atomic because + * `applyManifest` may run concurrently. + */ + std::atomic untrustedRejectCount_{0}; + + /** + * Number of cap rejections between summary warnings. + * + * @see untrustedRejectCount_ + */ + static constexpr std::uint64_t kUntrustedRejectCount = 10000; + public: - explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j) + /** + * @param j Journal for logging. + * + * @param maxUntrustedCount Untrusted master keys to keep. Pass the + * configured value; defaults to @ref kMaxUntrustedCount. Taken as a + * parameter because this module cannot depend on the config. + */ + explicit ManifestCache( + beast::Journal j = beast::Journal(beast::Journal::getNullSink()), + std::size_t maxUntrustedCount = kMaxUntrustedCount) + : j_(j), maxUntrustedCount_(maxUntrustedCount) { } @@ -378,17 +515,44 @@ public: /** * Add manifest to cache. * + * A brand-new unlisted key is rejected once the untrusted cap is full; + * updates to a cached key and `Uncapped` manifests bypass the cap. The + * caller decides `cap` before calling so the cache lock is not held while + * consulting the validator list, which would risk a lock-ordering deadlock. + * * @param m Manifest to add * - * @return `ManifestDisposition::accepted` if successful, or - * `stale` or `invalid` otherwise + * @param cap `Uncapped` skips the untrusted cap; use it for keys that are + * listed, configured, or loaded from the DB. Note `Uncapped` does not + * assert the key is currently trusted (a DB entry may predate a + * de-listing). Callers must state this explicitly so a manifest is + * never left uncapped by omission. + * + * @return `Accepted` if stored, `Stale` if superseded, `Invalid`/ + * `BadEphemeralKey` if malformed, or `UntrustedCapacity` if the + * untrusted cap is full. * * @par Thread Safety * * May be called concurrently */ ManifestDisposition - applyManifest(Manifest m); + applyManifest(Manifest m, ManifestRateLimitCapPolicy cap); + + /** + * Stop counting a master key against the untrusted cap. + * + * Called when a cached untrusted key becomes listed, freeing its slot. + * Idempotent and a no-op for keys that were never counted. + * + * @param pk Master public key that is now listed/trusted + * + * @par Thread Safety + * + * May be called concurrently + */ + void + promoteToTrusted(PublicKey const& pk); /** * Populate manifest cache with manifests in database and config. diff --git a/include/xrpl/server/Session.h b/include/xrpl/server/Session.h index be8d9a497c..03ac767c25 100644 --- a/include/xrpl/server/Session.h +++ b/include/xrpl/server/Session.h @@ -52,7 +52,7 @@ public: /** * Returns the remote address of the connection. */ - virtual beast::IP::Endpoint + virtual beast::ip::Endpoint remoteAddress() = 0; /** 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/BaseHTTPPeer.h b/include/xrpl/server/detail/BaseHTTPPeer.h index c7553c1da3..6020d3cc65 100644 --- a/include/xrpl/server/detail/BaseHTTPPeer.h +++ b/include/xrpl/server/detail/BaseHTTPPeer.h @@ -157,7 +157,7 @@ protected: return port_; } - beast::IP::Endpoint + beast::ip::Endpoint remoteAddress() override { return beast::IPAddressConversion::fromAsio(remoteAddress_); diff --git a/include/xrpl/server/detail/BaseWSPeer.h b/include/xrpl/server/detail/BaseWSPeer.h index 59a866ab8c..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,14 +189,14 @@ 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; impl().ws_.set_option(boost::beast::websocket::stream_base::decorator([](auto& res) { - res.set(boost::beast::http::field::server, BuildInfo::getFullVersionString()); + res.set(boost::beast::http::field::server, build_info::getFullVersionString()); })); impl().ws_.async_accept( request_, bind_executor(strand_, [self = impl().shared_from_this()](error_code const& ec) { @@ -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 a1194ccfd3..0baea78931 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -15,6 +14,7 @@ #include #include #include +#include #include #include @@ -35,7 +35,6 @@ namespace xrpl { -class SHAMapNodeID; class SHAMapSyncFilter; /** @@ -95,6 +94,21 @@ enum class SHAMapState { * * See https://en.wikipedia.org/wiki/Merkle_tree */ + +/** + * Holds a SHAMap node's identity, leaf status, and serialized data. Used by + * getNodeFat to return node data for peer synchronization. + */ +struct SHAMapNodeData +{ + SHAMapNodeID nodeID; + // The `data` field (a Blob, 8-byte aligned) needs 4 bytes of padding after the `nodeID` field + // (36 bytes, 4-byte aligned) regardless of what comes between them, so `isLeaf` costs nothing + // extra here. Moving it after `data` would add 8 bytes to the size of this struct instead. + bool isLeaf; + Blob data; +}; + class SHAMap { private: @@ -289,10 +303,10 @@ public: std::vector> getMissingNodes(int maxNodes, SHAMapSyncFilter const* filter); - bool + [[nodiscard]] bool getNodeFat( SHAMapNodeID const& wanted, - std::vector>& data, + std::vector& data, bool fatLeaves, std::uint32_t depth) const; @@ -321,10 +335,45 @@ public: void serializeRoot(Serializer& s) const; + /** + * Add a root node to the SHAMap during synchronization. + * + * This function is used when receiving the root node of a SHAMap from a peer during ledger + * synchronization. The node must already have been deserialized. + * + * @param hash The expected hash of the root node. + * @param rootNode A deserialized root node to add. + * @param filter Optional sync filter to track received nodes. + * @return Status indicating whether the node was useful, duplicate, or invalid. + * + * @note This function expects the rootNode to be a valid, deserialized SHAMapTreeNode. The + * caller is responsible for deserialization and basic validation before calling this + * function. + */ SHAMapAddNode - addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter); + addRootNode(SHAMapHash const& hash, SHAMapTreeNodePtr rootNode, SHAMapSyncFilter const* filter); + + /** + * Add a known node at a specific position in the SHAMap during synchronization. + * + * This function is used when receiving nodes from peers during ledger synchronization. The node + * is inserted at the position specified by nodeID. The node must already have been + * deserialized. + * + * @param nodeID The position in the tree where this node belongs. + * @param treeNode A deserialized tree node to add. + * @param filter Optional sync filter to track received nodes. + * @return Status indicating whether the node was useful, duplicate, or invalid. + * + * @note This function expects the treeNode to be a valid, deserialized SHAMapTreeNode. The + * caller is responsible for deserialization and basic validation before calling this + * function. This also means that the nodeID must be consistent with the node's content. + */ SHAMapAddNode - addKnownNode(SHAMapNodeID const& nodeID, Slice const& rawNode, SHAMapSyncFilter const* filter); + addKnownNode( + SHAMapNodeID const& nodeID, + SHAMapTreeNodePtr treeNode, + SHAMapSyncFilter const* filter); // status functions void @@ -371,7 +420,107 @@ public: invariants() const; private: - using SharedPtrNodeStack = std::stack>; + /** + * A path from the root of the map down to some node, pairing each node with the ID naming its + * position. + * + * The two halves of an entry must agree, and the only way to get that wrong is to compute an ID + * from the wrong branch. So this type does not accept an ID at all: every push takes the branch + * being descended and derives the ID itself, so a node and its ID cannot disagree. Reads are + * exposed through the same accessors a std::stack would offer. + */ + class NodePathStack + { + public: + [[nodiscard]] bool + empty() const + { + return stack_.empty(); + } + + [[nodiscard]] std::size_t + size() const + { + return stack_.size(); + } + + [[nodiscard]] std::pair const& + top() const + { + XRPL_ASSERT(!stack_.empty(), "xrpl::SHAMap::NodePathStack::top : non-empty stack"); + return stack_.top(); + } + + void + pop() + { + XRPL_ASSERT(!stack_.empty(), "xrpl::SHAMap::NodePathStack::pop : non-empty stack"); + stack_.pop(); + } + + void + clear() + { + stack_ = {}; + } + + /** + * Start a path at the root of the map, whose ID is the zero-depth ID by definition. + */ + void + pushRoot(SHAMapTreeNodePtr node) + { + XRPL_ASSERT(stack_.empty(), "xrpl::SHAMap::NodePathStack::pushRoot : empty stack"); + stack_.emplace(std::move(node), SHAMapNodeID{}); + } + + /** + * Extend the path to the child of the current node reached by `branch`. + * + * A node keeps the depth it was reached at, never a normalized kLeafDepth. Only a leaf may + * sit at kLeafDepth, since an inner node there would have no branch left to select. + */ + void + pushChild(SHAMapTreeNodePtr node, unsigned int branch) + { + XRPL_ASSERT(node, "xrpl::SHAMap::NodePathStack::pushChild : non-null node input"); + XRPL_ASSERT( + !stack_.empty(), "xrpl::SHAMap::NodePathStack::pushChild : non-empty stack"); + auto childID = stack_.top().second.getChildNodeID(branch); + XRPL_ASSERT_IF( + node->isInner(), + childID.getDepth() < kLeafDepth, + "xrpl::SHAMap::NodePathStack::pushChild : inner node above leaf depth"); + XRPL_ASSERT_IF( + node->isLeaf(), + childID.isPrefixOf(leafKey(*node)), + "xrpl::SHAMap::NodePathStack::pushChild : leaf key below branch"); + stack_.emplace(std::move(node), std::move(childID)); + } + + /** + * Extend the path to a node lying on the path to `target`. + * + * For nodes not reached by descending a known branch: the walk tracks only the key it is + * heading for, or the node is newly created. Either way `target` selects the branch. + */ + void + pushNode(SHAMapTreeNodePtr node, uint256 const& target) + { + if (stack_.empty()) + { + pushRoot(std::move(node)); + } + else + { + pushChild(std::move(node), selectBranch(stack_.top().second, target)); + } + } + + private: + std::stack> stack_; + }; + using DeltaRef = std::pair, boost::intrusive_ptr>; @@ -398,7 +547,7 @@ private: * Update hashes up to the root */ void - dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr terminal); + dirtyUp(NodePathStack& stack, uint256 const& target, SHAMapTreeNodePtr terminal); /** * Walk towards the specified id, returning the node. Caller must check @@ -406,7 +555,7 @@ private: * id */ SHAMapLeafNode* - walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack = nullptr) const; + walkTowardsKey(uint256 const& id, NodePathStack* stack = nullptr) const; /** * Return nullptr if key not found */ @@ -433,33 +582,26 @@ private: SHAMapTreeNodePtr writeNode(NodeObjectType t, SHAMapTreeNodePtr node) const; - // returns the first item at or below this node - SHAMapLeafNode* - firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = 0) const; + // direction in which a scan walks an inner node's branches + enum class BelowDirection { First, Last }; - // returns the last item at or below this node + /** + * Returns the first or last item at or below the node already on top of `stack`, extending + * `stack` with the path walked to reach it. + */ SHAMapLeafNode* - lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = kBranchFactor) const; - - // helper function for firstBelow and lastBelow - SHAMapLeafNode* - belowHelper( - SHAMapTreeNodePtr node, - SharedPtrNodeStack& stack, - int branch, - std::tuple, std::function> const& loopParams) - const; + belowHelper(NodePathStack& stack, 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 @@ -467,7 +609,7 @@ private: SHAMapTreeNode* descendAsync( SHAMapInnerNode* parent, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter, bool& pending, descendCallback&&) const; @@ -476,13 +618,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 @@ -496,9 +638,9 @@ private: hasLeafNode(uint256 const& tag, SHAMapHash const& hash) const; SHAMapLeafNode const* - peekFirstItem(SharedPtrNodeStack& stack) const; + peekFirstItem(NodePathStack& stack) const; SHAMapLeafNode const* - peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const; + peekNextItem(uint256 const& id, NodePathStack& stack) const; bool walkBranch( SHAMapTreeNode* node, @@ -532,8 +674,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 @@ -547,7 +689,7 @@ private: using DeferredNode = std::tuple< SHAMapInnerNode*, // parent node SHAMapNodeID, // parent node ID - int, // branch + unsigned int, // branch SHAMapTreeNodePtr>; // node int deferred; @@ -643,7 +785,7 @@ public: using pointer = value_type const*; private: - SharedPtrNodeStack stack_; + NodePathStack stack_; SHAMap const* map_ = nullptr; pointer item_ = nullptr; @@ -669,7 +811,7 @@ public: private: explicit ConstIterator(SHAMap const* map); ConstIterator(SHAMap const* map, std::nullptr_t); - ConstIterator(SHAMap const* map, pointer item, SharedPtrNodeStack&& stack); + ConstIterator(SHAMap const* map, pointer item, NodePathStack&& stack); friend bool operator==(ConstIterator const& x, ConstIterator const& y); @@ -688,10 +830,7 @@ inline SHAMap::ConstIterator::ConstIterator(SHAMap const* map, std::nullptr_t) : { } -inline SHAMap::ConstIterator::ConstIterator( - SHAMap const* map, - pointer item, - SharedPtrNodeStack&& stack) +inline SHAMap::ConstIterator::ConstIterator(SHAMap const* map, pointer item, NodePathStack&& stack) : stack_(std::move(stack)), map_(map), item_(item) { } @@ -740,12 +879,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/SHAMapLeafNode.h b/include/xrpl/shamap/SHAMapLeafNode.h index 26cfde9fe8..ab5bd574ed 100644 --- a/include/xrpl/shamap/SHAMapLeafNode.h +++ b/include/xrpl/shamap/SHAMapLeafNode.h @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include #include #include #include @@ -60,4 +63,16 @@ public: getString(SHAMapNodeID const&) const final; }; +/** + * Return the key of the item held by a SHAMap leaf node. + * + * @param node a node known to be a leaf (see SHAMapTreeNode::isLeaf). + */ +inline uint256 const& +leafKey(SHAMapTreeNode const& node) +{ + XRPL_ASSERT(node.isLeaf(), "xrpl::leafKey : node is a leaf"); + return safeDowncast(node).peekItem()->key(); +} + } // namespace xrpl diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index 6094892091..f35ba2d2a7 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -52,7 +53,21 @@ public: } [[nodiscard]] SHAMapNodeID - getChildNodeID(unsigned int m) const; + getChildNodeID(unsigned int branch) const; + + /** + * Test whether this node ID lies on the path to the given leaf key + * + * A node at depth d identifies the tree path spelled by the first d + * nibbles of its key, so any leaf beneath it must agree on that prefix. + * A node ID that fails this test names a different subtree than the one + * it was built for. + * + * @param key the key of a leaf below this node + * @return whether this node ID is a prefix of the leaf key + */ + [[nodiscard]] bool + isPrefixOf(uint256 const& key) const; /** * Create a SHAMapNodeID of a node with the depth of the node and @@ -63,47 +78,34 @@ public: * @return SHAMapNodeID of the node */ static SHAMapNodeID - createID(int depth, uint256 const& key); + createID(unsigned int depth, uint256 const& key); - // FIXME-C++20: use spaceship and operator synthesis /** * Comparison operators + * + * <, >, <= and >= are synthesized from the spaceship. It is written out + * rather than defaulted because the ordering is by depth first, and the + * members are not declared in that order. */ - bool - operator<(SHAMapNodeID const& n) const + std::strong_ordering + operator<=>(SHAMapNodeID const& n) const { - return std::tie(depth_, id_) < std::tie(n.depth_, n.id_); - } - - bool - operator>(SHAMapNodeID const& n) const - { - return n < *this; - } - - bool - operator<=(SHAMapNodeID const& n) const - { - return !(n < *this); - } - - bool - operator>=(SHAMapNodeID const& n) const - { - return !(*this < n); + return std::tie(depth_, id_) <=> std::tie(n.depth_, n.id_); } + /** + * Equality, which the spaceship above does not provide. + * + * Only a *defaulted* operator<=> implicitly declares a defaulted + * operator==; the one above is user-provided, so == has to be written. + * It cannot be defaulted either, because a defaulted == would also compare + * the CountedObject base, which is not equality comparable. + */ bool operator==(SHAMapNodeID const& n) const { return (depth_ == n.depth_) && (id_ == n.id_); } - - bool - operator!=(SHAMapNodeID const& n) const - { - return !(*this == n); - } }; inline std::string diff --git a/include/xrpl/shamap/detail/TaggedPointer.h b/include/xrpl/shamap/detail/TaggedPointer.h index 509e6cc58d..705681be1d 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.h +++ b/include/xrpl/shamap/detail/TaggedPointer.h @@ -219,11 +219,11 @@ public: * * @param i index of the requested child */ - [[nodiscard]] std::optional - getChildIndex(std::uint16_t isBranch, int i) const; + [[nodiscard]] std::optional + getChildIndex(std::uint16_t isBranch, unsigned int i) const; }; -[[nodiscard]] inline int +[[nodiscard]] inline unsigned int popcnt16(std::uint16_t a) { #if __cpp_lib_bitops @@ -234,11 +234,11 @@ popcnt16(std::uint16_t a) // fallback to table lookup static constexpr auto tbl = []() { std::array ret{}; - for (int i = 0; i != 256; ++i) + for (auto i = 0u; i != 256u; ++i) { - for (int j = 0; j != 8; ++j) + for (auto j = 0u; j != 8u; ++j) { - if (i & (1 << j)) + if (i & (1u << j)) ret[i]++; } } diff --git a/include/xrpl/shamap/detail/TaggedPointer.ipp b/include/xrpl/shamap/detail/TaggedPointer.ipp index 9275f3d15a..7db101b3cb 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.ipp +++ b/include/xrpl/shamap/detail/TaggedPointer.ipp @@ -22,6 +22,11 @@ static_assert( static_assert( kBoundaries.back() == SHAMapInnerNode::kBranchFactor, "Last element of boundaries must be number of children in a dense array"); +static_assert( + kBoundaries.front() >= 1, + "TaggedPointer.ipp subtracts 1 from a numAllocated value derived from " + "kBoundaries, as an unsigned quantity, in several places; the smallest " + "boundary must stay non-zero or those subtractions underflow."); // Terminology: A chunk is the memory being allocated from a block. A block // contains multiple chunks. This is the terminology the boost documentation @@ -148,16 +153,16 @@ TaggedPointer::iterChildren(std::uint16_t isBranch, F&& f) const if (numAllocated == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) f(hashes[i]); } else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(hashes[curHashI++]); } @@ -176,9 +181,9 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const if (capacity() == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, i); } @@ -187,10 +192,10 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, curHashI++); } @@ -216,14 +221,14 @@ TaggedPointer::destroyHashesAndChildren() deallocateArrays(tag, ptr); } -inline std::optional -TaggedPointer::getChildIndex(std::uint16_t isBranch, int i) const +inline std::optional +TaggedPointer::getChildIndex(std::uint16_t isBranch, unsigned int i) const { if (isDense()) return i; // Sparse case - if ((isBranch & (1 << i)) == 0) + if ((isBranch & (1u << i)) == 0u) { // Empty branch. Sparse children do not store empty branches return {}; @@ -273,10 +278,10 @@ inline TaggedPointer::TaggedPointer( *this = std::move(other); auto [srcDstNumAllocated, srcDstHashes, srcDstChildren] = getHashesAndChildren(); bool const srcDstIsDense = isDense(); - int srcDstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcDstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -298,13 +303,13 @@ inline TaggedPointer::TaggedPointer( // sparse // need to shift all the elements to the left by // one - for (int c = srcDstIndex; c < srcDstNumAllocated - 1; ++c) + for (auto c = srcDstIndex; c + 1 < srcDstNumAllocated; ++c) { srcDstHashes[c] = srcDstHashes[c + 1]; srcDstChildren[c] = std::move(srcDstChildren[c + 1]); } - srcDstHashes[srcDstNumAllocated - 1].zero(); - srcDstChildren[srcDstNumAllocated - 1].reset(); + srcDstHashes[srcDstNumAllocated - 1u].zero(); + srcDstChildren[srcDstNumAllocated - 1u].reset(); // do not increment the index } } @@ -321,7 +326,7 @@ inline TaggedPointer::TaggedPointer( // sparse // need to create a hole by shifting all the elements to the // right by one - for (int c = srcDstNumAllocated - 1; c > srcDstIndex; --c) + for (auto c = srcDstNumAllocated - 1u; c > srcDstIndex; --c) { srcDstHashes[c] = srcDstHashes[c - 1]; srcDstChildren[c] = std::move(srcDstChildren[c - 1]); @@ -352,10 +357,10 @@ inline TaggedPointer::TaggedPointer( auto [srcNumAllocated, srcHashes, srcChildren] = src.getHashesAndChildren(); bool const srcIsDense = src.isDense(); bool const dstIsDense = dst.isDense(); - int srcIndex = 0, dstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcIndex = 0u, dstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -409,7 +414,7 @@ inline TaggedPointer::TaggedPointer( !dstIsDense || dstIndex == dstNumAllocated, "xrpl::TaggedPointer::TaggedPointer(TaggedPointer&& ...) : " "non-sparse or valid sparse"); - for (int i = dstIndex; i < dstNumAllocated; ++i) + for (auto i = dstIndex; i < dstNumAllocated; ++i) { new (&dstHashes[i]) SHAMapHash{}; new (&dstChildren[i]) SHAMapTreeNodePtr{}; @@ -448,9 +453,9 @@ inline TaggedPointer::TaggedPointer( new (&newChildren[branchNum]) SHAMapTreeNodePtr{std::move(oldChildren[indexNum])}; }); // Run the constructors for the remaining elements - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if (((1 << i) & isBranch) != 0) + if (((1u << i) & isBranch) != 0u) continue; new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; @@ -459,7 +464,7 @@ inline TaggedPointer::TaggedPointer( else { // new arrays are sparse, old arrays may be sparse or dense - int curCompressedIndex = 0; + auto curCompressedIndex = 0u; iterNonEmptyChildIndexes(isBranch, [&](auto branchNum, auto indexNum) { new (&newHashes[curCompressedIndex]) SHAMapHash{oldHashes[indexNum]}; new (&newChildren[curCompressedIndex]) @@ -467,7 +472,7 @@ inline TaggedPointer::TaggedPointer( ++curCompressedIndex; }); // Run the constructors for the remaining elements - for (int i = curCompressedIndex; i < newNumAllocated; ++i) + for (auto i = curCompressedIndex; i < newNumAllocated; ++i) { new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index 472afdf624..7be7f34b0b 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -17,7 +18,6 @@ #include #include #include -#include namespace xrpl { @@ -131,14 +131,12 @@ public: } /** - * 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. + * Registers a newly-created order book directory with the shared, + * process-wide OrderBookDB, unless this transaction is being applied + * under TapDryRun. */ - TER - checkInvariants(TER const result, XRPAmount const fee); + void + addOrderBook(Book const& book); ApplyViewContext getApplyViewContext() @@ -150,13 +148,6 @@ public: } private: - static TER - failInvariantCheck(TER const result); - - template - TER - checkInvariantsHelper(TER const result, XRPAmount const fee, std::index_sequence); - OpenView& base_; ApplyFlags flags_; std::optional view_; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index a71285f70e..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/applySteps.h b/include/xrpl/tx/applySteps.h index bd495481f2..0a1ae9fa3a 100644 --- a/include/xrpl/tx/applySteps.h +++ b/include/xrpl/tx/applySteps.h @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -393,16 +394,21 @@ preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, Open * * No validation is done or implied by this function. * - * Caller is responsible for handling any exceptions. - * Since none should be thrown, that will usually - * mean terminating. - * + * Callers do not expect this function to throw; exceptions from a transactor's + * `calculateBaseFee` are caught and reported as an error instead. * @param view The current open ledger. * @param tx The transaction to be checked. * - * @return The base fee. + * @return The base fee on success. Returns `std::unexpected(temUNKNOWN)` if the transaction + * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's + * `calculateBaseFee` threw. + * + * @note Failure is reported as an error rather than a fee of zero because a + * zero (or default) fee would pass checkFee and let the transaction be + * applied for less than it owes. Callers that only need a fee hint may fall + * back to a default; callers deciding whether to apply should reject. */ -XRPAmount +[[nodiscard]] std::expected calculateBaseFee(ReadView const& view, STTx const& tx); /** diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 4b3e9beec4..301e464daf 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include namespace xrpl { @@ -69,7 +71,9 @@ private: IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce); + bool enforce, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); static bool validateFrozenState( @@ -78,7 +82,9 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze); + bool globalFreeze, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 1239305e79..e8dafbd301 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -198,7 +198,7 @@ public: /** * @brief Invariant: An account XRP balance must be in XRP and take a value - * between 0 and INITIAL_XRP drops, inclusive. + * between 0 and kInitialXRP drops, inclusive. * * We iterate all account roots modified by the transaction and ensure that * their XRP balances are reasonable. @@ -290,7 +290,7 @@ public: /** * @brief Invariant: an escrow entry must take a value between 0 and - * INITIAL_XRP drops exclusive. + * kInitialXRP drops exclusive. */ class NoZeroEscrow { diff --git a/include/xrpl/tx/invariants/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..8cbdefc911 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -15,7 +15,35 @@ 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: neither `PrincipalOutstanding` nor + * `TotalValueOutstanding` increases, and at least one of them + * strictly decreases; + * `PaymentRemaining` strictly decreases; + * `NextPaymentDueDate` advances by N * `PaymentInterval`, N > 0. + * `PaymentRemaining == 0` after: pinned by checks 1 and 5b. * */ class ValidLoan @@ -23,6 +51,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/AMMLiquidity.h b/include/xrpl/tx/paths/AMMLiquidity.h index 1904445554..b08a0ec2f9 100644 --- a/include/xrpl/tx/paths/AMMLiquidity.h +++ b/include/xrpl/tx/paths/AMMLiquidity.h @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -124,17 +123,12 @@ private: generateFibSeqOffer(TAmounts const& balances) const; /** - * Generate max offer. - * If `fixAMMOverflowOffer` is active, the offer is generated as: + * Generate max offer. The offer is generated as: * takerGets = 99% * balances.out takerPays = swapOut(takerGets). * Return nullopt if takerGets is 0 or takerGets == balances.out. - * - * If `fixAMMOverflowOffer` is not active, the offer is generated as: - * takerPays = max input amount; - * takerGets = swapIn(takerPays). */ [[nodiscard]] std::optional> - maxOffer(TAmounts const& balances, Rules const& rules) const; + maxOffer(TAmounts const& balances) const; }; } // namespace xrpl 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/sponsor/SponsorshipSet.h b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h index 3310c995ae..1100c5352a 100644 --- a/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h +++ b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -16,7 +18,7 @@ namespace xrpl { class SponsorshipSet : public Transactor { public: - static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Custom; explicit SponsorshipSet(ApplyContext& ctx) : Transactor(ctx) { @@ -47,6 +49,15 @@ public: XRPAmount fee, ReadView const& view, beast::Journal const& j) override; + +private: + TER + createSponsorship( + Keylet const& sponsorshipKeylet, + AccountID const& sponsorID, + AccountID const& sponseeID, + SLE::ref sponsorAccSle, + SLE::ref reserveSponsorAccSle); }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..12ad5c8f28 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +/** + * @brief Updates the encrypted mirror balances of a Confidential MPToken. + * + * @details + * This transaction updates a single holder's mirrored confidential balances + * (`sfIssuerEncryptedBalance` and/or `sfAuditorEncryptedBalance`) with the latest + * ElGamal public keys defined on the `MPTokenIssuance`. + * + * It supports both issuer and holder self-migration modes, each mode supports multiple flows: + * - Issuer mode: Submitted by the issuer. + * 1. Issuer Key Rotation Migration: Re-encrypts the + * holder's `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 2. Auditor Key Rotation Migration: Re-encrypts the + * holder's `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key. + * + * 3. Simultaneous Rotation Migration: Updates both the issuer + * and auditor encrypted balances in a single transaction to optimize network throughput. + * + * 4. Auditor Late-Registration Migration: When the issuer ElGamal + * public key is already registered on the `MPTokenIssuance` object, the issuer can + * register an auditor key at a later time through `MPTokenIssuanceSet`. Then the issuer uses this + * flow to set the holder's initial `sfAuditorEncryptedBalance` on `MPToken` object. + * + * - Holder self-migration mode: Submitted by the holder. The holder decrypts their own + * `sfConfidentialBalanceSpending` with holder's private key to recover the balance and + * re-encrypts it under the relevant new ElGamal public key(s). This mode is always + * available to the holder and is not conditioned on the issuer being unable to migrate + * them: the ledger cannot verify whether an issuer has really lost its private key. That + * loss is only the expected motivation, since an issuer that still holds its key can + * migrate holders itself in issuer mode. + * @note All holder migration flows strictly require the holder's + * `sfConfidentialBalanceInbox` to be canonically zero; the holder must run + * `ConfidentialMPTMergeInbox` first so the spending balance reflects the + * full balance. + * + * 5. Holder Issuer-Mirror Migration: Re-encrypts the holder's + * `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 6. Holder Auditor-Mirror Migration: Re-encrypts the holder's + * `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key, or + * sets it for the first time when the auditor key was late-registered. This is the + * holder-driven counterpart to flows 2 and 4, for when the issuer does not migrate + * the holder itself. + * + * 7. Simultaneous Holder Self-Migration: Updates both the issuer and auditor + * encrypted balances in a single transaction (both keys have rotated). + */ +class ConfidentialMPTMirrorUpdate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTMirrorUpdate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h index 5d35f65f44..1aa853d6e2 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h @@ -32,7 +32,7 @@ struct MPTCreateArgs std::optional transferFee = std::nullopt; std::optional const& metadata{}; std::optional domainId = std::nullopt; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; // Set only by callers that issue an MPT representing a wrapped asset // (e.g. VaultCreate's share token). The keylet must point to an // existing MPToken or RippleState owned by `account`. Surfaces on diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h index 52f155e8fe..a2a966009d 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h @@ -3,12 +3,15 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -22,6 +25,37 @@ public: { } + // Maps each MPTokenIssuanceSet set flag(e.g., tfMPTSetCanLock), to the issuance's + // corresponding immutable flag (e.g., lsifMPTCanLock) and the target ledger flag (e.g., + // lsfMPTCanLock). + struct FlagMapping + { + std::uint32_t setFlag; + std::uint32_t immutableFlag; + std::uint32_t ledgerFlag; + }; + + static constexpr std::array flagMapping = { + {{.setFlag = tfMPTSetCanLock, .immutableFlag = lsifMPTCanLock, .ledgerFlag = lsfMPTCanLock}, + {.setFlag = tfMPTSetRequireAuth, + .immutableFlag = lsifMPTRequireAuth, + .ledgerFlag = lsfMPTRequireAuth}, + {.setFlag = tfMPTSetCanEscrow, + .immutableFlag = lsifMPTCanEscrow, + .ledgerFlag = lsfMPTCanEscrow}, + {.setFlag = tfMPTSetCanTrade, + .immutableFlag = lsifMPTCanTrade, + .ledgerFlag = lsfMPTCanTrade}, + {.setFlag = tfMPTSetCanTransfer, + .immutableFlag = lsifMPTCanTransfer, + .ledgerFlag = lsfMPTCanTransfer}, + {.setFlag = tfMPTSetCanClawback, + .immutableFlag = lsifMPTCanClawback, + .ledgerFlag = lsfMPTCanClawback}, + {.setFlag = tfMPTSetCanHoldConfidentialBalance, + .immutableFlag = lsifMPTCanHoldConfidentialBalance, + .ledgerFlag = lsfMPTCanHoldConfidentialBalance}}}; + static bool checkExtraFeatures(PreflightContext const& ctx); 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 fe9ab3e250..f23b2dcc21 100644 --- a/nix/check-tools/README.md +++ b/nix/check-tools/README.md @@ -1,13 +1,14 @@ # 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 | -| --------------------- | ----------------------------------- | -| `nix-nixos-amd64.txt` | `nix-nixos` CI image, `linux/amd64` | -| `nix-nixos-arm64.txt` | `nix-nixos` CI image, `linux/arm64` | -| `macos.txt` | macOS, inside `nix develop` | +| File | Environment | +| ---------------------- | ------------------------------------ | +| `nix-ubuntu-amd64.txt` | `nix-ubuntu` CI image, `linux/amd64` | +| `nix-ubuntu-arm64.txt` | `nix-ubuntu` CI image, `linux/arm64` | +| `macos.txt` | macOS, inside `nix develop` | The [`check-tools`](../../.github/workflows/check-tools.yml) workflow regenerates each snapshot in its environment and fails if it differs from the committed file. @@ -17,21 +18,25 @@ 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 -The two Linux snapshots come from the `nix-nixos` image (Docker or a compatible +The two Linux snapshots come from the `nix-ubuntu` image (Docker or a compatible runtime such as Apple `container`). The image tag is pinned in `linux.json`: ```bash -img="ghcr.io/xrplf/xrpld/nix-nixos:$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" +img="ghcr.io/xrplf/xrpld/nix-ubuntu:$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" for arch in amd64 arm64; do container run --rm -i -e CHECK_TOOLS_SKIP_CLONE=1 -a "${arch}" --entrypoint bash "${img}" -s \ - "nix/check-tools/nix-nixos-${arch}.txt" + "nix/check-tools/nix-ubuntu-${arch}.txt" done ``` 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 4b78106c4c..50c12734e0 100644 --- a/package/README.md +++ b/package/README.md @@ -1,15 +1,22 @@ # Linux Packaging -This directory contains all files needed to build RPM and Debian packages for `xrpld`. +This directory contains all files needed to build RPM and Debian packages for +`xrpld`. The packages also ship the `validator-keys` tool, so packaging requires +a build configured with `-Dvalidator_keys=ON`. ## Directory layout ``` 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.in, lintian-overrides.in, rules, copyright, docs, links, source/format). + The `.in` files are templates rendered by `build_pkg.py`; `docs` and `links` are staged under the package name shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) @@ -19,51 +26,125 @@ 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. An optional `variant` names a flavour of the package (see +[Package variants](#package-variants)), and CI passes it as `--variant`. -| 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`: +To print the full packaging matrix (artifact names, images and package names) +for the current `linux.json`: ```bash ./.github/scripts/strategy-matrix/generate.py --packaging ``` +## Package variants + +A config whose binaries are not the plain release build cannot be packaged as +`xrpld`: both would carry the same name and version, so whichever published last +would win. It is packaged as a **variant** instead — `variant: "assert"` in its +`package` map, which CI passes to `build_pkg.py` as `--variant assert`, +producing `xrpld-assert`. What the build option itself does is a build concern, +not a packaging one; see the options table in [`BUILD.md`](../BUILD.md). + +A variant ships the same paths as `xrpld` — `/usr/bin/xrpld`, `/etc/xrpld`, +`xrpld.service`, `/etc/logrotate.d/xrpld` — differing only in the per-package +documentation directory, so it declares itself a stand-in for the plain package +rather than something installable next to it: `Conflicts`, `Replaces` and a +versioned `Provides: xrpld` on Debian, `Conflicts` and `Provides` on RPM. +Neither format declares `Obsoletes`, so `apt upgrade` and `dnf upgrade` keep an +installed flavour on its own flavour, and switching is always explicit: + +```bash +apt-get install xrpld-assert # apt removes the plain package itself +dnf swap xrpld xrpld-VARIANT # 'dnf install' alone stops at the conflict +``` + +Only the DEB packages carry a variant today — `xrpld-assert` comes from the +`debian` config alone, there being no call for an assert build on RHEL-based +distributions — but the RPM side works the same way if one is added. + +A switch is a removal plus an installation rather than an upgrade, so unlike a +version upgrade it stops the service: Debian's scriptlets start it again, while +on RPM the operator runs `systemctl start xrpld`. Configuration survives either +way, being conffiles on Debian and `%config(noreplace)` on RPM. + +`dnf` installs the replacement before erasing the old flavour, whose `%preun` +would leave `xrpld.service` disabled, so `%postun` re-applies the preset when +the unit file outlives the erase — which, since rpm keeps a file another +installed package owns, happens only during a swap. The cost is that a +deliberate `systemctl disable` is not carried across an RPM switch. + +The alternative is an `xrpld-common` package owning the unit, the sysusers and +tmpfiles snippets and the configuration, required by both flavours at an exact +version: nothing is erased mid-swap, so no scriptlet has to detect one. It is +not worth it for a single variant — it moves files out of the production +package, and a sanitizer flavour would likely need its own unit anyway, putting +the lifecycle back where it is now. + +Adding a variant is the flavour in `VARIANTS` in `build_pkg.py`, which is the +list `--variant` accepts, plus a config in `linux.json` with the CMake arguments +and a `package` map naming it, for one format or for both: `generate.py +--packaging` emits the package names per format, and the `test-install-deb` and +`test-install-rpm` jobs install what their own format produced. + +Operators switch between the flavours as described in +[`docs/install.md`](../docs/install.md#optional-the-assert-enabled-build). + ## Building packages ### 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` binary artifact 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: + +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-deb` and `test-install-rpm` call + [`reusable-package-test-install.yml`](../.github/workflows/reusable-package-test-install.yml) + with their format's package names and distro images, installing each package + in the container of every distro that format targets and running 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 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 +`xrpld` version always packages the same tool; bump that commit deliberately. ### Locally (mirrors CI) -With an `xrpld` binary already built at `build/xrpld`, run the packaging step -inside the same container CI uses. The image tag is derived from `linux.json` -so you don't need to hardcode a SHA. +With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and +`build/validator-keys`, run the packaging step inside the same container CI uses. +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 @@ -71,13 +152,18 @@ 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 ``` +Add `--variant assert` to package binaries built with `-Dassert=ON`; the package +is then named `xrpld-assert`. + ### Via CMake (host-side target) If you run CMake configure on a host that has `rpmbuild` or `dpkg-buildpackage` @@ -87,6 +173,7 @@ needed, but the host toolchain replaces the pinned CI image: ```bash cmake \ -Dxrpld=ON \ + -Dvalidator_keys=ON \ -Dpkg_release=1 \ -Dtests=OFF \ .. @@ -95,29 +182,105 @@ 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; `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`. +least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and +`validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise, 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 +`-Dassert=ON` passes `--variant assert`, so such a build packages as +`xrpld-assert` without anything else being asked for. -`build_pkg.sh` derives the `xrpld` software version from +## Publishing packages + +Packages are published to the XRPLF repositories on Sonatype Nexus through +`https://packages-upload.xrplf.org`. Reads go through +`https://packages.xrplf.org`, which Cloudflare proxies to cache them and which +rejects request bodies over 100 MB, so uploads use the DNS-only host instead. +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` | + +A variant is published to the same channel under its own name, so +`xrpld-assert` never overwrites `xrpld`. + +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 the install tests, 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. +- yum-hosted 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 @@ -135,45 +298,66 @@ 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. +`--variant` is the flavour of the package, empty by default and accepting only +the flavours in `VARIANTS`; see [Package variants](#package-variants). The RPM +path passes it to the spec as the `pkg_variant` macro, which suffixes `Name` and +adds the `Conflicts`/`Provides` pair. Debian control files have no conditionals, so the DEB path renders +`debian/control.in` and `debian/lintian-overrides.in` instead, substituting +`@PKG@` with the package name and `@VARIANT_FIELDS@` with the +`Conflicts`/`Replaces`/`Provides` block, empty for the plain package; a token +with no value fails the build rather than reaching dpkg. The files debhelper +keys by package name (`docs`, `links`, and the units) are staged under that same +name. The paths inside the package are unchanged either way, so `debian/rules` +reads its package name from `dh_listpackages` and names the unit, sysusers, +tmpfiles and logrotate files with `--name xrpld`. -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. +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. -It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls -`stage_common()` to copy the binary, config files, and shared support files -into the staging area, and invokes the platform build tool. +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`. + +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 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 1. Creates the standard `rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}` tree inside the build directory. -2. Copies `xrpld.spec` and all shared source files (binary, configs, service files) into `SOURCES/`. +2. Copies `xrpld.spec` and all shared source files (binaries, configs, service files) into `SOURCES/`. 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 @@ -182,37 +366,53 @@ service restart. ### DEB 1. Creates a staging source tree at `debbuild/source/` inside the build directory. -2. Stages the binary, configs, `README.md`, and `LICENSE.md`. -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. +2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and + `validator-keys-LICENSE`. +3. Stages `package/debian/` into `debbuild/source/debian/`: the `.in` templates + are rendered, and the files debhelper keys by package name (`docs`, `links`, + `lintian-overrides`) are staged under the name being built. +4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` as + `.xrpld.*`, which `dh_installsystemd`, `dh_installsysusers`, + `dh_installtmpfiles` and `dh_installlogrotate` read because `debian/rules` + passes them `--name xrpld`. 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..77ef8f3120 --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,350 @@ +#!/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]*)(\+.*)?$") + +# The package name a variant suffixes, and the name every variant keeps for its +# on-disk paths (/usr/bin/xrpld, /etc/xrpld, xrpld.service). +BASE_NAME = "xrpld" + +# The flavours that can be built, '' being the plain xrpld package. A variant +# needs a config in linux.json to be built by CI; see package/README.md. +VARIANTS = ("", "assert") + +# 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") + +# debian/ files debhelper keys by package name, staged as '.'. +DEBIAN_PKG_FILES = ("docs", "links") + +# Debian control files have no conditionals, so what makes a variant replace the +# plain package is rendered into control.in rather than written there. +DEB_VARIANT_FIELDS = """\ +Conflicts: xrpld +Replaces: xrpld +Provides: xrpld (= ${binary:Version})""" + +TOKEN = re.compile(r"@[A-Z_]+@") + + +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 render(template: Path, dest: Path, values: dict[str, str]) -> None: + """Write template to dest with its @TOKEN@ placeholders substituted. + + A token left without a value fails the build rather than reaching dpkg. + """ + text = template.read_text() + for token, value in values.items(): + text = text.replace(f"@{token}@", value) + + missing = sorted(set(TOKEN.findall(text))) + assert not missing, f"{template}: no value for {', '.join(missing)}" + + # An empty value at the end of a stanza would otherwise leave a blank line, + # which is what ends a stanza. + dest.write_text(text.rstrip("\n") + "\n") + + +def package_name(variant: str) -> str: + """The binary package name for a variant: '' -> xrpld, 'assert' -> xrpld-assert.""" + return f"{BASE_NAME}-{variant}" if variant else BASE_NAME + + +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, *, prefix: str = "") -> None: + """Copy the systemd, sysusers, tmpfiles and logrotate files into dest. + + Each format wants them somewhere else: rpmbuild reads them from SOURCES by + path, debhelper from debian/ by package name -- hence 'prefix', which makes + the copies 'xrpld-assert.xrpld.service' and so on. + """ + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / f"{prefix}{name}") + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str, variant: 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") + + # The spec defaults it to nothing, so a plain build is unchanged. + variant_defines = ["--define", f"pkg_variant {variant}"] if variant else [] + + 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", + *variant_defines, + spec, + ) + + +def stage_debian(dest: Path, name: str) -> None: + """Stage the debian directory for the package name being built.""" + source = SRC_DIR / "package" / "debian" + shutil.copytree( + source, dest, ignore=shutil.ignore_patterns("*.in", *DEBIAN_PKG_FILES) + ) + + values = { + "PKG": name, + "VARIANT_FIELDS": "" if name == BASE_NAME else DEB_VARIANT_FIELDS, + } + render(source / "control.in", dest / "control", values) + render(source / "lintian-overrides.in", dest / f"{name}.lintian-overrides", values) + + for suffix in DEBIAN_PKG_FILES: + shutil.copy2(source / suffix, dest / f"{name}.{suffix}") + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, + name: str, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + stage_debian(staging / "debian", name) + + # Prefixed whether it is a variant's name or not: debian/rules names them + # explicitly either way. + stage_units(staging / "debian", prefix=f"{name}.") + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + {name} ({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( + "--variant", + default="", + choices=VARIANTS, + help="the flavour of the package to build: 'assert' produces " + "xrpld-assert, which ships the same paths as xrpld and replaces it " + "(default: the plain xrpld package)", + ) + 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 + variant: str = args.variant + name = package_name(variant) + + 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) + + print(f"Building {package_type} {name} {version}-{pkg_release}", flush=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + name=name, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release, variant=variant) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index 3684fc096a..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from a pre-built xrpld binary. -# -# 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 xrpld [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 xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -if [[ ! -x "${xrpld_binary}" ]]; then - echo "build_pkg.sh: expected executable xrpld binary at ${xrpld_binary}." >&2 - echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&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 "${BUILD_DIR}/xrpld" "${dest}/xrpld" - 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.in
similarity index 55%
rename from package/debian/control
rename to package/debian/control.in
index 45d2acbbea..20486efc9a 100644
--- a/package/debian/control
+++ b/package/debian/control.in
@@ -1,23 +1,25 @@
-Source: xrpld
+Source: @PKG@
 Section: net
 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
 Vcs-Git: https://github.com/XRPLF/rippled.git
 Vcs-Browser: https://github.com/XRPLF/rippled
 
-Package: xrpld
-Section: net
-Priority: optional
+Package: @PKG@
 Architecture: any
 Depends:
  ${shlibs:Depends},
  ${misc:Depends}
 Description: XRP Ledger daemon
- Reference implementation of the XRP Ledger protocol.
- Participates in the peer-to-peer network, processes transactions,
- and maintains a local ledger copy.
+ xrpld is the reference implementation of the XRP Ledger protocol. It
+ participates in the peer-to-peer XRP Ledger network, processes
+ transactions, and maintains the ledger database.
+ This package also includes the validator-keys tool for validator key
+ management.
+@VARIANT_FIELDS@
diff --git a/package/debian/copyright b/package/debian/copyright
index ddaa719e3a..baaa12e13c 100644
--- a/package/debian/copyright
+++ b/package/debian/copyright
@@ -1,9 +1,28 @@
 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: *
 Copyright: 2011-present, the XRP Ledger developers
+License: ISC
+
+Files: validator-keys
+Copyright: 2016, Ripple Labs Inc.
+ 2011, Arthur Britto, David Schwartz, Jed McCaleb, Vinnie Falco, Bob Way,
+ Eric Lombrozo, Nikolaos D. Bougalis, Howard Hinnant
+ 2013, Raw Material Software Ltd.
+ 2003-2011, Christopher M. Kohlhoff
+ 2009-2010, Satoshi Nakamoto
+ 2011, The Bitcoin developers
+ 2003-2005, Tom Wu
+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
+ license (Bitcoin) and Tom Wu's license, whose terms require its notice to be
+ retained intact. The complete upstream notice is therefore shipped verbatim as
+ /usr/share/doc/xrpld/validator-keys-LICENSE.
+
 License: ISC
  Permission to use, copy, modify, and distribute this software for any
  purpose with or without fee is hereby granted, provided that the above
@@ -16,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/docs b/package/debian/docs
new file mode 100644
index 0000000000..97325dfcf5
--- /dev/null
+++ b/package/debian/docs
@@ -0,0 +1,3 @@
+README.md
+LICENSE.md
+validator-keys-LICENSE
diff --git a/package/debian/links b/package/debian/links
new file mode 100644
index 0000000000..6dea4f28f3
--- /dev/null
+++ b/package/debian/links
@@ -0,0 +1,3 @@
+# 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/lintian-overrides.in b/package/debian/lintian-overrides.in
new file mode 100644
index 0000000000..5e72a5ef6b
--- /dev/null
+++ b/package/debian/lintian-overrides.in
@@ -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/links after rippled fully deprecated.
+@PKG@: dir-in-usr-local [usr/local/bin/]
+@PKG@: file-in-usr-local [usr/local/bin/rippled]
+@PKG@: file-in-unusual-dir [usr/local/bin/rippled]
diff --git a/package/debian/rules b/package/debian/rules
old mode 100644
new mode 100755
index 16574bca3f..bc12f54218
--- a/package/debian/rules
+++ b/package/debian/rules
@@ -2,24 +2,76 @@
 
 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
+
+# The binary package's name, which a variant build changes to e.g. xrpld-assert,
+# and the directory debhelper expects its files staged in.
+PKG := $(firstword $(shell dh_listpackages))
+PKG_DIR = debian/$(PKG)
+
+# The base name, which every package ships under whatever it is called itself.
+BASE_NAME = xrpld
+
+# What build_pkg.py stages beside this directory, each installed under its own
+# name. The binaries are also the ones checked against LIBC_MIN below.
+BINARIES = $(BASE_NAME) validator-keys
+CONFIGS = $(BASE_NAME).cfg validators.txt
+
 %:
 	dh $@
 
 override_dh_auto_configure override_dh_auto_build override_dh_auto_test:
 	@:
 
+# The unit, sysusers, tmpfiles and logrotate files are named after the daemon
+# rather than after the package, so a variant still ships xrpld.service and
+# /etc/logrotate.d/xrpld. debhelper only reads debian/$(PKG).$(BASE_NAME).* when told
+# the name.
 override_dh_installsystemd:
-	dh_installsystemd --no-stop-on-upgrade xrpld.service
+	dh_installsystemd --no-stop-on-upgrade --name $(BASE_NAME)
 
+# 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
+	dh_installsysusers --name $(BASE_NAME)
 
 override_dh_installsysusers:
 
+override_dh_installtmpfiles:
+	dh_installtmpfiles --name $(BASE_NAME)
+
+override_dh_installlogrotate:
+	dh_installlogrotate --name $(BASE_NAME)
+
 override_dh_install:
-	install -D -m 0755 xrpld            debian/xrpld/usr/bin/xrpld
-	install -D -m 0644 xrpld.cfg        debian/xrpld/etc/xrpld/xrpld.cfg
-	install -D -m 0644 validators.txt   debian/xrpld/etc/xrpld/validators.txt
+	for binary in $(BINARIES); do \
+		install -D -m 0755 "$$binary" "$(PKG_DIR)/usr/bin/$$binary"; \
+	done
+	for config in $(CONFIGS); do \
+		install -D -m 0644 "$$config" "$(PKG_DIR)/etc/$(BASE_NAME)/$$config"; \
+	done
+
+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 $(BINARIES); 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/$(PKG).substvars
 
 override_dh_dwz:
 	@:
diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs
deleted file mode 100644
index b43bf86b50..0000000000
--- a/package/debian/xrpld.docs
+++ /dev/null
@@ -1 +0,0 @@
-README.md
diff --git a/package/debian/xrpld.links b/package/debian/xrpld.links
deleted file mode 100644
index 10d34f5b8c..0000000000
--- a/package/debian/xrpld.links
+++ /dev/null
@@ -1,2 +0,0 @@
-# Legacy compat symlinks (remove next major release)
-usr/bin/xrpld          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..a112e284aa
--- /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-upload.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 61c2d61ec6..45e7a78e42 100644
--- a/package/rpm/xrpld.spec
+++ b/package/rpm/xrpld.spec
@@ -6,10 +6,14 @@
 %{error:pkg_release must be defined}
 %endif
 
-Name:     xrpld
+# The base name, which every package ships under. A variant build
+# (build_pkg.py --variant) only suffixes the package name, e.g. xrpld-assert.
+%global base_name xrpld
+
+Name:     %{base_name}%{?pkg_variant:-%{pkg_variant}}
 Version:  %{pkg_version}
 Release:  %{pkg_release}%{?dist}
-Summary:  XRP Ledger daemon
+Summary:  XRP Ledger daemon%{?pkg_variant: (%{pkg_variant} build)}
 
 License:  ISC
 URL:      https://github.com/XRPLF/rippled
@@ -17,21 +21,39 @@ 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
+# A variant owns the same paths, so it stands in for the plain package.
+%if "%{?pkg_variant}" != ""
+Conflicts: %{base_name}
+Provides:  %{base_name} = %{version}-%{release}
+%endif
 
+# 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
 transactions, and maintains the ledger database.
+This package also includes the validator-keys tool for validator key
+management.
 
 %prep
 :
@@ -40,36 +62,39 @@ transactions, and maintains the ledger database.
 :
 
 %install
-install -Dm0755 %{_sourcedir}/xrpld                %{buildroot}%{_bindir}/%{name}
-install -Dm0644 %{_sourcedir}/xrpld.cfg            %{buildroot}%{_sysconfdir}/%{name}/xrpld.cfg
-install -Dm0644 %{_sourcedir}/validators.txt       %{buildroot}%{_sysconfdir}/%{name}/validators.txt
+install -Dm0755 %{_sourcedir}/xrpld                %{buildroot}%{_bindir}/%{base_name}
+install -Dm0755 %{_sourcedir}/validator-keys       %{buildroot}%{_bindir}/validator-keys
+install -Dm0644 %{_sourcedir}/xrpld.cfg            %{buildroot}%{_sysconfdir}/%{base_name}/xrpld.cfg
+install -Dm0644 %{_sourcedir}/validators.txt       %{buildroot}%{_sysconfdir}/%{base_name}/validators.txt
 
 # systemd units, sysusers, tmpfiles, preset
 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
-cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF'
+install -d %{buildroot}%{_presetdir}
+cat >%{buildroot}%{_presetdir}/50-%{base_name}.preset <<'EOF'
 enable xrpld.service
 EOF
 
 # Logrotate config
-install -Dm0644 %{_sourcedir}/xrpld.logrotate      %{buildroot}%{_sysconfdir}/logrotate.d/%{name}
+install -Dm0644 %{_sourcedir}/xrpld.logrotate      %{buildroot}%{_sysconfdir}/logrotate.d/%{base_name}
 
 # Docs
 install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md
 install -Dm0644 %{_sourcedir}/README.md  %{buildroot}%{_docdir}/%{name}/README.md
+# Upstream notice for the bundled validator-keys tool.
+install -Dm0644 %{_sourcedir}/validator-keys-LICENSE %{buildroot}%{_docdir}/%{name}/validator-keys-LICENSE
 
 # Legacy compatibility for pre-FHS package layouts.
 # TODO: remove after rippled fully deprecated.
 install -d %{buildroot}/usr/local/bin
-ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled
+ln -s %{_bindir}/%{base_name} %{buildroot}/usr/local/bin/rippled
 
 %pre
-%sysusers_create_package %{name} %{_sourcedir}/xrpld.sysusers
+%sysusers_create_package %{base_name} %{_sourcedir}/xrpld.sysusers
 
 %post
-systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
+%tmpfiles_create_package %{base_name} %{_sourcedir}/xrpld.tmpfiles
 %systemd_post xrpld.service
 
 %preun
@@ -77,22 +102,32 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
 
 %postun
 %systemd_postun xrpld.service
+# A flavour swap installs the replacement before erasing this package, so the
+# %%preun above has just disabled a unit the replacement still owns. rpm keeps a
+# file that another installed package owns, so the unit outliving our own erase
+# means exactly that; a plain erase takes it with us and re-presets nothing.
+if [ $1 -eq 0 ] && [ -f %{_unitdir}/xrpld.service ]; then
+    systemctl preset xrpld.service >/dev/null 2>&1 || :
+fi
 
 %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}/%{base_name}
 
-%{_bindir}/%{name}
+%{_bindir}/%{base_name}
+%{_bindir}/validator-keys
 
-%config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg
-%config(noreplace) %{_sysconfdir}/%{name}/validators.txt
-%config(noreplace) %{_sysconfdir}/logrotate.d/%{name}
+%config(noreplace) %{_sysconfdir}/%{base_name}/xrpld.cfg
+%config(noreplace) %{_sysconfdir}/%{base_name}/validators.txt
+%config(noreplace) %{_sysconfdir}/logrotate.d/%{base_name}
 
 
 %{_unitdir}/xrpld.service
-%{_presetdir}/50-xrpld.preset
+%attr(0644,root,root) %{_presetdir}/50-%{base_name}.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/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp
index 7e3e02f855..56f2c77204 100644
--- a/sanitizers/suppressions/ubsan.supp
+++ b/sanitizers/suppressions/ubsan.supp
@@ -102,6 +102,10 @@ undefined:nudb
 # Snappy compression library intentional overflows
 unsigned-integer-overflow:snappy.cc
 
+# fast_float parses floats with a SWAR trick (parse_eight_digits_unrolled) that
+# multiplies eight packed digits modulo 2^64; the wraparound is by design.
+unsigned-integer-overflow:fast_float
+
 # Abseil intentional overflows in hashing, RNG and time arithmetic.
 # Matched at library scope (like boost above): the wraparound is by design
 # across many absl files (hash mixing, raw_hash_set probing, duration math,
@@ -192,8 +196,9 @@ unsigned-integer-overflow:rpc/handlers/orderbook/GetAggregatePrice.cpp
 # Test-only intentional overflow/underflow in fixture and unit-test arithmetic.
 unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp
 unsigned-integer-overflow:test/app/Batch_test.cpp
+unsigned-integer-overflow:test/app/ConfidentialTransfer_test.cpp
 unsigned-integer-overflow:test/app/Invariants_test.cpp
-unsigned-integer-overflow:test/app/Loan_test.cpp
+unsigned-integer-overflow:test/app/lending/LoanSecurity_test.cpp
 unsigned-integer-overflow:test/app/NFToken_test.cpp
 unsigned-integer-overflow:test/app/OfferMPT_test.cpp
 unsigned-integer-overflow:test/app/Offer_test.cpp
diff --git a/src/benchmarks/libxrpl/nodestore/Backend.cpp b/src/benchmarks/libxrpl/nodestore/Backend.cpp
index f854dbd3a7..9d5937f869 100644
--- a/src/benchmarks/libxrpl/nodestore/Backend.cpp
+++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp
@@ -20,30 +20,32 @@
 namespace xrpl::node_store {
 namespace {
 
-constexpr std::size_t kPoolSizes[] = {1000, 10000, 100000};
-constexpr int kThreadCounts[] = {1, 4, 8};
+constexpr auto kPoolSizes = std::to_array({1000, 10000, 100000});
+constexpr auto kThreadCounts = std::to_array({1, 4, 8});
 constexpr std::size_t kBatchSize = 256;
+constexpr std::size_t kMissRatio = 5;
 
 constexpr std::string_view kNamePrefix = "BM_Backend_";
 constexpr std::string_view kNameSeparator = "/";
 
 struct RunState
 {
-    std::unique_ptr harness;
-    Batch present;                     // prefix-1 objects, eligible to be stored
-    Batch recent;                      // prefix-1 objects in the "future" key space
-    std::vector missing;      // prefix-2 keys that are never stored
-    std::vector shuffle;  // [0, poolSize) permutation for random-like access
-    std::size_t avgPayload = 0;        // mean getData().size() over `present`
+    std::unique_ptr harness;  ///< backend under test, rebuilt per run
+    Batch present;                            ///< prefix-1 objects, eligible to be stored
+    Batch recent;                             ///< prefix-1 objects in the "future" key space
+    std::vector missing;             ///< prefix-2 keys that are never stored
+    std::vector shuffle;         ///< [0, poolSize) permutation for random-like access
+    std::size_t avgPayload = 0;               ///< mean getData().size() over `present`
 
     void
     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;
     }
 };
 
@@ -85,7 +87,7 @@ Workload const kInsert{
         },
     .iterate =
         [](IterateContext const& ctx) {
-            auto& [rs, backend, index, poolSize] = ctx;
+            auto const& [rs, backend, index, poolSize] = ctx;
             backend.store(rs.present[index % poolSize]);
         },
     .reportBytes = true,
@@ -104,7 +106,7 @@ Workload const kFetch{
         },
     .iterate =
         [](IterateContext const& ctx) {
-            auto& [rs, backend, index, poolSize] = ctx;
+            auto const& [rs, backend, index, poolSize] = ctx;
             std::shared_ptr result;
             backend.fetch(rs.present[index % poolSize]->getHash(), &result);
             benchmark::DoNotOptimize(result);
@@ -118,7 +120,7 @@ Workload const kMissing{
     .setup = [](SetupContext const& ctx) { ctx.rs.missing = makeMissingKeys(ctx.poolSize); },
     .iterate =
         [](IterateContext const& ctx) {
-            auto& [rs, backend, index, poolSize] = ctx;
+            auto const& [rs, backend, index, poolSize] = ctx;
             std::shared_ptr result;
             backend.fetch(rs.missing[index % poolSize], &result);
             benchmark::DoNotOptimize(result);
@@ -139,10 +141,10 @@ Workload const kMixed{
         },
     .iterate =
         [](IterateContext const& ctx) {
-            auto& [rs, backend, index, poolSize] = ctx;
+            auto const& [rs, backend, index, poolSize] = ctx;
             std::shared_ptr result;
             auto const pick = rs.shuffle[index % poolSize];
-            if (index % 5 == 0)
+            if (index % kMissRatio == 0)
             {
                 backend.fetch(rs.missing[pick], &result);
             }
@@ -170,7 +172,7 @@ Workload const kWork{
         },
     .iterate =
         [](IterateContext const& ctx) {
-            auto& [rs, backend, index, poolSize] = ctx;
+            auto const& [rs, backend, index, poolSize] = ctx;
             auto const slot = index % poolSize;
             auto const pick = rs.shuffle[slot];
 
@@ -238,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[0], kPoolSizes[std::size(kPoolSizes) - 1]);
-        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;
     }
@@ -249,14 +255,14 @@ registerWorkload(BackendConfig const& bc, Workload const& w)
     {
         for (auto const threads : kThreadCounts)
         {
-            if (poolSize % static_cast(threads) != 0)
+            if (poolSize % threads != 0)
                 continue;
 
             auto rs = std::make_shared();
             benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs))
                 ->Arg(poolSize)
-                ->Iterations(poolSize / static_cast(threads))
-                ->Threads(threads)
+                ->Iterations(poolSize / threads)
+                ->Threads(static_cast(threads))
                 ->UseRealTime();
         }
     }
@@ -289,7 +295,7 @@ registerStoreBatch(BackendConfig const& bc)
                 rs->harness = std::make_unique(cfg);
                 rs->present = makePool(1, poolSize);
                 rs->avgPayload = averagePayload(rs->present);
-                std::vector const batches = sliceBatches(rs->present, kBatchSize);
+                std::vector const batches = sliceFixedBatches(rs->present, kBatchSize);
                 if (batches.empty())
                 {
                     state.SkipWithError("pool smaller than one batch");
diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h
index 57abf42e89..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 
@@ -26,6 +26,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -40,18 +41,13 @@ inline void
 rngcpy(void* buffer, std::size_t bytes, Generator& g)
 {
     using result_type = typename Generator::result_type;
-    while (bytes >= sizeof(result_type))
+    while (bytes > 0)
     {
         auto const v = g();
-        std::memcpy(buffer, &v, sizeof(v));
-        buffer = reinterpret_cast(buffer) + sizeof(v);
-        bytes -= sizeof(v);
-    }
-
-    if (bytes > 0)
-    {
-        auto const v = g();
-        std::memcpy(buffer, &v, bytes);
+        auto const chunk = std::min(bytes, sizeof(result_type));
+        std::memcpy(buffer, &v, chunk);
+        buffer = reinterpret_cast(buffer) + chunk;
+        bytes -= chunk;
     }
 }
 
@@ -145,7 +141,7 @@ makePool(std::uint8_t prefix, std::size_t count, std::size_t start = 0)
     Sequence seq(prefix);
     Batch pool;
     pool.reserve(count);
-    for (std::size_t i = 0; i < count; ++i)
+    for (auto i = 0uz; i < count; ++i)
         pool.push_back(seq.obj(start + i));
     return pool;
 }
@@ -158,7 +154,7 @@ makeMissingKeys(std::size_t count)
     Sequence seq(2);
     std::vector keys;
     keys.reserve(count);
-    for (std::size_t i = 0; i < count; ++i)
+    for (auto i = 0uz; i < count; ++i)
         keys.push_back(seq.key(i));
     return keys;
 }
@@ -206,16 +202,16 @@ inline std::vector
 makeShuffle(std::size_t size, std::uint64_t seed)
 {
     std::vector v(size);
-    std::iota(v.begin(), v.end(), std::size_t{0});
+    std::ranges::iota(v, 0uz);
     beast::xor_shift_engine gen(seed);
-    std::shuffle(v.begin(), v.end(), gen);
+    std::ranges::shuffle(v, gen);
     return v;
 }
 
 // Partition a pool into fixed-size batches. Any trailing remainder shorter than
 // `batchSize` is dropped, so every returned batch has exactly `batchSize`.
 inline std::vector
-sliceBatches(Batch const& pool, std::size_t batchSize)
+sliceFixedBatches(Batch const& pool, std::size_t batchSize)
 {
     std::vector batches;
     if (batchSize == 0)
@@ -228,13 +224,10 @@ sliceBatches(Batch const& pool, std::size_t batchSize)
 
 /**
  * @brief RAII owner of a NodeStore Backend opened on a private temporary directory.
- *
- * Member declaration order matters: `tempDir` is declared first so it is
- * destroyed last, after the backend has closed and released its files.
  */
 struct BackendHarness
 {
-    beast::TempDir tempDir;
+    TempDir tempDir;  ///< Declared first so it is destroyed last
     DummyScheduler scheduler;
     beast::Journal journal{beast::Journal::getNullSink()};
     std::unique_ptr backend;
@@ -264,7 +257,7 @@ struct BackendHarness
  */
 struct DatabaseHarness
 {
-    beast::TempDir tempDir;
+    TempDir tempDir;
     DummyScheduler scheduler;
     beast::Journal journal{beast::Journal::getNullSink()};
     std::unique_ptr db;
@@ -304,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/ResolverAsio.cpp b/src/libxrpl/basics/ResolverAsio.cpp
index 25e95b7fc5..53739fed8a 100644
--- a/src/libxrpl/basics/ResolverAsio.cpp
+++ b/src/libxrpl/basics/ResolverAsio.cpp
@@ -255,7 +255,7 @@ public:
         if (ec == boost::asio::error::operation_aborted)
             return;
 
-        std::vector addresses;
+        std::vector addresses;
         auto iter = results.begin();
 
         // If we get an error message back, we don't return any
@@ -283,7 +283,7 @@ public:
         // first attempt to parse as an endpoint (IP addr + port).
         // If that doesn't succeed, fall back to generic name + port parsing
 
-        if (auto const result = beast::IP::Endpoint::fromStringChecked(str))
+        if (auto const result = beast::ip::Endpoint::fromStringChecked(str))
         {
             return make_pair(result->address().to_string(), std::to_string(result->port()));
         }
diff --git a/src/libxrpl/basics/StringUtilities.cpp b/src/libxrpl/basics/StringUtilities.cpp
index f4edaf5aca..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,14 +67,14 @@ 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];
     // We need to use Endpoint to parse the domain to
     // strip surrounding brackets from IPv6 addresses,
     // e.g. [::1] => ::1.
-    auto const result = beast::IP::Endpoint::fromStringChecked(domain);
+    auto const result = beast::ip::Endpoint::fromStringChecked(domain);
     pUrl.domain = result ? result->address().to_string() : domain;
     std::string const port = smMatch[5];
     if (!port.empty())
@@ -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/basics/base64.cpp b/src/libxrpl/basics/base64.cpp
index c980a08669..f067dcbdca 100644
--- a/src/libxrpl/basics/base64.cpp
+++ b/src/libxrpl/basics/base64.cpp
@@ -76,24 +76,6 @@ getInverse()
     return &kTab[0];
 }
 
-/**
- * Returns max chars needed to encode a base64 string
- */
-constexpr std::size_t
-encodedSize(std::size_t n)
-{
-    return 4 * ((n + 2) / 3);
-}
-
-/**
- * Returns max bytes needed to decode a base64 string
- */
-constexpr std::size_t
-decodedSize(std::size_t n)
-{
-    return ((n / 4) * 3) + 2;
-}
-
 /**
  * Encode a series of octets as a padded, base64 string.
  *
diff --git a/src/libxrpl/beast/core/SemanticVersion.cpp b/src/libxrpl/beast/core/SemanticVersion.cpp
index a99437f8f2..f902a14b07 100644
--- a/src/libxrpl/beast/core/SemanticVersion.cpp
+++ b/src/libxrpl/beast/core/SemanticVersion.cpp
@@ -15,7 +15,7 @@
 namespace beast {
 
 std::string
-printIdentifiers(SemanticVersion::identifier_list const& list)
+printIdentifiers(SemanticVersion::IdentifierList const& list)
 {
     std::string ret;
 
@@ -115,7 +115,7 @@ extractIdentifier(std::string& value, bool allowLeadingZeroes, std::string& inpu
 
 bool
 extractIdentifiers(
-    SemanticVersion::identifier_list& identifiers,
+    SemanticVersion::IdentifierList& identifiers,
     bool allowLeadingZeroes,
     std::string& input)
 {
diff --git a/src/libxrpl/beast/insight/StatsDCollector.cpp b/src/libxrpl/beast/insight/StatsDCollector.cpp
index 3cff5d93b5..72fe6189a5 100644
--- a/src/libxrpl/beast/insight/StatsDCollector.cpp
+++ b/src/libxrpl/beast/insight/StatsDCollector.cpp
@@ -207,7 +207,7 @@ private:
     static constexpr auto kMaxPacketSize = 1472;
 
     Journal journal_;
-    IP::Endpoint address_;
+    ip::Endpoint address_;
     std::string prefix_;
     boost::asio::io_context ioContext_;
     std::optional> work_;
@@ -222,13 +222,13 @@ private:
     std::thread thread_;
 
     static boost::asio::ip::udp::endpoint
-    toEndpoint(IP::Endpoint const& ep)
+    toEndpoint(ip::Endpoint const& ep)
     {
         return boost::asio::ip::udp::endpoint(ep.address(), ep.port());
     }
 
 public:
-    StatsDCollectorImp(IP::Endpoint address, std::string prefix, Journal journal)
+    StatsDCollectorImp(ip::Endpoint address, std::string prefix, Journal journal)
         : journal_(journal)
         , address_(std::move(address))
         , prefix_(std::move(prefix))
@@ -707,7 +707,7 @@ StatsDMeterImpl::doProcess()
 //------------------------------------------------------------------------------
 
 std::shared_ptr
-StatsDCollector::make(IP::Endpoint const& address, std::string const& prefix, Journal journal)
+StatsDCollector::make(ip::Endpoint const& address, std::string const& prefix, Journal journal)
 {
     return std::make_shared(address, prefix, journal);
 }
diff --git a/src/libxrpl/beast/net/IPAddressConversion.cpp b/src/libxrpl/beast/net/IPAddressConversion.cpp
index c0a37d234e..bf24ef75c1 100644
--- a/src/libxrpl/beast/net/IPAddressConversion.cpp
+++ b/src/libxrpl/beast/net/IPAddressConversion.cpp
@@ -5,7 +5,7 @@
 #include 
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 Endpoint
 fromAsio(boost::asio::ip::address const& address)
@@ -31,4 +31,4 @@ toAsioEndpoint(Endpoint const& endpoint)
     return boost::asio::ip::tcp::endpoint{endpoint.address(), endpoint.port()};
 }
 
-}  // namespace beast::IP
+}  // namespace beast::ip
diff --git a/src/libxrpl/beast/net/IPAddressV4.cpp b/src/libxrpl/beast/net/IPAddressV4.cpp
index f9b0c96022..2a59fe1cc4 100644
--- a/src/libxrpl/beast/net/IPAddressV4.cpp
+++ b/src/libxrpl/beast/net/IPAddressV4.cpp
@@ -1,6 +1,6 @@
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 bool
 isPrivate(AddressV4 const& addr)
@@ -62,4 +62,4 @@ getClass(AddressV4 const& addr)
     return kTable[(addr.to_uint() & 0xE0000000) >> 29];
 }
 
-}  // namespace beast::IP
+}  // namespace beast::ip
diff --git a/src/libxrpl/beast/net/IPAddressV6.cpp b/src/libxrpl/beast/net/IPAddressV6.cpp
index c75ccaf1cc..e5ef55065f 100644
--- a/src/libxrpl/beast/net/IPAddressV6.cpp
+++ b/src/libxrpl/beast/net/IPAddressV6.cpp
@@ -4,7 +4,7 @@
 
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 bool
 isPrivate(AddressV6 const& addr)
@@ -58,4 +58,4 @@ isPublic(AddressV6 const& addr)
     return true;
 }
 
-}  // namespace beast::IP
+}  // namespace beast::ip
diff --git a/src/libxrpl/beast/net/IPEndpoint.cpp b/src/libxrpl/beast/net/IPEndpoint.cpp
index 5877151187..02ed5e37c5 100644
--- a/src/libxrpl/beast/net/IPEndpoint.cpp
+++ b/src/libxrpl/beast/net/IPEndpoint.cpp
@@ -14,7 +14,7 @@
 #include 
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 Endpoint::Endpoint() : port_(0)
 {
@@ -176,4 +176,4 @@ operator>>(std::istream& is, Endpoint& endpoint)
     return is;
 }
 
-}  // namespace beast::IP
+}  // namespace beast::ip
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/json/json_reader.cpp b/src/libxrpl/json/json_reader.cpp
index f9134e6629..8598f94491 100644
--- a/src/libxrpl/json/json_reader.cpp
+++ b/src/libxrpl/json/json_reader.cpp
@@ -3,9 +3,11 @@
 #include 
 #include 
 
+#include   // IWYU pragma: keep
+#include 
+
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -605,8 +607,14 @@ Reader::decodeNumber(Token& token)
 bool
 Reader::decodeDouble(Token& token)
 {
+    // Sanity check to avoid buffer overflow exploits.
+    if (token.end < token.start)
+    {
+        return addError("Unable to parse token length", token);
+    }
+
     double value = 0;
-    auto const [ptr, ec] = std::from_chars(token.start, token.end, value);
+    auto const [ptr, ec] = fast_float::from_chars(token.start, token.end, value);
 
     // Reject anything from_chars could not turn into a finite double:
     //   - ec != std::errc{}: no valid conversion, or an out-of-range magnitude
diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp
index 8116f4f641..75a49187b4 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 e6c3d632c1..2728a3b86f 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -9,8 +9,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -19,12 +21,15 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -76,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)
 {
@@ -130,6 +169,138 @@ 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 instant_recognition {
+
+AccountingDeltas
+loanOriginationDeltas(Number const& principalRequested, Number const& interestDue)
+{
+    return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue};
+}
+
+bool
+loanOriginationExceedsVaultMaximum(
+    Number const& vaultMaximum,
+    Number const& vaultTotal,
+    Number const& interestDue)
+{
+    return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal;
+}
+
+/*
+XLS-66 section 3.2.3.2, defines the default amount as
+
+DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding)
+
+Which is equivalent to (Loan.TotalValueOutstanding - Loan.ManagementFeeOutstanding)
+*/
+Number
+loanVaultExposure(SLE::const_ref loanSle)
+{
+    return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
+}
+
+AccountingDeltas
+loanPaymentDeltas(LoanPaymentParts const& parts)
+{
+    return {
+        .assetsTotalDelta = parts.valueChange,
+        .debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange};
+}
+
+}  // namespace instant_recognition
+
+namespace cash_basis {
+
+AccountingDeltas
+loanOriginationDeltas(Number const& principalRequested)
+{
+    return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested};
+}
+
+/*
+ * Under CashBasis accounting, Loan default amount is:
+ *
+ * DefaultAmount = Loan.PrincipalOutstanding
+ */
+Number
+loanVaultExposure(SLE::const_ref loanSle)
+{
+    return loanSle->at(sfPrincipalOutstanding);
+}
+
+AccountingDeltas
+loanPaymentDeltas(LoanPaymentParts const& parts)
+{
+    return {.assetsTotalDelta = parts.interestPaid, .debtTotalDelta = parts.principalPaid};
+}
+
+}  // namespace cash_basis
+
+namespace {
+
+// Cash-basis accounting applies only when featureLendingProtocolV1_1 is
+// enabled AND the specific Vault was created under it (LEVersion ==
+// VaultVersion::CashBasis). Vaults created before activation keep instant
+// interest recognition forever, even after the amendment later turns on.
+bool
+cashBasisEnabled(SLE::const_ref vaultSle)
+{
+    return getVaultVersion(vaultSle) == VaultVersion::CashBasis;
+}
+
+}  // namespace
+
+AccountingDeltas
+loanOriginationDeltas(
+    SLE::const_ref vaultSle,
+    Number const& principalRequested,
+    Number const& interestDue)
+{
+    return cashBasisEnabled(vaultSle)
+        ? cash_basis::loanOriginationDeltas(principalRequested)
+        : instant_recognition::loanOriginationDeltas(principalRequested, interestDue);
+}
+
+bool
+loanOriginationExceedsVaultMaximum(
+    SLE::const_ref vaultSle,
+    Number const& vaultTotal,
+    Number const& interestDue)
+{
+    // Cash-basis origination doesn't recognize interest into AssetsTotal, so
+    // interest due can never push the vault past AssetsMaximum at origination.
+    if (cashBasisEnabled(vaultSle))
+        return false;
+
+    auto const vaultMaximum = vaultSle->at(sfAssetsMaximum);
+    return instant_recognition::loanOriginationExceedsVaultMaximum(
+        vaultMaximum, vaultTotal, interestDue);
+}
+
+Number
+loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
+{
+    return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle)
+                                      : instant_recognition::loanVaultExposure(loanSle);
+}
+
+AccountingDeltas
+loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts)
+{
+    return cashBasisEnabled(vaultSle) ? cash_basis::loanPaymentDeltas(parts)
+                                      : instant_recognition::loanPaymentDeltas(parts);
+}
+
 namespace detail {
 
 void
@@ -354,7 +525,7 @@ loanLatePaymentInterest(
     // If the payment is not late by any amount of time, then there's no late
     // interest
     if (now <= nextPaymentDueDate)
-        return 0;
+        return kNumZero;
 
     // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
     auto const secondsOverdue = now - nextPaymentDueDate;
@@ -875,7 +1046,7 @@ doOverpayment(
 std::expected
 computeLatePayment(
     Asset const& asset,
-    ApplyView const& view,
+    ReadView const& view,
     SLE::const_ref loan,
     ExtendedPaymentComponents const& periodic,
     STAmount const& amount,
@@ -886,8 +1057,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.
@@ -968,7 +1142,7 @@ computeLatePayment(
 std::expected
 computeFullPayment(
     Asset const& asset,
-    ApplyView& view,
+    ReadView const& view,
     SLE::const_ref loan,
     Number const& periodicRate,
     STAmount const& amount,
@@ -1495,7 +1669,7 @@ makeRegularPayment(
     LoanPaymentType const paymentType,
     beast::Journal j)
 {
-    using namespace Lending;
+    using namespace lending;
 
     XRPL_ASSERT_PARTS(
         paymentType == LoanPaymentType::Regular || paymentType == LoanPaymentType::Overpayment,
@@ -2110,7 +2284,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 589e49d335..ebe5271765 100644
--- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
@@ -12,6 +12,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -773,6 +774,13 @@ tokenOfferCreatePreflight(
         return temBAD_AMOUNT;
     }
 
+    if (rules.enabled(fixCleanup3_4_0))
+    {
+        // We don't allow a non-native currency to use the currency code XRP.
+        if (badAsset() == amount.asset())
+            return temBAD_CURRENCY;
+    }
+
     if (!isXRP(amount))
     {
         if ((nftFlags & nft::kFlagOnlyXrp) != 0)
@@ -851,7 +859,13 @@ tokenOfferCreatePreclaim(
             return tefNFTOKEN_IS_NOT_TRANSFERABLE;
     }
 
-    if (isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
+    // The IOU issuer is not subject to their own global freeze when the offer
+    // is denominated in their own IOU (e.g. receiving their own transfer fees),
+    // and they cannot hold a trust line to themselves.
+    bool const acctIsIouIssuer =
+        view.rules().enabled(fixCleanup3_4_0) && acctID == amount.getIssuer();
+    if (!acctIsIouIssuer &&
+        isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
         return tecFROZEN;
 
     // If this is an offer to buy the token, the account must have the
@@ -925,7 +939,7 @@ tokenOfferCreateApply(
         priorBalance < accountReserve(view, acct, j, {.ownerCountDelta = 1}))
         return tecINSUFFICIENT_RESERVE;
 
-    auto const offerID = keylet::nftokenOffer(acctID, seqProxy.value());
+    auto const offerID = keylet::nftokenOffer(acctID, seqProxy);
 
     // Create the offer:
     {
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 b5b076d1cb..941b94143d 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -1,18 +1,26 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
 #include 
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
+#include 
 
 #include 
+#include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -63,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,
@@ -78,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;
@@ -106,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;
@@ -137,4 +217,114 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
     return sleToken->getFieldU64(sfMPTAmount) == outstanding;
 }
 
+[[nodiscard]] VaultVersion
+getVaultVersion(SLE::const_ref vault)
+{
+    XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle");
+    if (!vault->isFieldPresent(sfLEVersion))
+        return VaultVersion::Legacy;
+
+    auto const version = vault->at(sfLEVersion);
+    if (version > std::to_underlying(VaultVersion::CashBasis))
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::getVaultVersion : invalid vault version");
+        return VaultVersion::Legacy;
+        // LCOV_EXCL_STOP
+    }
+    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/peerfinder/Bootcache.cpp b/src/libxrpl/peerfinder/Bootcache.cpp
index a2a56b4d01..4f9b5fc816 100644
--- a/src/libxrpl/peerfinder/Bootcache.cpp
+++ b/src/libxrpl/peerfinder/Bootcache.cpp
@@ -16,7 +16,7 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 Bootcache::Bootcache(Store& store, clock_type& clock, beast::Journal journal)
     : store_(store), clock_(clock), journal_(journal), whenUpdate_(clock_.now())
@@ -78,7 +78,7 @@ void
 Bootcache::load()
 {
     clear();
-    auto const n(store_.load([this](beast::IP::Endpoint const& endpoint, int valence) {
+    auto const n(store_.load([this](beast::ip::Endpoint const& endpoint, int valence) {
         auto const result(this->map_.insert(value_type(endpoint, valence)));
         if (!result.second)
         {
@@ -96,7 +96,7 @@ Bootcache::load()
 }
 
 bool
-Bootcache::insert(beast::IP::Endpoint const& endpoint)
+Bootcache::insert(beast::ip::Endpoint const& endpoint)
 {
     auto const result(map_.insert(value_type(endpoint, 0)));
     if (result.second)
@@ -109,7 +109,7 @@ Bootcache::insert(beast::IP::Endpoint const& endpoint)
 }
 
 bool
-Bootcache::insertStatic(beast::IP::Endpoint const& endpoint)
+Bootcache::insertStatic(beast::ip::Endpoint const& endpoint)
 {
     auto result(map_.insert(value_type(endpoint, kStaticValence)));
 
@@ -130,7 +130,7 @@ Bootcache::insertStatic(beast::IP::Endpoint const& endpoint)
 }
 
 void
-Bootcache::onSuccess(beast::IP::Endpoint const& endpoint)
+Bootcache::onSuccess(beast::ip::Endpoint const& endpoint)
 {
     auto result(map_.insert(value_type(endpoint, 1)));
     if (result.second)
@@ -144,7 +144,7 @@ Bootcache::onSuccess(beast::IP::Endpoint const& endpoint)
         ++entry.valence();
         map_.erase(result.first);
         result = map_.insert(value_type(endpoint, entry));
-        XRPL_ASSERT(result.second, "xrpl::PeerFinder::Bootcache::onSuccess : endpoint inserted");
+        XRPL_ASSERT(result.second, "xrpl::peer_finder::Bootcache::onSuccess : endpoint inserted");
     }
     Entry const& entry(result.first->right);
     JLOG(journal_.info()) << std::left << std::setw(18) << "Bootcache connect " << endpoint
@@ -154,7 +154,7 @@ Bootcache::onSuccess(beast::IP::Endpoint const& endpoint)
 }
 
 void
-Bootcache::onFailure(beast::IP::Endpoint const& endpoint)
+Bootcache::onFailure(beast::ip::Endpoint const& endpoint)
 {
     auto result(map_.insert(value_type(endpoint, -1)));
     if (result.second)
@@ -168,7 +168,7 @@ Bootcache::onFailure(beast::IP::Endpoint const& endpoint)
         --entry.valence();
         map_.erase(result.first);
         result = map_.insert(value_type(endpoint, entry));
-        XRPL_ASSERT(result.second, "xrpl::PeerFinder::Bootcache::onFailure : endpoint inserted");
+        XRPL_ASSERT(result.second, "xrpl::peer_finder::Bootcache::onFailure : endpoint inserted");
     }
     Entry const& entry(result.first->right);
     auto const n(std::abs(entry.valence()));
@@ -201,11 +201,11 @@ Bootcache::onWrite(beast::PropertyStream::Map& map)
 void
 Bootcache::prune()
 {
-    if (size() <= Tuning::kBootcacheSize)
+    if (size() <= tuning::kBootcacheSize)
         return;
 
     // Calculate the amount to remove
-    auto count((size() * Tuning::kBootcachePrunePercent) / 100);
+    auto count((size() * tuning::kBootcachePrunePercent) / 100);
     decltype(count) pruned(0);
 
     // Work backwards because bimap doesn't handle
@@ -215,7 +215,7 @@ Bootcache::prune()
     {
         --count;
         --iter;
-        beast::IP::Endpoint const& endpoint(iter->get_left());
+        beast::ip::Endpoint const& endpoint(iter->get_left());
         Entry const& entry(iter->get_right());
         JLOG(journal_.trace()) << std::left << std::setw(18) << "Bootcache pruned" << endpoint
                                << " at valence " << entry.valence();
@@ -244,7 +244,7 @@ Bootcache::update()
     store_.save(list);
     // Reset the flag and cooldown timer
     needsUpdate_ = false;
-    whenUpdate_ = clock_.now() + Tuning::kBootcacheCooldownTime;
+    whenUpdate_ = clock_.now() + tuning::kBootcacheCooldownTime;
 }
 
 // Checks the clock and calls update if we are off the cooldown.
@@ -263,4 +263,4 @@ Bootcache::flagForUpdate()
     checkUpdate();
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/Config.cpp b/src/libxrpl/peerfinder/Config.cpp
index 3e2f74f42b..2e0f793a3a 100644
--- a/src/libxrpl/peerfinder/Config.cpp
+++ b/src/libxrpl/peerfinder/Config.cpp
@@ -7,13 +7,13 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 std::size_t
 Config::calcOutPeers() const
 {
     return std::max(
-        ((maxPeers * Tuning::kOutPercent) + 50) / 100, std::size_t(Tuning::kMinOutCount));
+        ((maxPeers * tuning::kOutPercent) + 50) / 100, std::size_t(tuning::kMinOutCount));
 }
 
 void
@@ -26,8 +26,8 @@ Config::applyTuning()
         // IP addresses.
         ipLimit = 2;
 
-        if (inPeers > Tuning::kDefaultMaxPeers)
-            ipLimit += std::min(5, static_cast(inPeers / Tuning::kDefaultMaxPeers));
+        if (inPeers > tuning::kDefaultMaxPeers)
+            ipLimit += std::min(5, static_cast(inPeers / tuning::kDefaultMaxPeers));
     }
 
     // We don't allow a single IP to consume all incoming slots,
@@ -58,7 +58,7 @@ Config::makeConfig(
     int ipLimit,
     bool verifyEndpoints)
 {
-    PeerFinder::Config config;
+    peer_finder::Config config;
 
     if (!limits.maxPeers)
     {
@@ -85,7 +85,7 @@ Config::makeConfig(
         if (limits.maxPeers && *limits.maxPeers != 0)
             config.maxPeers = *limits.maxPeers;
 
-        config.maxPeers = std::max(config.maxPeers, Tuning::kMinOutCount);
+        config.maxPeers = std::max(config.maxPeers, tuning::kMinOutCount);
         config.outPeers = config.calcOutPeers();
 
         // Calculate the number of outbound peers we want. If we dont want
@@ -107,8 +107,12 @@ Config::makeConfig(
     else
     {
         config.outPeers = *limits.outPeers;
-        config.inPeers = *limits.inPeers;
-        config.maxPeers = 0;
+
+        // Inbound slots only exist if we accept incoming connections, and
+        // `maxPeers` is the total across both directions. The legacy branch
+        // above upholds the same two invariants.
+        config.inPeers = config.wantIncoming ? *limits.inPeers : 0;
+        config.maxPeers = config.inPeers + config.outPeers;
     }
 
     // This will cause servers configured as validators to request that
@@ -132,4 +136,4 @@ Config::makeConfig(
     return config;
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/Endpoint.cpp b/src/libxrpl/peerfinder/Endpoint.cpp
index 12f2725ea5..6f3e2289f9 100644
--- a/src/libxrpl/peerfinder/Endpoint.cpp
+++ b/src/libxrpl/peerfinder/Endpoint.cpp
@@ -5,11 +5,11 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
-Endpoint::Endpoint(beast::IP::Endpoint ep, std::uint32_t hops)
-    : hops(std::min(hops, Tuning::kMaxHops + 1)), address(std::move(ep))
+Endpoint::Endpoint(beast::ip::Endpoint ep, std::uint32_t hops)
+    : hops(std::min(hops, tuning::kMaxHops + 1)), address(std::move(ep))
 {
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/PeerfinderManager.cpp b/src/libxrpl/peerfinder/PeerfinderManager.cpp
index 2219627f09..0cce2389ec 100644
--- a/src/libxrpl/peerfinder/PeerfinderManager.cpp
+++ b/src/libxrpl/peerfinder/PeerfinderManager.cpp
@@ -29,7 +29,7 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 class ManagerImp : public Manager
 {
@@ -98,7 +98,7 @@ public:
     }
 
     void
-    addFixedPeer(std::string_view name, std::vector const& addresses) override
+    addFixedPeer(std::string_view name, std::vector const& addresses) override
     {
         logic_.addFixedPeer(name, addresses);
     }
@@ -119,14 +119,14 @@ public:
 
     std::pair, Result>
     newInboundSlot(
-        beast::IP::Endpoint const& localEndpoint,
-        beast::IP::Endpoint const& remoteEndpoint) override
+        beast::ip::Endpoint const& localEndpoint,
+        beast::ip::Endpoint const& remoteEndpoint) override
     {
         return logic_.newInboundSlot(localEndpoint, remoteEndpoint);
     }
 
     std::pair, Result>
-    newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) override
+    newOutboundSlot(beast::ip::Endpoint const& remoteEndpoint) override
     {
         return logic_.newOutboundSlot(remoteEndpoint);
     }
@@ -163,7 +163,7 @@ public:
     //--------------------------------------------------------------------------
 
     bool
-    onConnected(std::shared_ptr const& slot, beast::IP::Endpoint const& localEndpoint)
+    onConnected(std::shared_ptr const& slot, beast::ip::Endpoint const& localEndpoint)
         override
     {
         SlotImp::ptr const impl(std::dynamic_pointer_cast(slot));
@@ -184,7 +184,7 @@ public:
         return logic_.redirect(impl);
     }
 
-    std::vector
+    std::vector
     autoconnect() override
     {
         return logic_.autoconnect();
@@ -265,4 +265,4 @@ makeManager(
     return std::make_unique(ioContext, clock, journal, store, collector);
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/SlotImp.cpp b/src/libxrpl/peerfinder/SlotImp.cpp
index 0a4f32fd62..5209bd51ab 100644
--- a/src/libxrpl/peerfinder/SlotImp.cpp
+++ b/src/libxrpl/peerfinder/SlotImp.cpp
@@ -9,11 +9,11 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 SlotImp::SlotImp(
-    beast::IP::Endpoint const& localEndpoint,
-    beast::IP::Endpoint remoteEndpoint,
+    beast::ip::Endpoint const& localEndpoint,
+    beast::ip::Endpoint remoteEndpoint,
     bool fixed,
     clock_type& clock)
     : recent(clock)
@@ -30,7 +30,7 @@ SlotImp::SlotImp(
 {
 }
 
-SlotImp::SlotImp(beast::IP::Endpoint remoteEndpoint, bool fixed, clock_type& clock)
+SlotImp::SlotImp(beast::ip::Endpoint remoteEndpoint, bool fixed, clock_type& clock)
     : recent(clock)
     , inbound_(false)
     , fixed_(fixed)
@@ -49,29 +49,29 @@ SlotImp::state(State state)
 {
     // Must go through activate() to set active state
     XRPL_ASSERT(
-        state != State::Active, "xrpl::PeerFinder::SlotImp::state : input state is not active");
+        state != State::Active, "xrpl::peer_finder::SlotImp::state : input state is not active");
 
     // The state must be different
     XRPL_ASSERT(
         state_ != state,
-        "xrpl::PeerFinder::SlotImp::state : input state is different from "
+        "xrpl::peer_finder::SlotImp::state : input state is different from "
         "current");
 
     // You can't transition into the initial states
     XRPL_ASSERT(
         state != State::Accept && state != State::Connect,
-        "xrpl::PeerFinder::SlotImp::state : input state is not an initial");
+        "xrpl::peer_finder::SlotImp::state : input state is not an initial");
 
     // Can only become connected from outbound connect state
     XRPL_ASSERT(
         state != State::Connected || (!inbound_ && state_ == State::Connect),
-        "xrpl::PeerFinder::SlotImp::state : input state is not connected an "
+        "xrpl::peer_finder::SlotImp::state : input state is not connected an "
         "invalid state");
 
     // Can't gracefully close on an outbound connection attempt
     XRPL_ASSERT(
         state != State::Closing || state_ != State::Connect,
-        "xrpl::PeerFinder::SlotImp::state : input state is not closing an "
+        "xrpl::peer_finder::SlotImp::state : input state is not closing an "
         "invalid state");
 
     state_ = state;
@@ -83,7 +83,7 @@ SlotImp::activate(clock_type::time_point const& now)
     // Can only become active from the accept or connected state
     XRPL_ASSERT(
         state_ == State::Accept || state_ == State::Connected,
-        "xrpl::PeerFinder::SlotImp::activate : valid state");
+        "xrpl::peer_finder::SlotImp::activate : valid state");
 
     state_ = State::Active;
     whenAcceptEndpoints = now;
@@ -100,7 +100,7 @@ SlotImp::RecentT::RecentT(clock_type& clock) : cache_(clock)
 }
 
 void
-SlotImp::RecentT::insert(beast::IP::Endpoint const& ep, std::uint32_t hops)
+SlotImp::RecentT::insert(beast::ip::Endpoint const& ep, std::uint32_t hops)
 {
     auto const result(cache_.emplace(ep, hops));
     if (!result.second)
@@ -115,7 +115,7 @@ SlotImp::RecentT::insert(beast::IP::Endpoint const& ep, std::uint32_t hops)
 }
 
 bool
-SlotImp::RecentT::filter(beast::IP::Endpoint const& ep, std::uint32_t hops)
+SlotImp::RecentT::filter(beast::ip::Endpoint const& ep, std::uint32_t hops)
 {
     auto const iter(cache_.find(ep));
     if (iter == cache_.end())
@@ -129,7 +129,7 @@ SlotImp::RecentT::filter(beast::IP::Endpoint const& ep, std::uint32_t hops)
 void
 SlotImp::RecentT::expire()
 {
-    beast::expire(cache_, Tuning::kLiveCacheSecondsToLive);
+    beast::expire(cache_, tuning::kLiveCacheSecondsToLive);
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/SourceStrings.cpp b/src/libxrpl/peerfinder/SourceStrings.cpp
index f47e0cd51d..ca6ff07cba 100644
--- a/src/libxrpl/peerfinder/SourceStrings.cpp
+++ b/src/libxrpl/peerfinder/SourceStrings.cpp
@@ -8,7 +8,7 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 class SourceStringsImp : public SourceStrings
 {
@@ -33,9 +33,9 @@ public:
         results.addresses.reserve(strings_.size());
         for (auto const& str : strings_)
         {
-            beast::IP::Endpoint ep(beast::IP::Endpoint::fromString(str));
+            beast::ip::Endpoint ep(beast::ip::Endpoint::fromString(str));
             if (isUnspecified(ep))
-                ep = beast::IP::Endpoint::fromString(str);
+                ep = beast::ip::Endpoint::fromString(str);
             if (!isUnspecified(ep))
                 results.addresses.push_back(ep);
         }
@@ -54,4 +54,4 @@ SourceStrings::make(std::string const& name, Strings const& strings)
     return std::make_shared(name, strings);
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp
index 6ac352f3e1..9788b4c025 100644
--- a/src/libxrpl/protocol/BuildInfo.cpp
+++ b/src/libxrpl/protocol/BuildInfo.cpp
@@ -13,7 +13,7 @@
 #include 
 #include 
 
-namespace xrpl::BuildInfo {
+namespace xrpl::build_info {
 
 namespace {
 
@@ -23,7 +23,7 @@ namespace {
 //------------------------------------------------------------------------------
 // clang-format off
 // NOLINTNEXTLINE(readability-identifier-naming)
-char const* const versionString = "3.3.0-rc1"
+char const* const versionString = "3.5.0-b0"
     // clang-format on
     ;
 
@@ -173,4 +173,4 @@ isNewerVersion(std::uint64_t version)
     return false;
 }
 
-}  // namespace xrpl::BuildInfo
+}  // namespace xrpl::build_info
diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp
index fe8a08c2ef..a3e48b5f31 100644
--- a/src/libxrpl/protocol/ConfidentialTransfer.cpp
+++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp
@@ -4,10 +4,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -124,7 +127,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 +274,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 +288,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 +325,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 +341,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 +369,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 ||
@@ -362,11 +399,89 @@ checkEncryptedAmountFormat(STObject const& object)
     return tesSUCCESS;
 }
 
+bool
+isIssuerMirrorCurrent(SLE const& issuance, SLE const& mptoken)
+{
+    XRPL_ASSERT(
+        issuance.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isIssuerMirrorCurrent : issuance MPTokenIssuance object");
+    XRPL_ASSERT(
+        mptoken.getType() == ltMPTOKEN, "xrpl::isIssuerMirrorCurrent : mptoken MPToken object");
+
+    return mptoken.isFieldPresent(sfIssuerEncryptedBalance) &&
+        mptoken[~sfIssuerKeyMirrorEpoch].value_or(0) == issuance[~sfIssuerKeyEpoch].value_or(0);
+}
+
+bool
+isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken)
+{
+    XRPL_ASSERT(
+        issuance.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isAuditorMirrorCurrent : issuance MPTokenIssuance object");
+    XRPL_ASSERT(
+        mptoken.getType() == ltMPTOKEN, "xrpl::isAuditorMirrorCurrent : mptoken MPToken object");
+
+    if (!issuance.isFieldPresent(sfAuditorEncryptionKey))
+        return true;
+
+    return mptoken.isFieldPresent(sfAuditorEncryptedBalance) &&
+        mptoken[~sfAuditorKeyMirrorEpoch].value_or(0) == issuance[~sfAuditorKeyEpoch].value_or(0);
+}
+
+bool
+areMirrorsCurrent(SLE const& issuance, SLE const& mptoken)
+{
+    return isIssuerMirrorCurrent(issuance, mptoken) && isAuditorMirrorCurrent(issuance, mptoken);
+}
+
+void
+setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken)
+{
+    XRPL_ASSERT(
+        issuance.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::setIssuerMirrorEpoch : issuance MPTokenIssuance object");
+    XRPL_ASSERT(
+        mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorEpoch : mptoken MPToken object");
+
+    // Unlike the auditor mirror, the issuer mirror is not optional: every
+    // confidential MPToken carries one, so there is no existence check here.
+    if (auto const epoch = issuance[~sfIssuerKeyEpoch].value_or(0); epoch != 0)
+        mptoken[sfIssuerKeyMirrorEpoch] = epoch;
+}
+
+void
+setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken)
+{
+    XRPL_ASSERT(
+        issuance.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::setAuditorMirrorEpoch : issuance MPTokenIssuance object");
+    XRPL_ASSERT(
+        mptoken.getType() == ltMPTOKEN, "xrpl::setAuditorMirrorEpoch : mptoken MPToken object");
+
+    if (!mptoken.isFieldPresent(sfAuditorEncryptedBalance))
+        return;
+
+    if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0)
+        mptoken[sfAuditorKeyMirrorEpoch] = epoch;
+}
+
+void
+setMirrorEpochs(SLE const& issuance, SLE& mptoken)
+{
+    setIssuerMirrorEpoch(issuance, mptoken);
+    setAuditorMirrorEpoch(issuance, mptoken);
+}
+
 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 +500,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 +540,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 +558,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 +603,12 @@ verifyConvertBackProof(
         spendingBalance.size() != kEcGamalEncryptedTotalLength ||
         balanceCommitment.size() != kEcPedersenCommitmentLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyConvertBackProof : callers must pre-validate proof/public "
+            "key/balance/commitment lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     if (mpt_verify_convert_back_proof(
diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp
index 87761ce13e..802bae100d 100644
--- a/src/libxrpl/protocol/ErrorCodes.cpp
+++ b/src/libxrpl/protocol/ErrorCodes.cpp
@@ -9,7 +9,7 @@
 #include 
 
 namespace xrpl {
-namespace RPC {
+namespace rpc {
 
 namespace detail {
 
@@ -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 = {};
@@ -215,12 +214,12 @@ errorCodeHttpStatus(ErrorCodeI code)
     return getErrorInfo(code).httpStatus;
 }
 
-}  // namespace RPC
+}  // namespace rpc
 
 std::string
 rpcErrorString(json::Value const& jv)
 {
-    XRPL_ASSERT(RPC::containsError(jv), "xrpl::RPC::rpcErrorString : input contains an error");
+    XRPL_ASSERT(rpc::containsError(jv), "xrpl::rpc::rpcErrorString : input contains an error");
     return jv[jss::error].asString() + jv[jss::error_message].asString();
 }
 
diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp
index 95416d0f2a..91ed5c893f 100644
--- a/src/libxrpl/protocol/Indexes.cpp
+++ b/src/libxrpl/protocol/Indexes.cpp
@@ -123,6 +123,10 @@ getBookBase(Book const& book)
 {
     XRPL_ASSERT(isConsistent(book), "xrpl::getBookBase : input is consistent");
 
+    constexpr std::uint8_t kIssueToMPTTag = 0x01;
+    constexpr std::uint8_t kMPTToIssueTag = 0x02;
+    constexpr std::uint8_t kMPTToMPTTag = 0x03;
+
     auto getIndexHash = [&book](Args... args) {
         if (book.domain)
             return indexHash(std::forward(args)..., *book.domain);
@@ -136,19 +140,36 @@ getBookBase(Book const& book)
                 return getIndexHash(
                     LedgerNameSpace::BookDir, in.currency, out.currency, in.account, out.account);
             }
+            // The three MPT-involving branches are new under MPTokensV2 and
+            // each gets a 1-byte discriminator to prevent preimage collisions
+            // between branches: the (Issue,MPT) and (MPT,Issue) preimages
+            // are both 64 bytes of raw concatenation, so without a
+            // per-branch tag chosen Currency / MPTID / AccountID values can
+            // align byte-for-byte and produce the same BookDir keylet for
+            // two distinct markets. (Issue,Issue) is left untagged to
+            // preserve existing mainnet order-book keylets.
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.currency, out.getMptID(), in.account);
+                    LedgerNameSpace::BookDir,
+                    kIssueToMPTTag,
+                    in.currency,
+                    out.getMptID(),
+                    in.account);
             }
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.getMptID(), out.currency, out.account);
+                    LedgerNameSpace::BookDir,
+                    kMPTToIssueTag,
+                    in.getMptID(),
+                    out.currency,
+                    out.account);
             }
             else
             {
-                return getIndexHash(LedgerNameSpace::BookDir, in.getMptID(), out.getMptID());
+                return getIndexHash(
+                    LedgerNameSpace::BookDir, kMPTToMPTTag, in.getMptID(), out.getMptID());
             }
         },
         book.in.value(),
@@ -180,26 +201,13 @@ getQuality(uint256 const& uBase)
     return boost::endian::load_big_u64(uBase.end() - 8);
 }
 
-uint256
-getTicketIndex(AccountID const& account, std::uint32_t ticketSeq)
-{
-    return indexHash(LedgerNameSpace::Ticket, account, ticketSeq);
-}
-
-uint256
-getTicketIndex(AccountID const& account, SeqProxy ticketSeq)
-{
-    XRPL_ASSERT(ticketSeq.isTicket(), "xrpl::getTicketIndex : valid input");
-    return getTicketIndex(account, ticketSeq.value());
-}
-
 MPTID
-makeMptID(std::uint32_t sequence, AccountID const& account)
+makeMptID(std::uint32_t const sequence, AccountID const& account)
 {
     MPTID u;
-    sequence = boost::endian::native_to_big(sequence);
-    memcpy(u.data(), &sequence, sizeof(sequence));
-    memcpy(u.data() + sizeof(sequence), account.data(), sizeof(account));
+    auto const bigEndianSequence = boost::endian::native_to_big(sequence);
+    memcpy(u.data(), &bigEndianSequence, sizeof(bigEndianSequence));
+    memcpy(u.data() + sizeof(bigEndianSequence), account.data(), sizeof(account));
     return u;
 }
 
@@ -286,13 +294,13 @@ trustLine(AccountID const& id0, AccountID const& id1, Currency const& currency)
 }
 
 Keylet
-offer(AccountID const& id, std::uint32_t seq) noexcept
+offer(AccountID const& id, SeqProxy const& seq) noexcept
 {
-    return {ltOFFER, indexHash(LedgerNameSpace::Offer, id, seq)};
+    return {ltOFFER, indexHash(LedgerNameSpace::Offer, id, seq.value())};
 }
 
 Keylet
-quality(Keylet const& k, std::uint64_t q) noexcept
+quality(Keylet const& k, std::uint64_t const q) noexcept
 {
     XRPL_ASSERT(k.type == ltDIR_NODE, "xrpl::keylet::quality : valid input type");
 
@@ -320,22 +328,17 @@ next(Keylet const& k)
 }
 
 Keylet
-ticket(AccountID const& id, std::uint32_t ticketSeq)
+ticket(AccountID const& id, SeqProxy const& seq)
 {
-    return {ltTICKET, getTicketIndex(id, ticketSeq)};
-}
-
-Keylet
-ticket(AccountID const& id, SeqProxy ticketSeq)
-{
-    return {ltTICKET, getTicketIndex(id, ticketSeq)};
+    XRPL_ASSERT(seq.isTicket(), "xrpl::keylet::ticket : valid input");
+    return {ltTICKET, indexHash(LedgerNameSpace::Ticket, id, seq.value())};
 }
 
 // This function is presently static, since it's never accessed from anywhere
 // else. If we ever support multiple pages of signer lists, this would be the
 // keylet used to locate them.
 static Keylet
-signerList(AccountID const& account, std::uint32_t page) noexcept
+signerList(AccountID const& account, std::uint32_t const page) noexcept
 {
     return {ltSIGNER_LIST, indexHash(LedgerNameSpace::SignerList, account, page)};
 }
@@ -353,9 +356,9 @@ sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept
 }
 
 Keylet
-check(AccountID const& id, std::uint32_t seq) noexcept
+check(AccountID const& id, SeqProxy const& seq) noexcept
 {
-    return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq)};
+    return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq.value())};
 }
 
 Keylet
@@ -394,7 +397,7 @@ ownerDir(AccountID const& id) noexcept
 }
 
 Keylet
-page(uint256 const& key, std::uint64_t index) noexcept
+page(uint256 const& key, std::uint64_t const index) noexcept
 {
     if (index == 0)
         return {ltDIR_NODE, key};
@@ -403,15 +406,15 @@ page(uint256 const& key, std::uint64_t index) noexcept
 }
 
 Keylet
-escrow(AccountID const& src, std::uint32_t seq) noexcept
+escrow(AccountID const& src, SeqProxy const& seq) noexcept
 {
-    return {ltESCROW, indexHash(LedgerNameSpace::Escrow, src, seq)};
+    return {ltESCROW, indexHash(LedgerNameSpace::Escrow, src, seq.value())};
 }
 
 Keylet
-payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept
+payChannel(AccountID const& src, AccountID const& dst, SeqProxy const& seq) noexcept
 {
-    return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq)};
+    return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq.value())};
 }
 
 Keylet
@@ -438,9 +441,9 @@ nftokenPage(Keylet const& k, uint256 const& token)
 }
 
 Keylet
-nftokenOffer(AccountID const& owner, std::uint32_t seq)
+nftokenOffer(AccountID const& owner, SeqProxy const& seq)
 {
-    return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq)};
+    return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq.value())};
 }
 
 Keylet
@@ -512,7 +515,7 @@ bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType)
 }
 
 Keylet
-xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq)
+xChainClaimID(STXChainBridge const& bridge, std::uint64_t const seq)
 {
     return {
         ltXCHAIN_OWNED_CLAIM_ID,
@@ -526,7 +529,7 @@ xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq)
 }
 
 Keylet
-xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq)
+xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t const seq)
 {
     return {
         ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID,
@@ -546,17 +549,11 @@ did(AccountID const& account) noexcept
 }
 
 Keylet
-oracle(AccountID const& account, std::uint32_t const& documentID) noexcept
+oracle(AccountID const& account, std::uint32_t const documentID) noexcept
 {
     return {ltORACLE, indexHash(LedgerNameSpace::Oracle, account, documentID)};
 }
 
-Keylet
-mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept
-{
-    return mptokenIssuance(makeMptID(seq, issuer));
-}
-
 Keylet
 mptokenIssuance(MPTID const& issuanceID) noexcept
 {
@@ -582,27 +579,29 @@ credential(AccountID const& subject, AccountID const& issuer, Slice const& credT
 }
 
 Keylet
-vault(AccountID const& owner, std::uint32_t seq) noexcept
+vault(AccountID const& owner, SeqProxy const& seq) noexcept
 {
-    return vault(indexHash(LedgerNameSpace::Vault, owner, seq));
+    return vault(indexHash(LedgerNameSpace::Vault, owner, seq.value()));
 }
 
 Keylet
-loanBroker(AccountID const& owner, std::uint32_t seq) noexcept
+loanBroker(AccountID const& owner, SeqProxy const& seq) noexcept
 {
-    return loanBroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq));
+    return loanBroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq.value()));
 }
 
 Keylet
-loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept
+loan(uint256 const& loanBrokerID, SeqProxy const& loanSeq) noexcept
 {
-    return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq));
+    return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq.value()));
 }
 
 Keylet
-permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept
+permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept
 {
-    return {ltPERMISSIONED_DOMAIN, indexHash(LedgerNameSpace::PermissionedDomain, account, seq)};
+    return {
+        ltPERMISSIONED_DOMAIN,
+        indexHash(LedgerNameSpace::PermissionedDomain, account, seq.value())};
 }
 
 Keylet
diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp
index 0bdb217771..5cb7d166e9 100644
--- a/src/libxrpl/protocol/InnerObjectFormats.cpp
+++ b/src/libxrpl/protocol/InnerObjectFormats.cpp
@@ -137,9 +137,9 @@ InnerObjectFormats::InnerObjectFormats()
             {sfCredentialType, SoeRequired},
         });
 
-    add(sfPermission.jsonName.cStr(), sfPermission.getCode(), {{sfPermissionValue, SoeRequired}});
+    add(sfPermission.jsonName, sfPermission.getCode(), {{sfPermissionValue, SoeRequired}});
 
-    add(sfBatchSigner.jsonName.cStr(),
+    add(sfBatchSigner.jsonName,
         sfBatchSigner.getCode(),
         {{sfAccount, SoeRequired},
          {sfSigningPubKey, SoeOptional},
@@ -161,7 +161,7 @@ InnerObjectFormats::InnerObjectFormats()
             {sfSigners, SoeOptional},
         });
 
-    add(sfSponsorSignature.jsonName.cStr(),
+    add(sfSponsorSignature.jsonName,
         sfSponsorSignature.getCode(),
         {
             {sfSigningPubKey, SoeOptional},
diff --git a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp
deleted file mode 100644
index 4f0a2d5071..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/RPCErr.cpp b/src/libxrpl/protocol/RPCErr.cpp
index ec9a3dee9d..c172a1898d 100644
--- a/src/libxrpl/protocol/RPCErr.cpp
+++ b/src/libxrpl/protocol/RPCErr.cpp
@@ -13,7 +13,7 @@ json::Value
 rpcError(ErrorCodeI iError)
 {
     json::Value jvResult(json::ValueType::Object);
-    RPC::injectError(iError, jvResult);
+    rpc::injectError(iError, jvResult);
     return jvResult;
 }
 
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/STIssue.cpp b/src/libxrpl/protocol/STIssue.cpp
index 10403d2c50..ba32c1214c 100644
--- a/src/libxrpl/protocol/STIssue.cpp
+++ b/src/libxrpl/protocol/STIssue.cpp
@@ -11,6 +11,8 @@
 #include 
 #include 
 
+#include 
+
 #include 
 #include 
 #include 
@@ -45,6 +47,10 @@ STIssue::STIssue(SerialIter& sit, SField const& name) : STBase{name}
         {
             MPTID mptID;
             std::uint32_t sequence = sit.get32();
+            // MPTID stores the sequence in canonical big-endian bytes. STIssue
+            // ledger bytes are the legacy LE-host encoding, so convert the
+            // native get32() value to LE bytes before copying into the MPTID.
+            sequence = boost::endian::native_to_little(sequence);
             static_assert(MPTID::size() == sizeof(sequence) + sizeof(currencyOrAccount));
             memcpy(mptID.data(), &sequence, sizeof(sequence));
             memcpy(
@@ -100,6 +106,10 @@ STIssue::add(Serializer& s) const
             s.addBitString(noAccount());
             std::uint32_t sequence = 0;
             memcpy(&sequence, issue.getMptID().data(), sizeof(sequence));
+            // The MPTID bytes are canonical big-endian. Interpret those bytes
+            // as the legacy LE-host value so add32() writes the preserved
+            // STIssue wire bytes on every host endian.
+            sequence = boost::endian::little_to_native(sequence);
             s.add32(sequence);
         });
 }
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/STObject.cpp b/src/libxrpl/protocol/STObject.cpp
index c2543d2cca..4447a69457 100644
--- a/src/libxrpl/protocol/STObject.cpp
+++ b/src/libxrpl/protocol/STObject.cpp
@@ -56,10 +56,15 @@ STObject::STObject(SOTemplate const& type, SField const& name) : STBase(name)
     set(type);
 }
 
-STObject::STObject(SOTemplate const& type, SerialIter& sit, SField const& name) : STBase(name)
+STObject::STObject(
+    SOTemplate const& type,
+    SerialIter& sit,
+    SField const& name,
+    bool requireCanonicalOrder)
+    : STBase(name)
 {
     v_.reserve(type.size());
-    set(sit);
+    set(sit, 0, requireCanonicalOrder);
     applyTemplate(type);  // May throw
 }
 
@@ -208,12 +213,13 @@ STObject::applyTemplateFromSField(SField const& sField)
 
 // return true = terminated with end-of-object
 bool
-STObject::set(SerialIter& sit, int depth)
+STObject::set(SerialIter& sit, int depth, bool requireCanonicalOrder)
 {
     bool reachedEndOfObject = false;
 
     v_.clear();
 
+    std::optional prevFieldCode;
     // Consume data in the pipe until we run out or reach the end
     while (!sit.empty())
     {
@@ -238,7 +244,6 @@ STObject::set(SerialIter& sit, int depth)
         }
 
         auto const& fn = SField::getField(type, field);
-
         if (fn.isInvalid())
         {
             JLOG(debugLog().error())
@@ -246,6 +251,13 @@ STObject::set(SerialIter& sit, int depth)
             Throw("Unknown field");
         }
 
+        if (requireCanonicalOrder && prevFieldCode.has_value() && fn.fieldCodeMem <= *prevFieldCode)
+        {
+            JLOG(debugLog().error()) << "Fields in object are not in canonical order";
+            Throw("Fields in object are not in canonical order");
+        }
+        prevFieldCode = fn.fieldCodeMem;
+
         // Unflatten the field
         v_.emplace_back(sit, fn, depth + 1);
 
diff --git a/src/libxrpl/protocol/STParsedJSON.cpp b/src/libxrpl/protocol/STParsedJSON.cpp
index 33ca5424d1..6ab272b3d1 100644
--- a/src/libxrpl/protocol/STParsedJSON.cpp
+++ b/src/libxrpl/protocol/STParsedJSON.cpp
@@ -48,7 +48,7 @@
 
 namespace xrpl {
 
-namespace STParsedJSONDetail {
+namespace st_parsed_json_detail {
 template 
 constexpr U
 toUnsigned(S value)
@@ -93,7 +93,7 @@ makeName(std::string const& object, std::string const& field)
 static inline json::Value
 notAnObject(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' is not a JSON object.");
 }
 
@@ -106,33 +106,33 @@ notAnObject(std::string const& object)
 static inline json::Value
 notAnArray(std::string const& object)
 {
-    return RPC::makeError(RpcInvalidParams, "Field '" + object + "' is not a JSON array.");
+    return rpc::makeError(RpcInvalidParams, "Field '" + object + "' is not a JSON array.");
 }
 
 static inline json::Value
 unknownField(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(RpcInvalidParams, "Field '" + makeName(object, field) + "' is unknown.");
+    return rpc::makeError(RpcInvalidParams, "Field '" + makeName(object, field) + "' is unknown.");
 }
 
 static inline json::Value
 outOfRange(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' is out of range.");
 }
 
 static inline json::Value
 badType(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' has bad type.");
 }
 
 static inline json::Value
 invalidData(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' has invalid data.");
 }
 
@@ -145,14 +145,14 @@ invalidData(std::string const& object)
 static inline json::Value
 arrayExpected(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' must be a JSON array.");
 }
 
 static inline json::Value
 arrayTooBig(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams,
         "Field '" + makeName(object, field) + "' exceeds allowed JSON array size of " +
             std::to_string(kMaxParsedJsonArraySize) + " elements per field.");
@@ -161,20 +161,20 @@ arrayTooBig(std::string const& object, std::string const& field)
 static inline json::Value
 stringExpected(std::string const& object, std::string const& field)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams, "Field '" + makeName(object, field) + "' must be a string.");
 }
 
 static inline json::Value
 tooDeep(std::string const& object)
 {
-    return RPC::makeError(RpcInvalidParams, "Field '" + object + "' exceeds nesting depth limit.");
+    return rpc::makeError(RpcInvalidParams, "Field '" + object + "' exceeds nesting depth limit.");
 }
 
 static inline json::Value
 singletonExpected(std::string const& object, unsigned int index)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams,
         "Field '" + object + "[" + std::to_string(index) +
             "]' must be an object with a single key/object value.");
@@ -183,7 +183,7 @@ singletonExpected(std::string const& object, unsigned int index)
 static inline json::Value
 templateMismatch(SField const& sField)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams,
         "Object '" + sField.getName() + "' contents did not meet requirements for that type.");
 }
@@ -191,7 +191,7 @@ templateMismatch(SField const& sField)
 static inline json::Value
 nonObjectInArray(std::string const& item, json::UInt index)
 {
-    return RPC::makeError(
+    return rpc::makeError(
         RpcInvalidParams,
         "Item '" + item + "' at index " + std::to_string(index) +
             " is not an object.  Arrays may only contain objects.");
@@ -791,7 +791,7 @@ parseLeaf(
 
                         if (pathEl.isMember(jss::currency) && pathEl.isMember(jss::mpt_issuance_id))
                         {
-                            error = RPC::makeError(RpcInvalidParams, "Invalid Asset.");
+                            error = rpc::makeError(RpcInvalidParams, "Invalid Asset.");
                             return ret;
                         }
 
@@ -1195,13 +1195,13 @@ parseArray(
     }
 }
 
-}  // namespace STParsedJSONDetail
+}  // namespace st_parsed_json_detail
 
 //------------------------------------------------------------------------------
 
 STParsedJSONObject::STParsedJSONObject(std::string const& name, json::Value const& json)
 {
-    using namespace STParsedJSONDetail;
+    using namespace st_parsed_json_detail;
     object = parseObject(name, json, sfGeneric, 0, error);
 }
 
diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp
index 8987d05f1e..2c074c3f2f 100644
--- a/src/libxrpl/protocol/STPathSet.cpp
+++ b/src/libxrpl/protocol/STPathSet.cpp
@@ -1,6 +1,8 @@
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -11,10 +13,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -31,6 +35,11 @@ STPathElement::getHash(STPathElement const& element)
     // NIKB NOTE: This doesn't have to be a secure hash as speed is more
     //            important. We don't even really need to fully hash the whole
     //            base_uint here, as a few bytes would do for our use.
+    //
+    // The note above is only true because the result of this function reaches
+    // nothing but STPathElement::operator==, where it is a fast-reject
+    // prefilter ahead of the field comparisons that decide the answer.  Do not
+    // use it to key a container.
 
     for (auto const x : element.getAccountID())
         hashAccount += (hashAccount * 257) ^ x;
@@ -51,6 +60,51 @@ STPathElement::getHash(STPathElement const& element)
     return (hashAccount ^ hashCurrency ^ hashIssuer);
 }
 
+// For guidance on deciding which option to pursue:
+// 1. Try to decrease the size of the STPathSet first.  For instance, if a std::optional was
+//    injected into the type, could you get the same functionality using a std::unique_ptr instead?
+// 2. If the size of the STPathSet is already as small as it can be, then consider what the cost
+//    of increasing STVar::kMaxSize would be on all the other STVar types.  Each of those types
+//    will carry the additional cost of accommodating the larger STPathSet in their SBO.
+// 3. If the cost of increasing STVar::kMaxSize is too high, then heap allocate the STPathSet and
+//    remove this static_assert.
+static_assert(
+    sizeof(STPathSet) <= detail::STVar::kMaxSize,
+    "STPathSet is too large to fit in STVar's small object optimization. Please verify if it "
+    "should, if the kMaxSize should be increased, or if STPathSet should be stored on the heap "
+    "instead of in STVar.");
+
+STPathSet::STPathSet(DeduplicationTag) : seen_{std::make_unique>()}
+{
+}
+
+STPathSet::STPathSet(STPathSet const& other)
+    : STBase{other}
+    , CountedObject{other}
+    , value_{other.value_}
+    , seen_{
+          other.seen_ != nullptr ? std::make_unique>(*other.seen_)
+                                 : nullptr}
+{
+}
+
+STPathSet&
+STPathSet::operator=(STPathSet const& other)
+{
+    if (this == &other)
+    {
+        return *this;
+    }
+    auto newSeen = other.seen_ != nullptr
+        ? std::make_unique>(*other.seen_)
+        : nullptr;
+    STBase::operator=(other);
+    CountedObject::operator=(other);
+    value_ = other.value_;
+    seen_ = std::move(newSeen);
+    return *this;
+}
+
 STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name)
 {
     std::vector path;
@@ -66,7 +120,8 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name)
                 Throw("empty path");
             }
 
-            pushBack(path);
+            // Move rather than converting the vector to an STPath by copy.
+            value_.emplace_back(std::move(path));
             path.clear();
 
             if (iType == STPathElement::TypeNone)
@@ -126,22 +181,10 @@ STPathSet::move(std::size_t n, void* buf)
 bool
 STPathSet::assembleAdd(STPath const& base, STPathElement const& tail)
 {  // assemble base+tail and add it to the set if it's not a duplicate
-    value_.push_back(base);
-
-    auto it = value_.rbegin();
-
-    STPath& newPath = *it;
-    newPath.pushBack(tail);
-
-    while (++it != value_.rend())
-    {
-        if (*it == newPath)
-        {
-            value_.pop_back();
-            return false;
-        }
-    }
-    return true;
+    XRPL_ASSERT(seen_ != nullptr, "xrpl::STPathSet::assembleAdd : DeduplicationTag");
+    STPath combined = base;
+    combined.pushBack(tail);
+    return appendUnique([&](auto& value) { value.push_back(std::move(combined)); });
 }
 
 bool
diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp
index 17d7617590..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();
 }
@@ -200,48 +200,54 @@ STTx::getSeqProxy() const
 {
     std::uint32_t const seq{getFieldU32(sfSequence)};
     if (seq != 0)
-        return SeqProxy::sequence(seq);
+        return SeqProxy::rawSequence(seq);
 
-    std::optional const ticketSeq{operator[](~sfTicketSequence)};
+    std::optional const ticketSeq{at(~sfTicketSequence)};
     if (!ticketSeq)
     {
         // No TicketSequence specified.  Return the Sequence, whatever it is.
-        return SeqProxy::sequence(seq);
+        return SeqProxy::rawSequence(seq);
     }
 
-    return SeqProxy{SeqProxy::Type::Ticket, *ticketSeq};
+    return SeqProxy::rawTicket(*ticketSeq);
 }
 
-std::uint32_t
-STTx::getSeqValue() const
+void
+STTx::sign(PublicKey const& publicKey, SecretKey const& secretKey)
 {
-    return getSeqProxy().value();
+    // 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
     {
@@ -250,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 (...)
     {
@@ -262,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());
     }
 
@@ -283,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
     {
@@ -324,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)
@@ -405,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
@@ -448,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));
 }
 
@@ -459,7 +472,7 @@ STTx::checkBatchSingleSign(STObject const& batchSigner, std::vector con
 {
     XRPL_ASSERT(getTxnType() == ttBATCH, "STTx::checkBatchSingleSign : batch transaction");
     Serializer msg;
-    serializeBatch(msg, getAccountID(sfAccount), getSeqValue(), getFlags(), txIds);
+    serializeBatch(msg, getAccountID(sfAccount), getSeqProxy().value(), getFlags(), txIds);
     finishMultiSigningData(batchSigner.getAccountID(sfAccount), msg);
     return singleSignHelper(batchSigner, msg.slice());
 }
@@ -468,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.
@@ -542,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
@@ -553,21 +562,18 @@ STTx::checkBatchMultiSign(
     // with the stuff that stays constant from signature to signature.
     auto const batchSignerAccount = batchSigner.getAccountID(sfAccount);
     Serializer dataStart;
-    serializeBatch(dataStart, getAccountID(sfAccount), getSeqValue(), getFlags(), txIds);
+    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.
@@ -579,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
@@ -812,16 +815,19 @@ invalidMPTAmountInTx(STObject const& tx)
 static bool
 isBatchRawTransactionOkay(STTx const& tx, std::string& reason)
 {
-    if (!tx.isFieldPresent(sfRawTransactions))
+    XRPL_ASSERT(
+        tx.getTxnType() == ttBATCH || !tx.isFieldPresent(sfRawTransactions),
+        "xrpl::isBatchRawTransactionOkay : raw transactions only on batch");
+
+    if (tx.getTxnType() != ttBATCH)
         return true;
 
-    // sfRawTransactions only appears on a Batch. passesLocalChecks runs on
-    // unverified user and peer input, so reject (rather than assert) a non-batch
-    // transaction that carries it.
-    if (tx.getTxnType() != ttBATCH)
+    if (!tx.isFieldPresent(sfRawTransactions))
     {
-        reason = "Only Batch transactions may contain raw transactions.";
+        // LCOV_EXCL_START
+        reason = "Batch transactions must contain raw transactions.";
         return false;
+        // LCOV_EXCL_STOP
     }
 
     if (tx.isFieldPresent(sfBatchSigners) &&
diff --git a/src/libxrpl/protocol/STValidation.cpp b/src/libxrpl/protocol/STValidation.cpp
index 1656aad3a2..1fad610c83 100644
--- a/src/libxrpl/protocol/STValidation.cpp
+++ b/src/libxrpl/protocol/STValidation.cpp
@@ -1,6 +1,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -15,6 +16,7 @@
 #include 
 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -59,6 +61,10 @@ STValidation::validationFormat()
         {sfBaseFeeDrops,          SoeOptional},
         {sfReserveBaseDrops,      SoeOptional},
         {sfReserveIncrementDrops, SoeOptional},
+        // featureSmartEscrow
+        {sfGasLimit,               SoeOptional},
+        {sfBytecodeSizeLimit,      SoeOptional},
+        {sfGasPrice,               SoeOptional},
     };
     // clang-format on
 
@@ -104,11 +110,42 @@ STValidation::isValid() const noexcept
             publicKeyType(getSignerPublic()) == KeyType::Secp256k1,
             "xrpl::STValidation::isValid : valid key type");
 
-        valid_ = verifyDigest(
-            getSignerPublic(),
-            getSigningHash(),
-            makeSlice(getFieldVL(sfSignature)),
-            (getFlags() & kVfFullyCanonicalSig) != 0u);
+        // Log that the signature was never checked, so an operator does not
+        // read this as a bad key. The log is guarded because it can throw too.
+        auto reportUncheckable = [this](char const* reason) noexcept {
+            try
+            {
+                JLOG(debugLog().error())
+                    << "Cannot check the signature of the validation for ledger " << getLedgerHash()
+                    << ": " << reason;
+            }
+            catch (...)  // NOLINT(bugprone-empty-catch)
+            {
+                // Nothing can be reported when reporting is what failed.
+            }
+        };
+
+        // The signing hash re-serializes the fields, which can fail. This
+        // function is noexcept, so report the validation as invalid instead of
+        // throwing. valid_ stays unset, so a later call checks again.
+        try
+        {
+            valid_ = verifyDigest(
+                getSignerPublic(),
+                getSigningHash(),
+                makeSlice(getFieldVL(sfSignature)),
+                (getFlags() & kVfFullyCanonicalSig) != 0u);
+        }
+        catch (std::exception const& e)
+        {
+            reportUncheckable(e.what());
+            return false;
+        }
+        catch (...)
+        {
+            reportUncheckable("unknown exception");
+            return false;
+        }
     }
 
     return valid_.value();
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/Serializer.cpp b/src/libxrpl/protocol/Serializer.cpp
index 80ecdee6c8..7f6fe625c2 100644
--- a/src/libxrpl/protocol/Serializer.cpp
+++ b/src/libxrpl/protocol/Serializer.cpp
@@ -143,10 +143,10 @@ Serializer::addFieldID(int type, int name)
 }
 
 int
-Serializer::add8(unsigned char byte)
+Serializer::add8(unsigned char byteValue)
 {
     int const ret = data_.size();
-    data_.push_back(byte);
+    data_.push_back(byteValue);
     return ret;
 }
 
@@ -210,109 +210,138 @@ Serializer::addVL(void const* ptr, int len)
 int
 Serializer::addEncoded(int length)
 {
-    std::array bytes{};
+    // Without this, a negative length would fall into the 1 byte case below and
+    // be cast to a first byte no header uses. A size too big for int arrives
+    // here negative as well, since callers pass sizes through this parameter.
+    if (length < kMinValueOfLengthFor1ByteHeader)
+        Throw("addEncoded: length is negative or did not fit in an int");
+
+    std::array bytes{};
     int numBytes = 0;
 
-    if (length <= 192)
+    if (length <= kMaxValueOfLengthFor1ByteHeader)
     {
-        bytes[0] = static_cast(length);
+        bytes[0] = static_cast(length);
         numBytes = 1;
     }
-    else if (length <= 12480)
+    else if (length <= kMaxValueOfLengthFor2ByteHeader)
     {
-        length -= 193;
-        bytes[0] = 193 + static_cast(length >> 8);
-        bytes[1] = static_cast(length & 0xff);
+        // Count from the smallest length a 2 byte header covers.
+        int const offset = length - kMinValueOfLengthFor2ByteHeader;
+        bytes[0] = static_cast(
+            kMinValueOfFirstByteFor2ByteHeader + (offset / kNumberOfValuesInOneByte));
+        bytes[1] = static_cast(offset % kNumberOfValuesInOneByte);
         numBytes = 2;
     }
-    else if (length <= 918744)
+    else if (length <= kMaxValueOfLengthFor3ByteHeader)
     {
-        length -= 12481;
-        bytes[0] = 241 + static_cast(length >> 16);
-        bytes[1] = static_cast((length >> 8) & 0xff);
-        bytes[2] = static_cast(length & 0xff);
+        int const offset = length - kMinValueOfLengthFor3ByteHeader;
+        bytes[0] = static_cast(
+            kMinValueOfFirstByteFor3ByteHeader + (offset / kNumberOfValuesInTwoBytes));
+        bytes[1] =
+            static_cast((offset / kNumberOfValuesInOneByte) % kNumberOfValuesInOneByte);
+        bytes[2] = static_cast(offset % kNumberOfValuesInOneByte);
         numBytes = 3;
     }
     else
     {
-        Throw("lenlen");
+        Throw("addEncoded: length is too large to encode");
     }
 
-    return addRaw(&bytes[0], numBytes);
+    return addRaw(bytes.data(), numBytes);
 }
 
 int
 Serializer::encodeLengthLength(int length)
 {
-    if (length < 0)
-        Throw("len<0");
+    if (length < kMinValueOfLengthFor1ByteHeader)
+    {
+        Throw(
+            "encodeLengthLength: length is negative or did not fit in an int");
+    }
 
-    if (length <= 192)
+    if (length <= kMaxValueOfLengthFor1ByteHeader)
         return 1;
 
-    if (length <= 12480)
+    if (length <= kMaxValueOfLengthFor2ByteHeader)
         return 2;
 
-    if (length <= 918744)
+    if (length <= kMaxValueOfLengthFor3ByteHeader)
         return 3;
 
-    Throw("len>918744");
-    return 0;  // Silence compiler warning.
+    Throw("encodeLengthLength: length is too large to encode");
 }
 
 int
-Serializer::decodeLengthLength(int b1)
+Serializer::decodeLengthLength(std::byte firstByte)
 {
-    if (b1 < 0)
-        Throw("b1<0");
+    int const firstByteValue = std::to_integer(firstByte);
 
-    if (b1 <= 192)
+    if (firstByteValue <= kMaxValueOfFirstByteFor1ByteHeader)
         return 1;
 
-    if (b1 <= 240)
+    if (firstByteValue <= kMaxValueOfFirstByteFor2ByteHeader)
         return 2;
 
-    if (b1 <= 254)
+    if (firstByteValue <= kMaxValueOfFirstByteFor3ByteHeader)
         return 3;
 
-    Throw("b1>254");
-    return 0;  // Silence compiler warning.
+    Throw("decodeLengthLength: first byte does not start any header");
 }
 
 int
-Serializer::decodeVLLength(int b1)
+Serializer::decodeVLLength(std::byte firstByte)
 {
-    if (b1 < 0)
-        Throw("b1<0");
+    int const length = std::to_integer(firstByte);
 
-    if (b1 > 254)
-        Throw("b1>254");
+    // A bigger value means a longer header, so it is not a length by itself.
+    if (length > kMaxValueOfLengthFor1ByteHeader)
+        Throw("decodeVLLength 1 byte: first byte is not a length");
 
-    return b1;
+    return length;
 }
 
 int
-Serializer::decodeVLLength(int b1, int b2)
+Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte)
 {
-    if (b1 < 193)
-        Throw("b1<193");
+    int const firstByteValue = std::to_integer(firstByte);
 
-    if (b1 > 240)
-        Throw("b1>240");
+    if (firstByteValue < kMinValueOfFirstByteFor2ByteHeader)
+        Throw("decodeVLLength 2 byte: first byte is below the range");
 
-    return 193 + ((b1 - 193) * 256) + b2;
+    if (firstByteValue > kMaxValueOfFirstByteFor2ByteHeader)
+        Throw("decodeVLLength 2 byte: first byte is above the range");
+
+    // Both bytes are bounded by their own type, and the first one is bounded to
+    // the 2 byte range above, so this cannot leave the range the header covers.
+    return kMinValueOfLengthFor2ByteHeader +
+        ((firstByteValue - kMinValueOfFirstByteFor2ByteHeader) * kNumberOfValuesInOneByte) +
+        std::to_integer(secondByte);
 }
 
 int
-Serializer::decodeVLLength(int b1, int b2, int b3)
+Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte)
 {
-    if (b1 < 241)
-        Throw("b1<241");
+    int const firstByteValue = std::to_integer(firstByte);
 
-    if (b1 > 254)
-        Throw("b1>254");
+    if (firstByteValue < kMinValueOfFirstByteFor3ByteHeader)
+        Throw("decodeVLLength 3 byte: first byte is below the range");
 
-    return 12481 + ((b1 - 241) * 65536) + (b2 * 256) + b3;
+    if (firstByteValue > kMaxValueOfFirstByteFor3ByteHeader)
+        Throw("decodeVLLength 3 byte: first byte is above the range");
+
+    int const length = kMinValueOfLengthFor3ByteHeader +
+        ((firstByteValue - kMinValueOfFirstByteFor3ByteHeader) * kNumberOfValuesInTwoBytes) +
+        (std::to_integer(secondByte) * kNumberOfValuesInOneByte) +
+        std::to_integer(thirdByte);
+
+    // A 3 byte header reaches further than kMaxValueOfLengthFor3ByteHeader, which
+    // is as far as the encoder goes. Refuse the rest, so every length accepted
+    // here is one that can be written back.
+    if (length > kMaxValueOfLengthFor3ByteHeader)
+        Throw("decodeVLLength 3 byte: length is too large to re-encode");
+
+    return length;
 }
 
 //------------------------------------------------------------------------------
@@ -471,24 +500,24 @@ SerialIter::getRaw(int size)
 int
 SerialIter::getVLDataLength()
 {
-    int const b1 = get8();
+    std::byte const firstByte{get8()};
     int datLen = 0;
-    int const lenLen = Serializer::decodeLengthLength(b1);
+    int const lenLen = Serializer::decodeLengthLength(firstByte);
     if (lenLen == 1)
     {
-        datLen = Serializer::decodeVLLength(b1);
+        datLen = Serializer::decodeVLLength(firstByte);
     }
     else if (lenLen == 2)
     {
-        int const b2 = get8();
-        datLen = Serializer::decodeVLLength(b1, b2);
+        std::byte const secondByte{get8()};
+        datLen = Serializer::decodeVLLength(firstByte, secondByte);
     }
     else
     {
         XRPL_ASSERT(lenLen == 3, "xrpl::SerialIter::getVLDataLength : lenLen is 3");
-        int const b2 = get8();
-        int const b3 = get8();
-        datLen = Serializer::decodeVLLength(b1, b2, b3);
+        std::byte const secondByte{get8()};
+        std::byte const thirdByte{get8()};
+        datLen = Serializer::decodeVLLength(firstByte, secondByte, thirdByte);
     }
     return datLen;
 }
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 c2167d58ce..c6ebe98642 100644
--- a/src/libxrpl/protocol/TER.cpp
+++ b/src/libxrpl/protocol/TER.cpp
@@ -108,6 +108,8 @@ transResults()
         MAKE_ERROR(tecPRECISION_LOSS,                "The amounts used by the transaction cannot interact."),
         MAKE_ERROR(tecBAD_PROOF,                     "Proof cannot be verified"),
         MAKE_ERROR(tecNO_SPONSOR_PERMISSION,         "Sponsor has not authorized this transaction."),
+        MAKE_ERROR(tecOUT_OF_GAS,                    "The WASM code ran out of gas during execution."),
+        MAKE_ERROR(tecBYTECODE_REJECTED,             "The custom WASM code that was run rejected your transaction."),
 
         MAKE_ERROR(tefALREADY,                     "The exact transaction was already in this ledger."),
         MAKE_ERROR(tefBAD_ADD_AUTH,                "Not authorized to add account."),
@@ -133,6 +135,8 @@ transResults()
         MAKE_ERROR(tefINVALID_LEDGER_FIX_TYPE,     "The LedgerFixType field has an invalid value."),
         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 that was not included."),
 
         MAKE_ERROR(telLOCAL_ERROR,            "Local failure."),
         MAKE_ERROR(telBAD_DOMAIN,             "Domain too long."),
@@ -204,6 +208,8 @@ 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(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."),
         MAKE_ERROR(terFUNDS_SPENT,            "DEPRECATED."),
diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp
index e4d4c4b03c..c393c606fe 100644
--- a/src/libxrpl/protocol/TxFormats.cpp
+++ b/src/libxrpl/protocol/TxFormats.cpp
@@ -45,7 +45,7 @@ TxFormats::TxFormats()
 #undef TRANSACTION
 
 #define UNWRAP(...) __VA_ARGS__
-#define TRANSACTION(tag, value, name, delegable, amendment, privileges, fields) \
+#define TRANSACTION(tag, value, name, settings, fields) \
     add(jss::name, tag, UNWRAP fields, getCommonFields());
 
 #include 
diff --git a/src/libxrpl/protocol/XChainAttestations.cpp b/src/libxrpl/protocol/XChainAttestations.cpp
index 792fe5da9d..7c887e785b 100644
--- a/src/libxrpl/protocol/XChainAttestations.cpp
+++ b/src/libxrpl/protocol/XChainAttestations.cpp
@@ -24,7 +24,7 @@
 #include 
 
 namespace xrpl {
-namespace Attestations {
+namespace attestations {
 
 AttestationBase::AttestationBase(
     AccountID attestationSignerAccount,
@@ -385,7 +385,7 @@ operator==(AttestationCreateAccount const& lhs, AttestationCreateAccount const&
         std::tie(rhs.createCount, rhs.toCreate, rhs.rewardAmount);
 }
 
-}  // namespace Attestations
+}  // namespace attestations
 
 SField const& XChainClaimAttestation::arrayFieldName{sfXChainClaimAttestations};
 SField const& XChainCreateAccountAttestation::arrayFieldName{sfXChainCreateAccountAttestations};
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/resource/Charge.cpp b/src/libxrpl/resource/Charge.cpp
index e174c13522..f80588b143 100644
--- a/src/libxrpl/resource/Charge.cpp
+++ b/src/libxrpl/resource/Charge.cpp
@@ -6,7 +6,7 @@
 #include 
 #include 
 
-namespace xrpl::Resource {
+namespace xrpl::resource {
 
 Charge::Charge(value_type cost, std::string label) : cost_(cost), label_(std::move(label))
 {
@@ -57,4 +57,4 @@ Charge::operator*(value_type m) const
     return Charge(cost_ * m, label_);
 }
 
-}  // namespace xrpl::Resource
+}  // namespace xrpl::resource
diff --git a/src/libxrpl/resource/Consumer.cpp b/src/libxrpl/resource/Consumer.cpp
index 58d5775a31..934aaddbf5 100644
--- a/src/libxrpl/resource/Consumer.cpp
+++ b/src/libxrpl/resource/Consumer.cpp
@@ -12,7 +12,7 @@
 #include 
 #include 
 
-namespace xrpl::Resource {
+namespace xrpl::resource {
 
 Consumer::Consumer(Logic& logic, Entry& entry) : logic_(&logic), entry_(&entry)
 {
@@ -99,14 +99,14 @@ Consumer::charge(Charge const& what, std::string const& context)
 bool
 Consumer::warn()
 {
-    XRPL_ASSERT(entry_, "xrpl::Resource::Consumer::warn : non-null entry");
+    XRPL_ASSERT(entry_, "xrpl::resource::Consumer::warn : non-null entry");
     return logic_->warn(*entry_);
 }
 
 bool
 Consumer::disconnect(beast::Journal const& j)
 {
-    XRPL_ASSERT(entry_, "xrpl::Resource::Consumer::disconnect : non-null entry");
+    XRPL_ASSERT(entry_, "xrpl::resource::Consumer::disconnect : non-null entry");
     bool const d = logic_->disconnect(*entry_);
     if (d)
     {
@@ -118,14 +118,14 @@ Consumer::disconnect(beast::Journal const& j)
 int
 Consumer::balance()
 {
-    XRPL_ASSERT(entry_, "xrpl::Resource::Consumer::balance : non-null entry");
+    XRPL_ASSERT(entry_, "xrpl::resource::Consumer::balance : non-null entry");
     return logic_->balance(*entry_);
 }
 
 Entry&
 Consumer::entry()
 {
-    XRPL_ASSERT(entry_, "xrpl::Resource::Consumer::entry : non-null entry");
+    XRPL_ASSERT(entry_, "xrpl::resource::Consumer::entry : non-null entry");
     return *entry_;
 }
 
@@ -142,4 +142,4 @@ operator<<(std::ostream& os, Consumer const& v)
     return os;
 }
 
-}  // namespace xrpl::Resource
+}  // namespace xrpl::resource
diff --git a/src/libxrpl/resource/Fees.cpp b/src/libxrpl/resource/Fees.cpp
index bb825fa3c7..42c7f8e6ad 100644
--- a/src/libxrpl/resource/Fees.cpp
+++ b/src/libxrpl/resource/Fees.cpp
@@ -2,13 +2,14 @@
 
 #include 
 
-namespace xrpl::Resource {
+namespace xrpl::resource {
 
 Charge const kFeeMalformedRequest(200, "malformed request");
 Charge const kFeeRequestNoReply(10, "unsatisfiable request");
 Charge const kFeeInvalidSignature(2000, "invalid signature");
 Charge const kFeeUselessData(150, "useless data");
 Charge const kFeeInvalidData(400, "invalid data");
+Charge const kFeeMalformedData(2000, "malformed data");
 
 Charge const kFeeMalformedRpc(100, "malformed RPC");
 Charge const kFeeReferenceRpc(20, "reference RPC");
@@ -23,6 +24,6 @@ Charge const kFeeHeavyBurdenPeer(2000, "heavy peer request");
 Charge const kFeeWarning(4000, "received warning");
 Charge const kFeeDrop(6000, "dropped");
 
-// See also Resource::Logic::charge for log level cutoff values
+// See also resource::Logic::charge for log level cutoff values
 
-}  // namespace xrpl::Resource
+}  // namespace xrpl::resource
diff --git a/src/libxrpl/resource/ResourceManager.cpp b/src/libxrpl/resource/ResourceManager.cpp
index e3b4d9cc5c..cdfa95facd 100644
--- a/src/libxrpl/resource/ResourceManager.cpp
+++ b/src/libxrpl/resource/ResourceManager.cpp
@@ -23,7 +23,7 @@
 #include 
 #include 
 
-namespace xrpl::Resource {
+namespace xrpl::resource {
 
 class ManagerImp : public Manager
 {
@@ -58,14 +58,14 @@ public:
     }
 
     Consumer
-    newInboundEndpoint(beast::IP::Endpoint const& address) override
+    newInboundEndpoint(beast::ip::Endpoint const& address) override
     {
         return logic_.newInboundEndpoint(address);
     }
 
     Consumer
     newInboundEndpoint(
-        beast::IP::Endpoint const& address,
+        beast::ip::Endpoint const& address,
         bool const proxy,
         std::string_view forwardedFor) override
     {
@@ -85,13 +85,13 @@ public:
     }
 
     Consumer
-    newOutboundEndpoint(beast::IP::Endpoint const& address) override
+    newOutboundEndpoint(beast::ip::Endpoint const& address) override
     {
         return logic_.newOutboundEndpoint(address);
     }
 
     Consumer
-    newUnlimitedEndpoint(beast::IP::Endpoint const& address) override
+    newUnlimitedEndpoint(beast::ip::Endpoint const& address) override
     {
         return logic_.newUnlimitedEndpoint(address);
     }
@@ -136,7 +136,7 @@ private:
     void
     run()
     {
-        beast::setCurrentThreadName("Resource::Mngr");
+        beast::setCurrentThreadName("resource::Mngr");
         for (;;)
         {
             logic_.periodicActivity();
@@ -164,4 +164,4 @@ makeManager(beast::insight::Collector::ptr const& collector, beast::Journal jour
     return std::make_unique(collector, journal);
 }
 
-}  // namespace xrpl::Resource
+}  // namespace xrpl::resource
diff --git a/src/libxrpl/server/InfoSub.cpp b/src/libxrpl/server/InfoSub.cpp
index 353c295856..bd50b1311c 100644
--- a/src/libxrpl/server/InfoSub.cpp
+++ b/src/libxrpl/server/InfoSub.cpp
@@ -7,10 +7,12 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -64,6 +66,9 @@ InfoSub::InfoSub(Source& source, Consumer consumer)
 
 InfoSub::~InfoSub()
 {
+    // Stream unsubscribes are O(1): each erases this connection's single seq_
+    // from one stream map, so they are cheap enough to run inline on the
+    // disconnect thread.
     // Each Source teardown call below acquires a server-side lock and
     // can throw. Wrap each independent call so partial failure does not
     // skip the remaining teardown steps.
@@ -79,32 +84,51 @@ InfoSub::~InfoSub()
     safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j);
     safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j);
 
-    // Use the internal unsubscribe so that it won't call
-    // back to us and modify its own parameter
-    if (!realTimeSubscriptions_.empty())
-    {
-        safeUnsub(
-            seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j);
-    }
-
-    if (!normalSubscriptions_.empty())
-    {
-        safeUnsub(
-            seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j);
-    }
-
-    for (auto const& account : accountHistorySubscriptions_)
-    {
-        safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j);
-    }
-
+    // Book subscriptions are torn down inline here, keyed on seq_, rather than
+    // through the chunked account cleanup below. The book set is not capped, so
+    // it can be large; but each unsubBookInternal takes bookLock_ for a single
+    // O(1) erase and releases it, so even a large set never holds a lock across
+    // the whole loop - a competing book publish can interleave between erases.
+    // The disconnect thread still does O(N) brief acquisitions. Use the internal
+    // variant so it does not write back to bookSubscriptions_ on this
+    // partially-destroyed object.
     for (auto const& book : bookSubscriptions_)
     {
         safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j);
     }
+
+    // Hand the account sets off (by move) to the Source for a chunked,
+    // off-thread teardown keyed on seq_, instead of erasing them inline here.
+    // This keeps the destructor from holding the account lock across a large
+    // erase loop. The job never references this object, which is being
+    // destroyed.
+    //
+    // Moving the sets without holding lock_ is safe: the destructor runs only
+    // when the last shared_ptr to this InfoSub is released, so by the
+    // shared_ptr contract no other thread holds a reference. Subscription maps
+    // store weak_ptrs, so a concurrent publisher must weak_ptr::lock() first;
+    // that succeeds only while a strong reference exists, which cannot overlap
+    // with destruction. No other thread can observe the moved-from sets.
+    //
+    // Wrapped like the steps above: scheduleAccountCleanup enqueues a JobQueue
+    // task, which allocates and locks and so can throw. A throw out of this
+    // noexcept destructor would terminate the process. Skipping the cleanup on
+    // throw is harmless: the account/rt maps hold weak_ptrs that the next
+    // publish prunes once this InfoSub is gone, and any history paging job
+    // self-terminates when its weak sink can no longer be locked.
+    safeUnsub(
+        seq_,
+        [&] {
+            source_.scheduleAccountCleanup(
+                seq_,
+                std::move(realTimeSubscriptions_),
+                std::move(normalSubscriptions_),
+                std::move(accountHistorySubscriptions_));
+        },
+        j);
 }
 
-Resource::Consumer&
+resource::Consumer&
 InfoSub::getConsumer()
 {
     return consumer_;
@@ -121,6 +145,53 @@ InfoSub::onSendEmpty()
 {
 }
 
+std::size_t
+InfoSub::totalSubscriptionCount() const
+{
+    // Hold lock_ for the whole read so the three sets cannot be mutated
+    // mid-count by a concurrent (un)subscribe on this connection.
+    std::scoped_lock const sl(lock_);
+
+    // Combined tally the per-connection cap is enforced against.
+    return normalSubscriptions_.size() + realTimeSubscriptions_.size() +
+        accountHistorySubscriptions_.size();
+}
+
+bool
+InfoSub::tryReserveAccountSubscriptions(
+    hash_set const& proposedAccounts,
+    hash_set const& normalAccounts,
+    std::size_t cap)
+{
+    // One lock hold covers the count, the check and the insert.
+    std::scoped_lock const sl(lock_);
+
+    // Entries not already tracked; re-subscribing held accounts is not charged.
+    auto const countNew = [](hash_set const& requested,
+                             hash_set const& existing) {
+        std::size_t fresh = 0;
+        for (auto const& account : requested)
+        {
+            if (!existing.contains(account))
+                ++fresh;
+        }
+        return fresh;
+    };
+
+    std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) +
+        countNew(normalAccounts, normalSubscriptions_);
+
+    std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() +
+        accountHistorySubscriptions_.size();
+
+    if (exceedsSubscriptionCap(current, additional, cap))
+        return false;
+
+    realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end());
+    normalSubscriptions_.insert(normalAccounts.begin(), normalAccounts.end());
+    return true;
+}
+
 void
 InfoSub::insertSubAccountInfo(AccountID const& account, bool rt)
 {
@@ -165,6 +236,13 @@ InfoSub::deleteSubAccountHistory(AccountID const& account)
     accountHistorySubscriptions_.erase(account);
 }
 
+bool
+InfoSub::hasAccountHistorySubscription(AccountID const& account) const
+{
+    std::scoped_lock const sl(lock_);
+    return accountHistorySubscriptions_.contains(account);
+}
+
 void
 InfoSub::insertBookSubscription(Book const& book)
 {
diff --git a/src/libxrpl/server/JSONRPCUtil.cpp b/src/libxrpl/server/JSONRPCUtil.cpp
index f38ff280ac..d59582fc1a 100644
--- a/src/libxrpl/server/JSONRPCUtil.cpp
+++ b/src/libxrpl/server/JSONRPCUtil.cpp
@@ -42,7 +42,7 @@ httpReply(int nStatus, std::string const& content, json::Output const& output, b
 
         // CHECKME this returns a different version than the replies below. Is
         //         this by design or an accident or should it be using
-        //         BuildInfo::getFullVersionString () as well?
+        //         build_info::getFullVersionString () as well?
         output("Server: " + systemName() + "-json-rpc/v1");
         output("\r\n");
 
@@ -123,7 +123,7 @@ httpReply(int nStatus, std::string const& content, json::Output const& output, b
         "Content-Type: application/json; charset=UTF-8\r\n");
 
     output("Server: " + systemName() + "-json-rpc/");
-    output(BuildInfo::getFullVersionString());
+    output(build_info::getFullVersionString());
     output(
         "\r\n"
         "\r\n");
diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp
index b26c67e531..c85c8445f0 100644
--- a/src/libxrpl/server/Manifest.cpp
+++ b/src/libxrpl/server/Manifest.cpp
@@ -23,8 +23,6 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
@@ -62,6 +60,11 @@ deserializeManifest(Slice s, beast::Journal journal)
     if (s.empty())
         return std::nullopt;
 
+    // A valid manifest has a fixed maximum size, so reject anything larger
+    // before parsing it.
+    if (s.size() > kMaxManifestBytes)
+        return std::nullopt;
+
     static SOTemplate const kManifestFormat{
         // A manifest must include:
         // - the master public key
@@ -272,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);
 
@@ -377,16 +380,20 @@ ManifestCache::revoked(PublicKey const& pk) const
 }
 
 ManifestDisposition
-ManifestCache::applyManifest(Manifest m)
+ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap)
 {
+    bool const uncapped = cap == ManifestRateLimitCapPolicy::Uncapped;
+
+    // The signature is checked only on the first `prewriteCheck` run (under the
+    // read lock). It is expensive, so `checkSignature` is cleared the first
+    // time it is read; the second run (under the write lock) skips it.
+    bool checkSignature = true;
+
     // Check the manifest against the conditions that do not require a
-    // `unique_lock` (write lock) on the `mutex_`. Since the signature can be
-    // relatively expensive, the `checkSignature` parameter determines if the
-    // signature should be checked. Since `prewriteCheck` is run twice (see
-    // comment below), `checkSignature` only needs to be set to true on the
-    // first run.
-    auto prewriteCheck = [this, &m](auto const& iter, bool checkSignature, auto const& lock)
-        -> std::optional {
+    // `unique_lock` (write lock) on the `mutex_`.
+    auto prewriteCheck = [this, &m, &checkSignature](
+                             auto const& iter,
+                             auto const& lock) -> std::optional {
         XRPL_ASSERT(lock.owns_lock(), "xrpl::ManifestCache::applyManifest::prewriteCheck : locked");
         (void)lock;  // not used. parameter is present to ensure the mutex is
                      // locked when the lambda is called.
@@ -401,11 +408,15 @@ ManifestCache::applyManifest(Manifest m)
             return ManifestDisposition::Stale;
         }
 
-        if (checkSignature && !m.verify())
+        if (checkSignature)
         {
-            if (auto stream = j_.warn())
-                logMftAct(stream, "Invalid", m.masterKey, m.sequence);
-            return ManifestDisposition::Invalid;
+            checkSignature = false;
+            if (!m.verify())
+            {
+                if (auto stream = j_.warn())
+                    logMftAct(stream, "Invalid", m.masterKey, m.sequence);
+                return ManifestDisposition::Invalid;
+            }
         }
 
         // If the master key associated with a manifest is or might be
@@ -465,14 +476,51 @@ ManifestCache::applyManifest(Manifest m)
         return std::nullopt;
     };
 
+    // Reject a brand-new manifest for an unlisted key once the untrusted cap
+    // is full. Updates to a cached key and uncapped manifests always pass.
+    // Called under both the read and write lock, since the cap can be reached
+    // between the two. The lock param enforces that.
+    auto atUntrustedCap = [this, &m, uncapped](auto const& iter, auto const& lock) {
+        XRPL_ASSERT(
+            lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked");
+        (void)lock;  // not used. parameter is present to ensure the mutex is
+                     // locked when the lambda is called.
+        if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= maxUntrustedCount_)
+        {
+            // Log each rejection at debug, but warn only once per interval so a
+            // flood does not fill the log.
+            if (auto stream = j_.debug())
+                logMftAct(stream, "UntrustedCapacity", m.masterKey, m.sequence);
+            if (auto const n = untrustedRejectCount_.fetch_add(1) + 1;
+                n % kUntrustedRejectCount == 0)
+            {
+                JLOG(j_.warn()) << "Untrusted manifest cap reached; " << n
+                                << " manifests rejected so far";
+            }
+            return true;
+        }
+        return false;
+    };
+
     {
         std::shared_lock const sl{mutex_};
-        if (auto d = prewriteCheck(map_.find(m.masterKey), /*checkSig*/ true, sl))
+        auto const iter = map_.find(m.masterKey);
+
+        if (atUntrustedCap(iter, sl))
+            return ManifestDisposition::UntrustedCapacity;
+
+        if (auto d = prewriteCheck(iter, sl); d.has_value())
             return *d;
     }
 
     std::unique_lock const sl{mutex_};
     auto const iter = map_.find(m.masterKey);
+
+    // Re-check the cap under the write lock: the cache may have grown while the
+    // read lock above was released.
+    if (atUntrustedCap(iter, sl))
+        return ManifestDisposition::UntrustedCapacity;
+
     // Since we released the previously held read lock, it's possible that the
     // collections have been written to. This means we need to run
     // `prewriteCheck` again. This re-does work, but `prewriteCheck` is
@@ -482,7 +530,7 @@ ManifestCache::applyManifest(Manifest m)
     // doesn't need to happen again (signature checks are somewhat expensive).
     // Note: It's a mistake to use an upgradable lock. This is a recipe for
     // deadlock.
-    if (auto d = prewriteCheck(iter, /*checkSig*/ false, sl))
+    if (auto d = prewriteCheck(iter, sl); d.has_value())
         return *d;
 
     bool const revoked = m.revoked();
@@ -501,6 +549,12 @@ ManifestCache::applyManifest(Manifest m)
         }
 
         auto masterKey = m.masterKey;
+
+        // Count this key against the untrusted cap. Uncapped keys (listed,
+        // configured, or DB-loaded) are not tracked.
+        if (!uncapped)
+            untrustedKeys_.insert(masterKey);
+
         map_.emplace(std::move(masterKey), std::move(m));
 
         // Something has changed. Keep track of it.
@@ -514,6 +568,11 @@ ManifestCache::applyManifest(Manifest m)
     if (auto stream = j_.info())
         logMftAct(stream, "AcceptedUpdate", m.masterKey, m.sequence, iter->second.sequence);
 
+    // If this key was counted against the cap but now arrives uncapped, free
+    // its slot without waiting for promoteToTrusted.
+    if (uncapped)
+        untrustedKeys_.erase(m.masterKey);
+
     signingToMasterKeys_.erase(
         *iter->second.signingKey);  // NOLINT(bugprone-unchecked-optional-access) prewriteCheck
                                     // ensures old manifest is not revoked
@@ -521,8 +580,8 @@ ManifestCache::applyManifest(Manifest m)
     if (!revoked)
     {
         signingToMasterKeys_.emplace(
-            *m.signingKey, m.masterKey);  // NOLINT(bugprone-unchecked-optional-access) non-revoked
-                                          // manifest always has signingKey
+            *m.signingKey, m.masterKey);  // NOLINT(bugprone-unchecked-optional-access)
+                                          // non-revoked manifest always has signingKey
     }
 
     iter->second = std::move(m);
@@ -533,6 +592,16 @@ ManifestCache::applyManifest(Manifest m)
     return ManifestDisposition::Accepted;
 }
 
+void
+ManifestCache::promoteToTrusted(PublicKey const& pk)
+{
+    // Frees the key's untrusted slot; a no-op (and idempotent) if the key was
+    // never counted. Not re-added on de-listing, so list/de-list cannot grow
+    // the count.
+    std::unique_lock const sl{mutex_};
+    untrustedKeys_.erase(pk);
+}
+
 void
 ManifestCache::load(DatabaseCon& dbCon, std::string const& dbTable)
 {
@@ -563,7 +632,8 @@ ManifestCache::load(
             JLOG(j_.warn()) << "Configured manifest revokes public key";
         }
 
-        if (applyManifest(std::move(*mo)) == ManifestDisposition::Invalid)
+        if (applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) ==
+            ManifestDisposition::Invalid)
         {
             JLOG(j_.error()) << "Manifest in config was rejected";
             return false;
@@ -581,11 +651,13 @@ 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));
 
-        if (!mo || !mo->revoked() || applyManifest(std::move(*mo)) == ManifestDisposition::Invalid)
+        if (!mo || !mo->revoked() ||
+            applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) ==
+                ManifestDisposition::Invalid)
         {
             JLOG(j_.error()) << "Invalid validator key revocation in config";
             return false;
diff --git a/src/libxrpl/server/Port.cpp b/src/libxrpl/server/Port.cpp
index c1a79019af..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;
@@ -107,7 +107,7 @@ populate(
         {
             // First, check to see if 0.0.0.0 or ipv6 equivalent was configured,
             // which means all IP addresses.
-            auto const addr = beast::IP::Endpoint::fromStringChecked(ip);
+            auto const addr = beast::ip::Endpoint::fromStringChecked(ip);
             if (addr)
             {
                 if (isUnspecified(*addr))
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 f3a7ff76ba..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
@@ -29,6 +28,8 @@
 #include 
 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -77,7 +78,9 @@ getManifests(
                 continue;
             }
 
-            cache.applyManifest(std::move(*mo));
+            // Only trusted manifests are persisted (see saveManifests), so
+            // anything loaded from the DB bypasses the untrusted cap.
+            cache.applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped);
         }
         else
         {
@@ -107,19 +110,27 @@ saveManifests(
 {
     soci::transaction tr(session);
     session << "DELETE FROM " << dbTable;
+    // Count skipped untrusted manifests and log one summary afterwards, since
+    // the cache can hold many and per-entry logging would flood at shutdown.
+    std::size_t skipped = 0;
     for (auto const& v : map)
     {
-        // Save all revocation manifests,
-        // but only save trusted non-revocation manifests.
-        if (!v.second.revoked() && !isTrusted(v.second.masterKey))
+        // Persist only trusted keys. Untrusted gossip is left out so a flood
+        // cannot survive a restart on disk.
+        if (!isTrusted(v.second.masterKey))
         {
-            JLOG(j.info()) << "Untrusted manifest in cache not saved to db";
+            ++skipped;
             continue;
         }
 
         saveManifest(session, dbTable, v.second.serialized);
     }
     tr.commit();
+
+    if (skipped != 0)
+    {
+        JLOG(j.info()) << skipped << " untrusted manifest(s) in cache not saved to db";
+    }
 }
 
 void
@@ -161,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..0e28c0222a 100644
--- a/src/libxrpl/shamap/SHAMap.cpp
+++ b/src/libxrpl/shamap/SHAMap.cpp
@@ -97,7 +97,7 @@ SHAMap::snapShot(bool isMutable) const
 }
 
 void
-SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr child)
+SHAMap::dirtyUp(NodePathStack& stack, uint256 const& target, SHAMapTreeNodePtr child)
 {
     // walk the tree up from through the inner nodes to the root_
     // update hashes and links
@@ -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));
@@ -127,29 +126,34 @@ SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNode
 }
 
 SHAMapLeafNode*
-SHAMap::walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack) const
+SHAMap::walkTowardsKey(uint256 const& id, NodePathStack* stack) const
 {
     XRPL_ASSERT(
         stack == nullptr || stack->empty(), "xrpl::SHAMap::walkTowardsKey : empty stack input");
     auto inNode = root_;
     SHAMapNodeID nodeID;
 
+    // Every node on this walk lies on the path to `id`, so the stack can derive each ID from the
+    // branch `id` selects at the node above it.
+    auto pushCurrent = [&] {
+        if (stack != nullptr)
+            stack->pushNode(inNode, id);
+    };
+
     while (inNode->isInner())
     {
-        if (stack != nullptr)
-            stack->emplace(inNode, nodeID);
+        pushCurrent();
 
-        auto const inner = intr_ptr::staticPointerCast(inNode);
+        auto& inner = safeDowncast(*inNode);
         auto const branch = selectBranch(nodeID, id);
-        if (inner->isEmptyBranch(branch))
+        if (inner.isEmptyBranch(branch))
             return nullptr;
 
-        inNode = descendThrow(*inner, branch);
+        inNode = descendThrow(inner, branch);
         nodeID = nodeID.getChildNodeID(branch);
     }
 
-    if (stack != nullptr)
-        stack->emplace(inNode, nodeID);
+    pushCurrent();
     return safeDowncast(inNode.get());
 }
 
@@ -278,7 +282,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 +293,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 +304,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 +319,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 +336,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 +348,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 +376,7 @@ SHAMap::descend(
 SHAMapTreeNode*
 SHAMap::descendAsync(
     SHAMapInnerNode* parent,
-    int branch,
+    unsigned int branch,
     SHAMapSyncFilter const* filter,
     bool& pending,
     descendCallback&& callback) const
@@ -430,69 +433,40 @@ SHAMap::unshareNode(intr_ptr::SharedPtr node, SHAMapNodeID const& nodeID)
 }
 
 SHAMapLeafNode*
-SHAMap::belowHelper(
-    SHAMapTreeNodePtr node,
-    SharedPtrNodeStack& stack,
-    int branch,
-    std::tuple, std::function> const& loopParams) const
+SHAMap::belowHelper(NodePathStack& stack, BelowDirection direction) const
 {
-    auto& [init, cmp, incr] = loopParams;
-    if (node->isLeaf())
+    XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack input");
+    if (auto const& top = stack.top().first; top->isLeaf())
+        return safeDowncast(top.get());
+
+    // The stack owns the node/ID pairing, so descending is only ever "push the branch we took".
+    // `scanned` counts how many branches of the current node we have examined; the branch we look
+    // at is derived from it, so no index ever goes out of range. `inner` tracks the node on top of
+    // the stack, which keeps it alive, so it only needs recomputing after a push.
+    auto* inner = safeDowncast(stack.top().first.get());
+    for (auto scanned = 0u; scanned < kBranchFactor;)
     {
-        auto n = intr_ptr::staticPointerCast(node);
-        stack.push({node, {kLeafDepth, n->peekItem()->key()}});
-        return n.get();
-    }
-    auto inner = intr_ptr::staticPointerCast(node);
-    if (stack.empty())
-    {
-        stack.emplace(inner, SHAMapNodeID{});
-    }
-    else
-    {
-        stack.emplace(inner, stack.top().second.getChildNodeID(branch));
-    }
-    for (int i = init; cmp(i);)
-    {
-        if (!inner->isEmptyBranch(i))
+        auto const childBranch =
+            (direction == BelowDirection::Last) ? (kBranchFactor - 1u - scanned) : scanned;
+
+        if (inner->isEmptyBranch(childBranch))
         {
-            node.adopt(descendThrow(inner.get(), i));
-            XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack");
-            if (node->isLeaf())
-            {
-                auto n = intr_ptr::staticPointerCast(node);
-                stack.push({n, {kLeafDepth, n->peekItem()->key()}});
-                return n.get();
-            }
-            inner = intr_ptr::staticPointerCast(node);
-            stack.emplace(inner, stack.top().second.getChildNodeID(branch));
-            i = init;  // descend and reset loop
-        }
-        else
-        {
-            incr(i);  // scan next branch
+            ++scanned;  // scan next branch
+            continue;
         }
+
+        stack.pushChild(descendThrow(*inner, childBranch), childBranch);
+
+        auto const& child = stack.top().first;
+        if (child->isLeaf())
+            return safeDowncast(child.get());
+
+        inner = safeDowncast(child.get());
+        scanned = 0u;  // descend and restart the scan on the new node
     }
     return nullptr;
 }
-SHAMapLeafNode*
-SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, 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});
-}
-SHAMapLeafNode*
-SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, 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});
-}
 static boost::intrusive_ptr const kNoItem;
 
 boost::intrusive_ptr const&
@@ -504,7 +478,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))
             {
@@ -535,36 +509,36 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const
 }
 
 SHAMapLeafNode const*
-SHAMap::peekFirstItem(SharedPtrNodeStack& stack) const
+SHAMap::peekFirstItem(NodePathStack& stack) const
 {
     XRPL_ASSERT(stack.empty(), "xrpl::SHAMap::peekFirstItem : empty stack input");
-    SHAMapLeafNode const* node = firstBelow(root_, stack);
+    stack.pushRoot(root_);
+    SHAMapLeafNode const* node = belowHelper(stack, BelowDirection::First);
     if (node == nullptr)
     {
-        while (!stack.empty())
-            stack.pop();
+        stack.clear();
         return nullptr;
     }
     return node;
 }
 
 SHAMapLeafNode const*
-SHAMap::peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const
+SHAMap::peekNextItem(uint256 const& id, NodePathStack& stack) const
 {
     XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::peekNextItem : non-empty stack input");
     XRPL_ASSERT(stack.top().first->isLeaf(), "xrpl::SHAMap::peekNextItem : stack starts with leaf");
     stack.pop();
     while (!stack.empty())
     {
-        auto [node, nodeID] = stack.top();
+        auto const [node, nodeID] = stack.top();
         XRPL_ASSERT(!node->isLeaf(), "xrpl::SHAMap::peekNextItem : another node is not leaf");
-        auto inner = intr_ptr::staticPointerCast(node);
+        auto& inner = safeDowncast(*node);
         for (auto i = selectBranch(nodeID, id) + 1; i < kBranchFactor; ++i)
         {
-            if (!inner->isEmptyBranch(i))
+            if (!inner.isEmptyBranch(i))
             {
-                node = descendThrow(*inner, i);
-                auto leaf = firstBelow(node, stack, i);
+                stack.pushChild(descendThrow(inner, i), i);
+                auto leaf = belowHelper(stack, BelowDirection::First);
                 if (leaf == nullptr)
                     Throw(type_, id);
                 XRPL_ASSERT(leaf->isLeaf(), "xrpl::SHAMap::peekNextItem : leaf is valid");
@@ -603,11 +577,11 @@ SHAMap::peekItem(uint256 const& id, SHAMapHash& hash) const
 SHAMap::ConstIterator
 SHAMap::upperBound(uint256 const& id) const
 {
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     walkTowardsKey(id, &stack);
     while (!stack.empty())
     {
-        auto [node, nodeID] = stack.top();
+        auto const [node, nodeID] = stack.top();
         if (node->isLeaf())
         {
             auto leaf = safeDowncast(node.get());
@@ -616,13 +590,13 @@ SHAMap::upperBound(uint256 const& id) const
         }
         else
         {
-            auto inner = intr_ptr::staticPointerCast(node);
+            auto& inner = safeDowncast(*node);
             for (auto branch = selectBranch(nodeID, id) + 1; branch < kBranchFactor; ++branch)
             {
-                if (!inner->isEmptyBranch(branch))
+                if (!inner.isEmptyBranch(branch))
                 {
-                    node = descendThrow(*inner, branch);
-                    auto leaf = firstBelow(node, stack, branch);
+                    stack.pushChild(descendThrow(inner, branch), branch);
+                    auto leaf = belowHelper(stack, BelowDirection::First);
                     if (leaf == nullptr)
                         Throw(type_, id);
                     return ConstIterator(this, leaf->peekItem().get(), std::move(stack));
@@ -636,11 +610,11 @@ SHAMap::upperBound(uint256 const& id) const
 SHAMap::ConstIterator
 SHAMap::lowerBound(uint256 const& id) const
 {
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     walkTowardsKey(id, &stack);
     while (!stack.empty())
     {
-        auto [node, nodeID] = stack.top();
+        auto const [node, nodeID] = stack.top();
         if (node->isLeaf())
         {
             auto leaf = safeDowncast(node.get());
@@ -649,13 +623,14 @@ SHAMap::lowerBound(uint256 const& id) const
         }
         else
         {
-            auto inner = intr_ptr::staticPointerCast(node);
-            for (int branch = selectBranch(nodeID, id) - 1; branch >= 0; --branch)
+            auto& inner = safeDowncast(*node);
+            for (auto branch = selectBranch(nodeID, id); branch > 0u;)
             {
-                if (!inner->isEmptyBranch(branch))
+                --branch;
+                if (!inner.isEmptyBranch(branch))
                 {
-                    node = descendThrow(*inner, branch);
-                    auto leaf = lastBelow(node, stack, branch);
+                    stack.pushChild(descendThrow(inner, branch), branch);
+                    auto leaf = belowHelper(stack, BelowDirection::Last);
                     if (leaf == nullptr)
                         Throw(type_, id);
                     return ConstIterator(this, leaf->peekItem().get(), std::move(stack));
@@ -680,7 +655,7 @@ SHAMap::delItem(uint256 const& id)
     // delete the item with this ID
     XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::delItem : not immutable");
 
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     walkTowardsKey(id, &stack);
 
     if (stack.empty())
@@ -715,7 +690,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 +705,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))
                         {
@@ -766,7 +741,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr
     // add the specified item, does not update
     uint256 const tag = item->key();
 
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     walkTowardsKey(tag, &stack);
 
     if (stack.empty())
@@ -786,7 +761,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,11 +777,11 @@ 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())))
         {
-            stack.emplace(node, nodeID);
+            stack.pushNode(node, tag);
 
             // we need a new inner node, since both go on same branch at this
             // level
@@ -853,7 +828,7 @@ SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr, 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 +1007,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 +1101,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))
                 {
@@ -1175,7 +1150,7 @@ SHAMap::invariants() const
     auto node = root_.get();
     XRPL_ASSERT(node, "xrpl::SHAMap::invariants : non-null root node");
     XRPL_ASSERT(!node->isLeaf(), "xrpl::SHAMap::invariants : root node is not leaf");
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     for (auto leaf = peekFirstItem(stack); leaf != nullptr;
          leaf = peekNextItem(leaf->peekItem()->key(), stack))
         ;
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 16aaafe709..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,10 +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 < 65), "xrpl::SHAMapNodeID::createID : valid branch input");
-    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 cc30426f9d..602d8e629c 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));
@@ -107,7 +107,7 @@ SHAMap::visitNodes(std::function const& function) const
 
 void
 SHAMap::visitDifferences(
-    SHAMap const* have,
+    SHAMap const* map,
     std::function const& function) const
 {
     // Visit every node in this SHAMap that is not present
@@ -118,13 +118,13 @@ SHAMap::visitDifferences(
     if (root_->getHash().isZero())
         return;
 
-    if ((have != nullptr) && (root_->getHash() == have->root_->getHash()))
+    if ((map != nullptr) && (root_->getHash() == map->root_->getHash()))
         return;
 
     if (root_->isLeaf())
     {
         auto leaf = intr_ptr::staticPointerCast(root_);
-        if ((have == nullptr) || !have->hasLeafNode(leaf->peekItem()->key(), leaf->getHash()))
+        if ((map == nullptr) || !map->hasLeafNode(leaf->peekItem()->key(), leaf->getHash()))
             function(*root_);
         return;
     }
@@ -143,24 +143,35 @@ 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))
             {
                 auto const& childHash = node->getChildHash(i);
-                SHAMapNodeID const childID = nodeID.getChildNodeID(i);
+                auto const childID = nodeID.getChildNodeID(i);
                 auto next = descendThrow(node, i);
 
                 if (next->isInner())
                 {
-                    if ((have == nullptr) || !have->hasInnerNode(childID, childHash))
+                    if ((map == nullptr) || !map->hasInnerNode(childID, childHash))
                         stack.emplace(safeDowncast(next), childID);
                 }
-                else if (
-                    (have == nullptr) ||
-                    !have->hasLeafNode(
-                        safeDowncast(next)->peekItem()->key(), childHash))
+                else if ((map == nullptr) || !map->hasLeafNode(leafKey(*next), childHash))
                 {
                     if (!function(*next))
                         return;
@@ -179,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;
 
@@ -265,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};
 
@@ -414,7 +425,7 @@ SHAMap::getMissingNodes(int max, SHAMapSyncFilter const* filter)
 bool
 SHAMap::getNodeFat(
     SHAMapNodeID const& wanted,
-    std::vector>& data,
+    std::vector& data,
     bool fatLeaves,
     std::uint32_t depth) const
 {
@@ -426,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;
@@ -447,7 +458,7 @@ SHAMap::getNodeFat(
         return false;
     }
 
-    std::stack> stack;
+    std::stack> stack;
     stack.emplace(node, nodeID, depth);
 
     Serializer s(8192);
@@ -460,19 +471,19 @@ SHAMap::getNodeFat(
         // Add this node to the reply
         s.erase();
         node->serializeForWire(s);
-        data.emplace_back(nodeID, s.getData());
+        data.emplace_back(nodeID, node->isLeaf(), s.getData());
 
         if (node->isInner())
         {
             // 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))
                     {
@@ -490,7 +501,7 @@ SHAMap::getNodeFat(
                             // Just include this node
                             s.erase();
                             childNode->serializeForWire(s);
-                            data.emplace_back(childID, s.getData());
+                            data.emplace_back(childID, childNode->isLeaf(), s.getData());
                         }
                     }
                 }
@@ -508,25 +519,33 @@ SHAMap::serializeRoot(Serializer& s) const
 }
 
 SHAMapAddNode
-SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFilter const* filter)
+SHAMap::addRootNode(
+    SHAMapHash const& hash,
+    SHAMapTreeNodePtr rootNode,
+    SHAMapSyncFilter const* filter)
 {
+    XRPL_ASSERT(cowid_ >= 1, "xrpl::SHAMap::addRootNode : valid cowid");
+    XRPL_ASSERT(rootNode, "xrpl::SHAMap::addRootNode : non-null root node");
+
     // we already have a root_ node
     if (root_->getHash().isNonZero())
     {
-        JLOG(journal_.trace()) << "got root node, already have one";
-        XRPL_ASSERT(root_->getHash() == hash, "xrpl::SHAMap::addRootNode : valid hash input");
+        JLOG(journal_.trace()) << "Got root node, already have one";
+        XRPL_ASSERT(root_->getHash() == hash, "xrpl::SHAMap::addRootNode : valid hash");
         return SHAMapAddNode::duplicate();
     }
 
-    XRPL_ASSERT(cowid_ >= 1, "xrpl::SHAMap::addRootNode : valid cowid");
-    auto node = SHAMapTreeNode::makeFromWire(rootNode);
-    if (!node || node->getHash() != hash)
+    if (rootNode->getHash() != hash)
+    {
+        JLOG(journal_.warn()) << "Corrupt root node received: expected hash " << hash << ", got "
+                              << rootNode->getHash();
         return SHAMapAddNode::invalid();
+    }
 
     if (backed_)
-        canonicalize(hash, node);
+        canonicalize(hash, rootNode);
 
-    root_ = node;
+    root_ = std::move(rootNode);
 
     if (root_->isLeaf())
         clearSynching();
@@ -543,9 +562,17 @@ SHAMap::addRootNode(SHAMapHash const& hash, Slice const& rootNode, SHAMapSyncFil
 }
 
 SHAMapAddNode
-SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncFilter const* filter)
+SHAMap::addKnownNode(
+    SHAMapNodeID const& nodeID,
+    SHAMapTreeNodePtr treeNode,
+    SHAMapSyncFilter const* filter)
 {
-    XRPL_ASSERT(!node.isRoot(), "xrpl::SHAMap::addKnownNode : valid node input");
+    XRPL_ASSERT(!nodeID.isRoot(), "xrpl::SHAMap::addKnownNode : valid node");
+    XRPL_ASSERT(treeNode, "xrpl::SHAMap::addKnownNode : non-null tree node");
+    XRPL_ASSERT_IF(
+        treeNode->isLeaf(),
+        nodeID.isPrefixOf(leafKey(*treeNode)),
+        "xrpl::SHAMap::addKnownNode : leaf position consistent with node ID");
 
     if (!isSynching())
     {
@@ -559,14 +586,14 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF
 
     while (currNode->isInner() &&
            !safeDowncast(currNode)->isFullBelow(generation) &&
-           (currNodeID.getDepth() < node.getDepth()))
+           (currNodeID.getDepth() < nodeID.getDepth()))
     {
-        int const branch = selectBranch(currNodeID, node.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))
         {
-            JLOG(journal_.warn()) << "Add known node for empty branch" << node;
+            JLOG(journal_.warn()) << "Add known node " << nodeID << " for empty branch " << branch
+                                  << " at " << currNodeID;
             return SHAMapAddNode::invalid();
         }
 
@@ -582,67 +609,45 @@ SHAMap::addKnownNode(SHAMapNodeID const& node, Slice const& rawNode, SHAMapSyncF
         if (currNode != nullptr)
             continue;
 
-        auto newNode = SHAMapTreeNode::makeFromWire(rawNode);
-
-        if (!newNode || childHash != newNode->getHash())
+        if (childHash != treeNode->getHash())
         {
-            JLOG(journal_.warn()) << "Corrupt node received";
+            JLOG(journal_.warn()) << "Corrupt node " << nodeID << " received: expected hash "
+                                  << childHash << ", got " << treeNode->getHash();
             return SHAMapAddNode::invalid();
         }
 
-        // In rare cases, a node can still be corrupt even after hash
-        // validation. For leaf nodes, we perform an additional check to
-        // ensure the node's position in the tree is consistent with its
-        // content to prevent inconsistencies that could
-        // propagate further down the line.
-        if (newNode->isLeaf())
-        {
-            auto const& actualKey =
-                safeDowncast(newNode.get())->peekItem()->key();
-
-            // Validate that this leaf belongs at the target position
-            auto const expectedNodeID = SHAMapNodeID::createID(node.getDepth(), actualKey);
-            if (expectedNodeID.getNodeID() != node.getNodeID())
-            {
-                JLOG(journal_.debug())
-                    << "Leaf node position mismatch: "
-                    << "expected=" << expectedNodeID.getNodeID() << ", actual=" << node.getNodeID();
-                return SHAMapAddNode::invalid();
-            }
-        }
-
         // Inner nodes must be at a level strictly less than 64
         // but leaf nodes (while notionally at level 64) can be
         // at any depth up to and including 64:
         if ((currNodeID.getDepth() > kLeafDepth) ||
-            (newNode->isInner() && currNodeID.getDepth() == kLeafDepth))
+            (treeNode->isInner() && currNodeID.getDepth() == kLeafDepth))
         {
             // Map is provably invalid
             state_ = SHAMapState::Invalid;
             return SHAMapAddNode::useful();
         }
 
-        if (currNodeID != node)
+        if (currNodeID != nodeID)
         {
             // Either this node is broken or we didn't request it (yet)
-            JLOG(journal_.warn()) << "unable to hook node " << node;
+            JLOG(journal_.warn()) << "unable to hook node " << nodeID;
             JLOG(journal_.info()) << " stuck at " << currNodeID;
-            JLOG(journal_.info()) << "got depth=" << node.getDepth()
+            JLOG(journal_.info()) << "got depth=" << nodeID.getDepth()
                                   << ", walked to= " << currNodeID.getDepth();
             return SHAMapAddNode::useful();
         }
 
         if (backed_)
-            canonicalize(childHash, newNode);
+            canonicalize(childHash, treeNode);
 
-        newNode = prevNode->canonicalizeChild(branch, std::move(newNode));
+        treeNode = prevNode->canonicalizeChild(branch, std::move(treeNode));
 
         if (filter != nullptr)
         {
             Serializer s;
-            newNode->serializeWithPrefix(s);
+            treeNode->serializeWithPrefix(s);
             filter->gotNode(
-                false, childHash, ledgerSeq_, std::move(s.modData()), newNode->getType());
+                false, childHash, ledgerSeq_, std::move(s.modData()), treeNode->getType());
         }
 
         return SHAMapAddNode::useful();
@@ -693,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))
                 {
@@ -732,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;
@@ -758,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
@@ -777,7 +793,7 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const
 std::optional>
 SHAMap::getProofPath(uint256 const& key) const
 {
-    SharedPtrNodeStack stack;
+    NodePathStack stack;
     walkTowardsKey(key, &stack);
 
     if (stack.empty())
@@ -810,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};
@@ -826,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 5e5ab90441..96dcd5f587 100644
--- a/src/libxrpl/tx/ApplyContext.cpp
+++ b/src/libxrpl/tx/ApplyContext.cpp
@@ -1,27 +1,21 @@
 #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 {
 
@@ -62,6 +56,13 @@ ApplyContext::apply(TER ter)
     return view_->apply(base_, tx, ter, parentBatchId_, (flags_ & TapDryRun) != 0u, journal);
 }
 
+void
+ApplyContext::addOrderBook(Book const& book)
+{
+    if ((flags_ & TapDryRun) == TapNone)
+        registry.get().getOrderBookDB().addOrderBook(book);
+}
+
 std::size_t
 ApplyContext::size()
 {
@@ -75,75 +76,4 @@ ApplyContext::visit(
     view_->visit(base_, func);  // NOLINT(bugprone-unchecked-optional-access)
 }
 
-TER
-ApplyContext::failInvariantCheck(TER const result)
-{
-    // If we already failed invariant checks before and we are now attempting to
-    // only charge a fee, and even that fails the invariant checks something is
-    // very wrong. We switch to tefINVARIANT_FAILED, which does NOT get included
-    // in a ledger.
-
-    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
-        ? TER{tefINVARIANT_FAILED}
-        : TER{tecINVARIANT_FAILED};
-}
-
-template 
-TER
-ApplyContext::checkInvariantsHelper(
-    TER const result,
-    XRPAmount const fee,
-    std::index_sequence)
-{
-    try
-    {
-        auto checkers = getInvariantChecks();
-
-        // call each check's per-entry method
-        visit(
-            [&checkers](
-                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
-                (..., std::get(checkers).visitEntry(isDelete, before, after));
-            });
-
-        // Note: do not replace this logic with a `...&&` fold expression.
-        // The fold expression will only run until the first check fails (it
-        // short-circuits). While the logic is still correct, the log
-        // message won't be. Every failed invariant should write to the log,
-        // not just the first one.
-        std::array const finalizers{{std::get(checkers).finalize(
-            tx, result, fee, *view_, journal)...}};  // NOLINT(bugprone-unchecked-optional-access)
-
-        // call each check's finalizer to see that it passes
-        if (!std::ranges::all_of(finalizers, [](auto const& b) { return b; }))
-        {
-            JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: "
-                                  << to_string(tx.getJson(JsonOptions::Values::None));
-
-            return failInvariantCheck(result);
-        }
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(journal.fatal()) << "Transaction caused an exception in a global invariant"
-                              << ", ex: " << ex.what()
-                              << ", tx: " << to_string(tx.getJson(JsonOptions::Values::None));
-
-        return failInvariantCheck(result);
-    }
-
-    return result;
-}
-
-TER
-ApplyContext::checkInvariants(TER const result, XRPAmount const fee)
-{
-    XRPL_ASSERT(
-        isTesSuccess(result) || isTecClaim(result),
-        "xrpl::ApplyContext::checkInvariants : is tesSUCCESS or tecCLAIM");
-
-    return checkInvariantsHelper(
-        result, fee, std::make_index_sequence>{});
-}
-
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/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 4b562692d7..6bf99e567d 100644
--- a/src/libxrpl/tx/Transactor.cpp
+++ b/src/libxrpl/tx/Transactor.cpp
@@ -41,11 +41,12 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -709,7 +710,7 @@ Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j
     }
 
     SeqProxy const tSeqProx = tx.getSeqProxy();
-    SeqProxy const aSeq = SeqProxy::sequence((*sle)[sfSequence]);
+    SeqProxy const aSeq = SeqProxy::rawSequence((*sle)[sfSequence]);
 
     if (tSeqProx.isSeq())
     {
@@ -791,16 +792,17 @@ TER
 Transactor::consumeSeqProxy(SLE::pointer const& sleAccount)
 {
     XRPL_ASSERT(sleAccount, "xrpl::Transactor::consumeSeqProxy : non-null account");
-    SeqProxy const seqProx = ctx_.tx.getSeqProxy();
-    if (seqProx.isSeq())
+    SeqProxy const seqProxy = ctx_.tx.getSeqProxy();
+    if (seqProxy.isSeq())
     {
         // Note that if this transaction is a TicketCreate, then
         // the transaction will modify the account root sfSequence
         // yet again.
-        sleAccount->setFieldU32(sfSequence, seqProx.value() + 1);
+        sleAccount->setFieldU32(sfSequence, seqProxy.value() + 1);
         return tesSUCCESS;
     }
-    return ticketDelete(view(), accountID_, getTicketIndex(accountID_, seqProx), j_);
+    auto const keylet = keylet::ticket(accountID_, seqProxy);
+    return ticketDelete(view(), accountID_, keylet.key, j_);
 }
 
 // Remove a single Ticket from the ledger.
@@ -1538,53 +1540,12 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
 }
 
 [[nodiscard]] TER
-Transactor::checkTransactionInvariants(TER result, XRPAmount fee)
+Transactor::checkInvariants(TER result, XRPAmount fee, InvariantScope scope)
 {
-    try
-    {
-        // Phase 1: visit modified entries
-        ctx_.visit(
-            [this](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
-                this->visitInvariantEntry(isDelete, before, after);
-            });
+    if (scope == InvariantScope::Full)
+        return xrpl::checkInvariants(ctx_, result, fee, *this);
 
-        // Phase 2: finalize
-        if (!this->finalizeInvariants(ctx_.tx, result, fee, ctx_.view(), ctx_.journal))
-        {
-            JLOG(ctx_.journal.fatal()) <<                                             //
-                "Transaction has failed one or more transaction invariants, tx: " <<  //
-                to_string(ctx_.tx.getJson(JsonOptions::Values::None));
-            return tecINVARIANT_FAILED;
-        }
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(ctx_.journal.fatal()) <<                               //
-            "Exception while checking transaction invariants: " <<  //
-            ex.what() <<                                            //
-            ", tx: " <<                                             //
-            to_string(ctx_.tx.getJson(JsonOptions::Values::None));
-
-        return tecINVARIANT_FAILED;
-    }
-
-    return result;
-}
-
-[[nodiscard]] TER
-Transactor::checkInvariants(TER result, XRPAmount fee)
-{
-    /*
-     * DISABLED for 3.2.0 — Must be re-introduced for 3.3.0
-     *
-     * Transaction invariants are disabled due to a performance regression:
-     * the two-pass design (transaction-specific invariants + protocol invariants)
-     * iterates over modified ledger entries twice per transaction.
-     *
-     * Until resolved, only protocol invariants are checked (delegated to ctx_).
-     * This is safe because all transaction invariants in 3.2.0 are  no-ops.
-     */
-    return ctx_.checkInvariants(result, fee);
+    return xrpl::checkInvariants(ctx_, result, fee);
 }
 
 //------------------------------------------------------------------------------
@@ -1636,85 +1597,97 @@ Transactor::operator()()
     if (auto stream = j_.trace())
         stream << "preclaim result: " << transToken(result);
 
-    bool applied = isTesSuccess(result);
     auto fee = ctx_.tx.getFieldAmount(sfFee).xrp();
+    bool const canApply = std::invoke([&result, &fee, this] {
+        bool canApplyTmp = isTesSuccess(result);
 
-    if (ctx_.size() > kOversizeMetaDataCap)
-        result = tecOVERSIZE;
+        if (ctx_.size() > kOversizeMetaDataCap)
+            result = tecOVERSIZE;
 
-    if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u))
-    {
-        // If the TapFailHard flag is set, a tec result
-        // must not do anything
-        ctx_.discard();
-        applied = false;
-    }
-    else if (
-        (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) ||
-        (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags())))
-    {
-        std::tie(result, fee, applied) = processPersistentChanges(result, fee);
-    }
-
-    if (applied)
-    {
-        // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can
-        // proceed to apply the tx
-        result = checkInvariants(result, fee);
-        if (result == tecINVARIANT_FAILED)
+        if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u))
         {
-            // Reset to fee-claim only
-            auto const resetResult = reset(fee);
-            if (!isTesSuccess(resetResult.first))
-                result = resetResult.first;
-
-            fee = resetResult.second;
-
-            // Check invariants again to ensure the fee claiming doesn't violate
-            // invariants. After reset, only protocol invariants are re-checked.
-            // Transaction invariants are not meaningful here — the transaction's
-            // effects have been rolled back.
-            if (isTesSuccess(result) || isTecClaim(result))
-                result = ctx_.checkInvariants(result, fee);
+            // If the TapFailHard flag is set, a tec result
+            // must not do anything
+            ctx_.discard();
+            canApplyTmp = false;
         }
+        else if (
+            (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) ||
+            (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags())))
+        {
+            // This is and must remain the only place where `canApplyTmp` can change from false to
+            // true. Changing from true to false is no problem.
+            std::tie(result, fee, canApplyTmp) = processPersistentChanges(result, fee);
+        }
+        return canApplyTmp;
+    });
 
-        // We ran through the invariant checker, which can, in some cases,
-        // return a tef error code. Don't apply the transaction in that case.
-        if (!isTecClaim(result) && !isTesSuccess(result))
-            applied = false;
+    auto const logger = [this](
+                            TER result,
+                            bool canApply,
+                            std::optional&& metadata = std::nullopt) -> ApplyResult {
+        JLOG(j_.trace()) << (canApply ? "applied " : "not applied ") << transToken(result);
+        return {result, canApply, std::move(metadata)};
+    };
+
+    if (!canApply)
+        return logger(result, canApply);
+
+    // First invariant pass: both protocol and transaction-specific
+    // checks run against the transaction's tentative outcome. If it
+    // does not return tecINVARIANT_FAILED, we can proceed to apply the
+    // tx.
+    result = checkInvariants(result, fee, InvariantScope::Full);
+    if (result == tecINVARIANT_FAILED)
+    {
+        // Fee-claim reset: roll the transaction's effects back so that
+        // only the fee deduction remains. This is the reset referenced
+        // by InvariantScope::ProtocolOnly.
+        auto const resetResult = reset(fee);
+        if (!isTesSuccess(resetResult.first))
+            result = resetResult.first;
+
+        fee = resetResult.second;
+
+        // Re-check invariants against the post-reset (fee-claim only)
+        // state. The transaction's effects are gone, so the
+        // transaction-specific invariants no longer apply and only the
+        // protocol invariants are re-run. A failure here escalates to
+        // tefINVARIANT_FAILED and excludes the tx from the ledger.
+        if (isTesSuccess(result) || isTecClaim(result))
+            result = checkInvariants(result, fee, InvariantScope::ProtocolOnly);
     }
 
+    // We ran through the invariant checker, which can, in some cases,
+    // return a tef error code. Don't apply the transaction in that case.
+    if (!isTecClaim(result) && !isTesSuccess(result))
+        return logger(result, false);
+
     std::optional metadata;
-    if (applied)
-    {
-        // Transaction succeeded fully or (retries are not allowed and the
-        // transaction could claim a fee)
 
-        // The transactor and invariant checkers guarantee that this will
-        // *never* trigger but if it, somehow, happens, don't allow a tx
-        // that charges a negative fee.
-        if (fee < beast::kZero)
-            Throw("fee charged is negative!");
+    // Transaction succeeded fully or (retries are not allowed and the
+    // transaction could claim a fee)
 
-        // Charge whatever fee they specified. The fee has already been
-        // deducted from the balance of the account that issued the
-        // transaction. We just need to account for it in the ledger
-        // header.
-        if (!view().open() && fee != beast::kZero)
-            ctx_.destroyXRP(fee);
+    // The transactor and invariant checkers guarantee that this will
+    // *never* trigger but if it, somehow, happens, don't allow a tx
+    // that charges a negative fee.
+    if (fee < beast::kZero)
+        Throw("fee charged is negative!");
 
-        // Once we call apply, we will no longer be able to look at view()
-        metadata = ctx_.apply(result);
-    }
+    // Charge whatever fee they specified. The fee has already been
+    // deducted from the balance of the account that issued the
+    // transaction. We just need to account for it in the ledger
+    // header.
+    if (!view().open() && fee != beast::kZero)
+        ctx_.destroyXRP(fee);
+
+    // Once we call apply, we will no longer be able to look at view()
+    metadata = ctx_.apply(result);
 
     if ((ctx_.flags() & TapDryRun) != 0u)
-    {
-        applied = false;
-    }
+        return logger(result, false, std::move(metadata));
 
-    JLOG(j_.trace()) << (applied ? "applied " : "not applied ") << transToken(result);
-
-    return {result, applied, metadata};
+    return logger(result, canApply, std::move(metadata));
 }
 
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/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/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp
index 5af4f621a7..00ac9f9983 100644
--- a/src/libxrpl/tx/applySteps.cpp
+++ b/src/libxrpl/tx/applySteps.cpp
@@ -17,6 +17,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -195,7 +196,12 @@ invokePreclaim(PreclaimContext const& ctx)
                     }())
                     return preSigResult;
 
-                if (TER const result = T::checkFee(ctx, calculateBaseFee(ctx.view, ctx.tx)))
+                // We can't check the fee if we can't compute it, so reject.
+                auto const baseFee = calculateBaseFee(ctx.view, ctx.tx);
+                if (!baseFee)
+                    return baseFee.error();
+
+                if (TER const result = T::checkFee(ctx, *baseFee))
                     return result;
             }
 
@@ -223,13 +229,12 @@ invokePreclaim(PreclaimContext const& ctx)
  *
  * @param view The ledger view to use for fee calculation.
  * @param tx The transaction for which the base fee is to be calculated.
- * @return The calculated base fee as an XRPAmount.
+ * @return The calculated base fee. Returns `std::unexpected(temUNKNOWN)` if the transaction
+ * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's
+ * `calculateBaseFee` threw.
  *
- * @throws std::exception If an error occurs during fee calculation, including
- * but not limited to unknown transaction types or internal errors, the function
- * logs an error and returns an XRPAmount of zero.
  */
-static XRPAmount
+static std::expected
 invokeCalculateBaseFee(ReadView const& view, STTx const& tx)
 {
     try
@@ -238,20 +243,32 @@ invokeCalculateBaseFee(ReadView const& view, STTx const& tx)
             return T::calculateBaseFee(view, tx);
         });
     }
-    catch (UnknownTxnType const& e)
+    catch (UnknownTxnType const&)
     {
         // LCOV_EXCL_START
         UNREACHABLE("xrpl::invoke_calculateBaseFee : unknown transaction type");
-        return XRPAmount{0};
+        return std::unexpected(temUNKNOWN);
         // LCOV_EXCL_STOP
     }
+    catch (std::exception const& e)
+    {
+        JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID()
+                                 << " threw an exception: " << e.what();
+        return std::unexpected(tefEXCEPTION);
+    }
+    catch (...)
+    {
+        JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID()
+                                 << " threw an unknown exception";
+        return std::unexpected(tefEXCEPTION);
+    }
 }
 
 TxConsequences::TxConsequences(NotTEC pfResult)
     : isBlocker_(false)
     , fee_(beast::kZero)
     , potentialSpend_(beast::kZero)
-    , seqProx_(SeqProxy::sequence(0))
+    , seqProx_(SeqProxy::rawSequence(0))
     , sequencesConsumed_(0)
 {
     XRPL_ASSERT(
@@ -416,7 +433,7 @@ preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, Open
     }
 }
 
-XRPAmount
+std::expected
 calculateBaseFee(ReadView const& view, STTx const& tx)
 {
     return invokeCalculateBaseFee(view, tx);
@@ -441,13 +458,26 @@ doApply(PreclaimResult const& preclaimResult, ServiceRegistry& registry, OpenVie
     {
         if (!preclaimResult.likelyToClaimFee)
             return {preclaimResult.ter, false};
+
+        // For any tx with a real account, preclaim already computed this fee
+        // successfully against this same view.
+        auto const baseFee = calculateBaseFee(view, preclaimResult.tx);
+        if (!baseFee)
+        {
+            // LCOV_EXCL_START
+            JLOG(preclaimResult.j.error())
+                << "apply: could not compute base fee: " << transToken(baseFee.error());
+            return {tefINTERNAL, false};
+            // LCOV_EXCL_STOP
+        }
+
         ApplyContext ctx(
             registry,
             view,
             preclaimResult.parentBatchId,
             preclaimResult.tx,
             preclaimResult.ter,
-            calculateBaseFee(view, preclaimResult.tx),
+            *baseFee,
             preclaimResult.flags,
             preclaimResult.j);
         return invokeApply(ctx);
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 9b997e06dd..96820d00bb 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";
@@ -1116,30 +1123,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) ||
@@ -1150,15 +1161,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) ||
@@ -1176,20 +1179,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..f87a7620af 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,146 @@ 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.
+            //
+            // PrincipalOutstanding may stay put on a non-final pay: at integer
+            // scale, fixCleanup3_2_0 rounds principal up so a fractional
+            // amortization step does not reduce it. Interest (TVO) still falls.
+            // Neither balance may grow: a payment never adds to what is owed,
+            // since late-payment penalties are charged in the same transaction
+            // rather than tracked in TotalValueOutstanding.
+            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 not increase "
+                                           "PrincipalOutstanding on a non-full-repayment";
+                        return false;
+                    }
+                    if (after->at(sfTotalValueOutstanding) > before->at(sfTotalValueOutstanding))
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: loan pay must not increase "
+                                           "TotalValueOutstanding on a non-full-repayment";
+                        return false;
+                    }
+                    if (after->at(sfPrincipalOutstanding) == before->at(sfPrincipalOutstanding) &&
+                        after->at(sfTotalValueOutstanding) == before->at(sfTotalValueOutstanding))
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: loan pay must decrease "
+                                           "PrincipalOutstanding or TotalValueOutstanding "
+                                           "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..060673c6e1 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 
@@ -39,6 +40,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array({
     ttCONFIDENTIAL_MPT_CONVERT_BACK,
     ttCONFIDENTIAL_MPT_MERGE_INBOX,
     ttCONFIDENTIAL_MPT_CLAWBACK,
+    ttCONFIDENTIAL_MPT_MIRROR_UPDATE,
 });
 
 // Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
@@ -143,6 +145,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 +212,7 @@ ValidMPTIssuance::finalize(
         }
 
         auto const txnType = tx.getTxnType();
-        if (hasPrivilege(tx, CreateMptIssuance))
+        if (hasPrivilege(tx, Privilege::CreateMptIssuance))
         {
             if (mptIssuancesCreated_ == 0)
             {
@@ -229,7 +233,7 @@ ValidMPTIssuance::finalize(
             return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
         }
 
-        if (hasPrivilege(tx, DestroyMptIssuance))
+        if (hasPrivilege(tx, Privilege::DestroyMptIssuance))
         {
             if (mptIssuancesDeleted_ == 0)
             {
@@ -256,7 +260,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 +277,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,45 +287,65 @@ 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";
                     return false;
                 }
             }
-            else if (lendingProtocolEnabled && (mptokensCreated_ + mptokensDeleted_) > 1)
+            else
             {
-                JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
-                                   "but created/deleted bad number mptokens";
-                return false;
-            }
-            else if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0))
-            {
-                JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
-                                   "succeeded but created/deleted mptokens";
-                return false;
-            }
-            else if (
-                !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) &&
-                (mptokensCreated_ + mptokensDeleted_ != 1))
-            {
-                // if the holder submitted this tx, then a mptoken must be
-                // either created or deleted.
-                JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder "
-                                   "succeeded but created/deleted bad number of mptokens";
-                return false;
+                // Cap on MPToken creates and deletes while featureLendingProtocol is enabled.
+                // - LoanSet: at most two creates and no deletes.
+                // - VaultWithdraw: at most one create and one delete.
+                // - Other MayAuthorizeMpt types: created + deleted <= 1.
+                // - MustAuthorizeMpt still requires exactly one create or delete below.
+                auto const mptokensExceedAuthorizeCap = [&] {
+                    if (!lendingProtocolEnabled)
+                        return false;
+                    if (rules.enabled(fixCleanup3_4_0))
+                    {
+                        if (txnType == ttLOAN_SET)
+                            return mptokensDeleted_ != 0 || mptokensCreated_ > 2;
+                        if (txnType == ttVAULT_WITHDRAW)
+                            return mptokensCreated_ > 1 || mptokensDeleted_ > 1;
+                    }
+                    return (mptokensCreated_ + mptokensDeleted_) > 1;
+                };
+                if (mptokensExceedAuthorizeCap())
+                {
+                    JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
+                                       "but created/deleted bad number mptokens";
+                    return false;
+                }
+                if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0))
+                {
+                    JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
+                                       "succeeded but created/deleted mptokens";
+                    return false;
+                }
+                if (!submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) &&
+                    (mptokensCreated_ + mptokensDeleted_ != 1))
+                {
+                    // if the holder submitted this tx, then a mptoken must be
+                    // either created or deleted.
+                    JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder "
+                                       "succeeded but created/deleted bad number of mptokens";
+                    return false;
+                }
             }
 
             return true;
         }
 
-        if (hasPrivilege(tx, MayCreateMpt))
+        if (hasPrivilege(tx, Privilege::MayCreateMpt))
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -375,7 +400,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 +497,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 +511,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 +537,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 +844,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 +864,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 +889,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 +920,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 +942,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 +970,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 +995,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 +1019,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 52ecbcd9d1..b3b1601018 100644
--- a/src/libxrpl/tx/invariants/NFTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp
@@ -206,7 +206,7 @@ NFTokenCountTracking::finalize(
     ReadView const& view,
     beast::Journal const& j) const
 {
-    if (!hasPrivilege(tx, ChangeNftCounts))
+    if (!hasPrivilege(tx, Privilege::ChangeNftCounts))
     {
         if (beforeMintedTotal_ != afterMintedTotal_)
         {
diff --git a/src/libxrpl/tx/invariants/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 a9ba0ec874..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 ||
@@ -465,7 +596,7 @@ ValidVault::finalize(
 
     if (afterVault.assetsAvailable < kZero)
     {
-        JLOG(j.fatal()) << "Invariant failed: assets available must be positive";
+        JLOG(j.fatal()) << "Invariant failed: assets available must not be negative";
         result = false;
     }
 
@@ -475,23 +606,46 @@ 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";
+        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 (fix340Enabled && afterVault.lossUnrealized < kZero)
+    {
+        JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative";
         result = false;
     }
 
     if (afterVault.assetsTotal < kZero)
     {
-        JLOG(j.fatal()) << "Invariant failed: assets outstanding must be positive";
+        JLOG(j.fatal()) << "Invariant failed: assets outstanding must not be negative";
         result = false;
     }
 
     if (afterVault.assetsMaximum < kZero)
     {
-        JLOG(j.fatal()) << "Invariant failed: assets maximum must be positive";
+        JLOG(j.fatal()) << "Invariant failed: assets maximum must not be negative";
         result = false;
     }
 
@@ -514,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;
@@ -600,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: {
@@ -625,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 "
@@ -660,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)
                 {
@@ -700,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())
@@ -725,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";
@@ -773,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";
@@ -782,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;
@@ -798,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;
@@ -828,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;
+                        }
                     }
                 }
 
@@ -931,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";
@@ -941,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";
@@ -986,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";
@@ -997,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";
@@ -1046,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/AMMLiquidity.cpp b/src/libxrpl/tx/paths/AMMLiquidity.cpp
index 0d1c66ead8..1b38847d7b 100644
--- a/src/libxrpl/tx/paths/AMMLiquidity.cpp
+++ b/src/libxrpl/tx/paths/AMMLiquidity.cpp
@@ -133,17 +133,8 @@ maxOut(T const& out, Asset const& asset)
 
 template 
 std::optional>
-AMMLiquidity::maxOffer(TAmounts const& balances, Rules const& rules) const
+AMMLiquidity::maxOffer(TAmounts const& balances) const
 {
-    if (!rules.enabled(fixAMMOverflowOffer))
-    {
-        return AMMOffer(
-            *this,
-            {maxAmount(), swapAssetIn(balances, maxAmount(), tradingFee_)},
-            balances,
-            Quality{balances});
-    }
-
     auto const out = maxOut(balances.out, assetOut());
     if (out <= TOut{0} || out >= balances.out)
         return std::nullopt;
@@ -206,7 +197,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c
                 // changed in BookStep per either deliver amount limit, or
                 // sendmax, or available output or input funds. Might return
                 // nullopt if the pool is small.
-                return maxOffer(balances, view.rules());
+                return maxOffer(balances);
             }
             if (auto const amounts =
                     changeSpotPriceQuality(balances, *clobQuality, tradingFee_, view.rules(), j_))
@@ -215,7 +206,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c
             }
             if (view.rules().enabled(fixAMMv1_2))
             {
-                if (auto const maxAMMOffer = maxOffer(balances, view.rules());
+                if (auto const maxAMMOffer = maxOffer(balances);
                     maxAMMOffer && Quality{maxAMMOffer->amount()} > *clobQuality)
                     return maxAMMOffer;
             }
@@ -223,10 +214,6 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c
         catch (std::overflow_error const& e)
         {
             JLOG(j_.error()) << "AMMLiquidity::getOffer overflow " << e.what();
-            if (!view.rules().enabled(fixAMMOverflowOffer))
-            {
-                return maxOffer(balances, view.rules());
-            }
 
             return std::nullopt;
         }
diff --git a/src/libxrpl/tx/paths/AMMOffer.cpp b/src/libxrpl/tx/paths/AMMOffer.cpp
index 3a7bd8f1df..a4a067c4f0 100644
--- a/src/libxrpl/tx/paths/AMMOffer.cpp
+++ b/src/libxrpl/tx/paths/AMMOffer.cpp
@@ -134,11 +134,13 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast::
 {
     if (consumed.in > amounts_.in || consumed.out > amounts_.out)
     {
+        // LCOV_EXCL_START
         JLOG(j.error()) << "AMMOffer::checkInvariant failed: consumed " << to_string(consumed.in)
                         << " " << to_string(consumed.out) << " amounts " << to_string(amounts_.in)
                         << " " << to_string(amounts_.out);
 
         return false;
+        // LCOV_EXCL_STOP
     }
 
     Number const product = balances_.in * balances_.out;
@@ -149,6 +151,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast::
     if (newProduct >= product || withinRelativeDistance(product, newProduct, Number{1, -7}))
         return true;
 
+    // LCOV_EXCL_START
     JLOG(j.error()) << "AMMOffer::checkInvariant failed: balances " << to_string(balances_.in)
                     << " " << to_string(balances_.out) << " new balances "
                     << to_string(newBalances.in) << " " << to_string(newBalances.out)
@@ -156,6 +159,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast::
                     << (product != Number{0} ? to_string((product - newProduct) / product)
                                              : "undefined");
     return false;
+    // LCOV_EXCL_STOP
 }
 
 template class AMMOffer;
diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp
index 71902ce8b9..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.
@@ -865,17 +916,30 @@ BookStep::consumeOffer(
 {
     if (!offer.checkInvariant(ofrAmt, j_))
     {
-        // purposely written as separate if statements so we get logging even
-        // when the amendment isn't active.
-        if (sb.rules().enabled(fixAMMOverflowOffer))
-        {
-            Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed.");
-        }
+        // LCOV_EXCL_START
+        Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed.");
+        // LCOV_EXCL_STOP
     }
 
     // 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))
@@ -1046,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,
@@ -1147,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;
         }
@@ -1166,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
@@ -1184,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;
@@ -1200,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;
@@ -1217,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);
 
@@ -1411,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;
 
@@ -1424,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 0055fce403..0936fe26dc 100644
--- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp
+++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
@@ -50,7 +50,7 @@ AccountDelete::preflight(PreflightContext const& ctx)
         return temDST_IS_SRC;
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -241,6 +241,8 @@ AccountDelete::preclaim(PreclaimContext const& ctx)
     if (!ctx.tx.isFieldPresent(sfCredentialIDs))
     {
         // Check whether the destination account requires deposit authorization.
+        // This also checks if destination is a pseudo-account, since pseudo-accounts have the
+        // lsfDepositAuth flag set by default
         if (sleDst->isFlag(lsfDepositAuth))
         {
             if (!ctx.view.exists(keylet::depositPreauth(dst, account)))
diff --git a/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp b/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
index e03cb56fd5..cbfb93c386 100644
--- a/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
+++ b/src/libxrpl/tx/transactors/bridge/XChainBridge.cpp
@@ -864,7 +864,7 @@ applyClaimAttestations(
             return std::unexpected(tecXCHAIN_NO_CLAIM_ID);
 
         // Add claims that are part of the signer's list to the "claims" vector
-        std::vector atts;
+        std::vector atts;
         atts.reserve(std::distance(attBegin, attEnd));
         for (auto att = attBegin; att != attEnd; ++att)
         {
@@ -1042,7 +1042,7 @@ applyCreateAccountAttestations(
                 return std::unexpected(tecINSUFFICIENT_RESERVE);
         }
 
-        std::vector atts;
+        std::vector atts;
         atts.reserve(std::distance(attBegin, attEnd));
         for (auto att = attBegin; att != attEnd; ++att)
         {
@@ -1160,8 +1160,8 @@ std::optional
 toClaim(STTx const& tx)
 {
     static_assert(
-        std::is_same_v ||
-        std::is_same_v);
+        std::is_same_v ||
+        std::is_same_v);
 
     try
     {
@@ -1301,10 +1301,10 @@ attestationDoApply(ApplyContext& ctx)
     auto const& [srcChain, signersList, quorum, thisDoor, bridgeK] = scopeResult.value();
 
     static_assert(
-        std::is_same_v ||
-        std::is_same_v);
+        std::is_same_v ||
+        std::is_same_v);
 
-    if constexpr (std::is_same_v)
+    if constexpr (std::is_same_v)
     {
         return applyClaimAttestations(
             ctx.view(),
@@ -1317,7 +1317,7 @@ attestationDoApply(ApplyContext& ctx)
             quorum,
             ctx.journal);
     }
-    else if constexpr (std::is_same_v)
+    else if constexpr (std::is_same_v)
     {
         return applyCreateAccountAttestations(
             ctx.view(),
@@ -2067,19 +2067,19 @@ XChainCreateClaimID::doApply()
 NotTEC
 XChainAddClaimAttestation::preflight(PreflightContext const& ctx)
 {
-    return attestationPreflight(ctx);
+    return attestationPreflight(ctx);
 }
 
 TER
 XChainAddClaimAttestation::preclaim(PreclaimContext const& ctx)
 {
-    return attestationPreclaim(ctx);
+    return attestationPreclaim(ctx);
 }
 
 TER
 XChainAddClaimAttestation::doApply()
 {
-    return attestationDoApply(ctx_);
+    return attestationDoApply(ctx_);
 }
 
 //------------------------------------------------------------------------------
@@ -2087,19 +2087,19 @@ XChainAddClaimAttestation::doApply()
 NotTEC
 XChainAddAccountCreateAttestation::preflight(PreflightContext const& ctx)
 {
-    return attestationPreflight(ctx);
+    return attestationPreflight(ctx);
 }
 
 TER
 XChainAddAccountCreateAttestation::preclaim(PreclaimContext const& ctx)
 {
-    return attestationPreclaim(ctx);
+    return attestationPreclaim(ctx);
 }
 
 TER
 XChainAddAccountCreateAttestation::doApply()
 {
-    return attestationDoApply(ctx_);
+    return attestationDoApply(ctx_);
 }
 
 //------------------------------------------------------------------------------
diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp
index a8c989f4df..f753f604f0 100644
--- a/src/libxrpl/tx/transactors/check/CheckCash.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp
@@ -19,8 +19,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
@@ -376,18 +377,29 @@ CheckCash::doApply()
         else
         {
             // Note that for DeliverMin we don't know exactly how much
-            // currency we want flow to deliver.  We can't ask for the
-            // maximum possible currency because there might be a gateway
-            // transfer rate to account for.  Since the transfer rate cannot
-            // exceed 200%, we use 1/2 maxValue as our limit.
+            // currency we want flow to deliver.  For IOUs, use a value
+            // higher than any real delivery as the request. MPTs are
+            // bounded integral amounts, so use the maximum output the check
+            // can actually deliver without exceeding SendMax.
             auto const maxDeliverMin = [&]() {
                 return optDeliverMin->asset().visit(
                     [&](Issue const&) {
                         return STAmount(
                             optDeliverMin->asset(), STAmount::kMaxValue / 2, STAmount::kMaxOffset);
                     },
-                    [&](MPTIssue const&) {
-                        return STAmount(optDeliverMin->asset(), kMaxMpTokenAmount / 2);
+                    [&](MPTIssue const& issue) {
+                        MPTAmount maxDeliver = sendMax.mpt();
+                        auto const& issuer = issue.getIssuer();
+                        if (srcId != issuer && accountID_ != issuer)
+                        {
+                            auto const rate = transferRate(psb, issue.getMptID());
+                            // Request at most floor(SendMax / rate). The endpoint reverse pass
+                            // will quote ceil(output * rate), so this keeps the input
+                            // representable and within SendMax.
+                            maxDeliver =
+                                mulRatio(maxDeliver, QUALITY_ONE, rate.value, /*roundUp*/ false);
+                        }
+                        return STAmount(maxDeliver, issue);
                     });
             };
             STAmount const flowDeliver{
@@ -427,6 +439,12 @@ CheckCash::doApply()
             AccountID const& deliverIssuer = flowDeliver.getIssuer();
             auto const err = flowDeliver.asset().visit(
                 [&](Issue const& issue) -> std::optional {
+                    // An issuer needs no holder-limit waiver to receive its own currency.
+                    if (deliverIssuer == accountID_ && ctx_.view().rules().enabled(fixCleanup3_4_0))
+                    {
+                        return std::nullopt;
+                    }
+
                     // If a trust line does not exist yet create one.
                     Issue const& trustLineIssue = issue;
                     AccountID const truster = deliverIssuer == accountID_ ? srcId : accountID_;
@@ -516,7 +534,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/check/CheckCreate.cpp b/src/libxrpl/tx/transactors/check/CheckCreate.cpp
index cb1d81ba4a..129855e48d 100644
--- a/src/libxrpl/tx/transactors/check/CheckCreate.cpp
+++ b/src/libxrpl/tx/transactors/check/CheckCreate.cpp
@@ -25,7 +25,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 
@@ -201,14 +200,14 @@ CheckCreate::doApply()
         return ret;
     // Note that we use the value from the sequence or ticket as the
     // Check sequence.  For more explanation see comments in SeqProxy.h.
-    std::uint32_t const seq = ctx_.tx.getSeqValue();
+    auto const seq = ctx_.tx.getSeqProxy();
     Keylet const checkKeylet = keylet::check(accountID_, seq);
     auto sleCheck = std::make_shared(checkKeylet);
 
     sleCheck->setAccountID(sfAccount, accountID_);
     AccountID const dstAccountId = ctx_.tx[sfDestination];
     sleCheck->setAccountID(sfDestination, dstAccountId);
-    sleCheck->setFieldU32(sfSequence, seq);
+    sleCheck->setFieldU32(sfSequence, seq.value());
     sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]);
     if (auto const srcTag = ctx_.tx[~sfSourceTag])
         sleCheck->setFieldU32(sfSourceTag, *srcTag);
diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
index e902ee73a6..5cce1a7de8 100644
--- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
+++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp
@@ -84,7 +84,9 @@ CredentialCreate::preclaim(PreclaimContext const& ctx)
     auto const credType(ctx.tx[sfCredentialType]);
     auto const subject = ctx.tx[sfSubject];
 
-    if (!ctx.view.exists(keylet::account(subject)))
+    auto const subjectSle = ctx.view.read(keylet::account(subject));
+
+    if (!subjectSle)
     {
         JLOG(ctx.j.trace()) << "Subject doesn't exist.";
         return tecNO_TARGET;
@@ -96,6 +98,12 @@ CredentialCreate::preclaim(PreclaimContext const& ctx)
         return tecDUPLICATE;
     }
 
+    if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(subjectSle))
+    {
+        JLOG(ctx.j.trace()) << "Subject is a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     return tesSUCCESS;
 }
 
diff --git a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
index 96e6c9e443..12edb43bff 100644
--- a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
+++ b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp
@@ -57,7 +57,7 @@ DelegateSet::preclaim(PreclaimContext const& ctx)
         return tecNO_TARGET;
 
     if (isPseudoAccount(sleAuthorize))
-        return tecNO_PERMISSION;
+        return tecPSEUDO_ACCOUNT;
 
     // Deleting the delegate object is invalid if it doesn’t exist.
     if (ctx.tx.getFieldArray(sfPermissions).empty() &&
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/AMMCreate.cpp b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
index 7c7d35497a..2bc9aa5ba1 100644
--- a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp
@@ -2,8 +2,6 @@
 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
@@ -397,7 +395,7 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou
         Book const book{assetIn, assetOut, std::nullopt};
         auto const dir = keylet::quality(keylet::book(book), uRate);
         if (auto const bookExisted = static_cast(sb.read(dir)); !bookExisted)
-            ctx.registry.get().getOrderBookDB().addOrderBook(book);
+            ctx.addOrderBook(book);
     };
     addOrderBook(amount.asset(), amount2.asset(), getRate(amount2, amount));
     addOrderBook(amount2.asset(), amount.asset(), getRate(amount, amount2));
diff --git a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
index 0d1798babc..64d6d70e67 100644
--- a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp
@@ -28,6 +28,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -437,11 +438,10 @@ AMMDeposit::applyGuts(Sandbox& sb)
 
     auto const subTxType = ctx_.tx.getFlags() & tfDepositSubTx;
 
-    auto const [result, newLPTokenBalance] = [&,
-                                              &amountBalance = amountBalance,
-                                              &amount2Balance = amount2Balance,
-                                              &lptAMMBalance =
-                                                  lptAMMBalance]() -> std::pair {
+    auto dispatchToDeposit = [&,
+                              &amountBalance = amountBalance,
+                              &amount2Balance = amount2Balance,
+                              &lptAMMBalance = lptAMMBalance]() -> std::pair {
         if (subTxType & tfTwoAsset)
         {
             return equalDepositLimit(
@@ -493,6 +493,28 @@ AMMDeposit::applyGuts(Sandbox& sb)
         JLOG(j_.error()) << "AMM Deposit: invalid options.";
         return std::make_pair(tecINTERNAL, STAmount{});
         // LCOV_EXCL_STOP
+    };
+
+    auto const [result, newLPTokenBalance] = [&]() -> std::pair {
+        try
+        {
+            return dispatchToDeposit();
+        }
+        catch (std::runtime_error const& e)
+        {
+            REACHABLE("xrpl::AMMDeposit::applyGuts : deposit amount out of range reached");
+            // A deposit whose solved amount exceeds the integral asset's range
+            // throws while converting to STAmount: past int64max
+            // Number::operator rep() throws std::overflow_error; above the asset
+            // maximum STAmount::canonicalize throws std::runtime_error. Fail
+            // cleanly with a tec rather than letting it escape doApply as
+            // tefEXCEPTION. Any other exception is left to propagate.
+            // Gated by fixCleanup3_4_0 to preserve the legacy result pre-amendment.
+            if (!sb.rules().enabled(fixCleanup3_4_0))
+                throw;  // LCOV_EXCL_LINE - preserve legacy tefEXCEPTION
+            JLOG(j_.error()) << "AMMDeposit: deposit amount out of range " << e.what();
+            return std::make_pair(tecAMM_FAILED, STAmount{});
+        }
     }();
 
     if (isTesSuccess(result))
diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
index 2baa7edfb4..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 
@@ -34,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -374,11 +376,10 @@ AMMWithdraw::applyGuts(Sandbox& sb)
     auto const [amountBalance, amount2Balance, lptAMMBalance] = *expected;
     auto const subTxType = ctx_.tx.getFlags() & tfWithdrawSubTx;
 
-    auto const [result, newLPTokenBalance] = [&,
-                                              &amountBalance = amountBalance,
-                                              &amount2Balance = amount2Balance,
-                                              &lptAMMBalance =
-                                                  lptAMMBalance]() -> std::pair {
+    auto dispatchToWithdraw = [&,
+                               &amountBalance = amountBalance,
+                               &amount2Balance = amount2Balance,
+                               &lptAMMBalance = lptAMMBalance]() -> std::pair {
         if (subTxType & tfTwoAsset)
         {
             return equalWithdrawLimit(
@@ -432,6 +433,29 @@ AMMWithdraw::applyGuts(Sandbox& sb)
         JLOG(j_.error()) << "AMM Withdraw: invalid options.";
         return std::make_pair(tecINTERNAL, STAmount{});
         // LCOV_EXCL_STOP
+    };
+
+    auto const [result, newLPTokenBalance] = [&]() -> std::pair {
+        try
+        {
+            return dispatchToWithdraw();
+        }
+        catch (std::runtime_error const& e)
+        {
+            // Defense in-depth for amount overflow/out-of-range: the withdrawal
+            // counterpart of the AMMDeposit guard. Unlike deposit, no known
+            // withdraw path can throw here - preclaim bounds the requested
+            // amounts by the pool balances, and the only historical throw
+            // (denom == 0 in singleWithdrawEPrice) is guarded under
+            // fixCleanup3_3_0. Gated by fixCleanup3_4_0 to preserve the
+            // legacy tefEXCEPTION pre-amendment.
+            if (!sb.rules().enabled(fixCleanup3_4_0))
+                throw;
+            // LCOV_EXCL_START
+            JLOG(j_.error()) << "AMMWithdraw: amount out of range " << e.what();
+            return std::make_pair(tecAMM_FAILED, STAmount{});
+            // LCOV_EXCL_STOP
+        }
     }();
 
     if (!isTesSuccess(result))
@@ -493,6 +517,7 @@ AMMWithdraw::withdraw(
         view,
         ammSle,
         ammAccount,
+        std::nullopt,
         accountID_,
         amountBalance,
         amountWithdraw,
@@ -502,6 +527,7 @@ AMMWithdraw::withdraw(
         tfee,
         issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
+        ReserveHandling::EnforceReserve,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
         j_);
@@ -513,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,
@@ -522,6 +549,7 @@ AMMWithdraw::withdraw(
     std::uint16_t tfee,
     FreezeHandling freezeHandling,
     AuthHandling authHandling,
+    ReserveHandling reserveHandling,
     WithdrawAll withdrawAll,
     XRPAmount const& priorBalance,
     beast::Journal const& journal)
@@ -643,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
@@ -667,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;
         }
@@ -680,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;
@@ -781,6 +850,7 @@ AMMWithdraw::equalWithdrawTokens(
         view,
         ammSle,
         accountID_,
+        std::nullopt,
         ammAccount,
         amountBalance,
         amount2Balance,
@@ -790,6 +860,7 @@ AMMWithdraw::equalWithdrawTokens(
         tfee,
         issuerFreezeHandling(),
         AuthHandling::ZeroIfUnauthorized,
+        ReserveHandling::EnforceReserve,
         isWithdrawAll(ctx_.tx),
         preFeeBalance_,
         ctx_.journal);
@@ -833,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,
@@ -842,6 +914,7 @@ AMMWithdraw::equalWithdrawTokens(
     std::uint16_t tfee,
     FreezeHandling freezeHandling,
     AuthHandling authHandling,
+    ReserveHandling reserveHandling,
     WithdrawAll withdrawAll,
     XRPAmount const& priorBalance,
     beast::Journal const& journal)
@@ -855,6 +928,7 @@ AMMWithdraw::equalWithdrawTokens(
                 view,
                 ammSle,
                 ammAccount,
+                clawbackIssuer,
                 account,
                 amountBalance,
                 amountBalance,
@@ -864,6 +938,7 @@ AMMWithdraw::equalWithdrawTokens(
                 tfee,
                 freezeHandling,
                 authHandling,
+                reserveHandling,
                 WithdrawAll::Yes,
                 priorBalance,
                 journal);
@@ -890,6 +965,7 @@ AMMWithdraw::equalWithdrawTokens(
             view,
             ammSle,
             ammAccount,
+            clawbackIssuer,
             account,
             amountBalance,
             amountWithdraw,
@@ -899,6 +975,7 @@ AMMWithdraw::equalWithdrawTokens(
             tfee,
             freezeHandling,
             authHandling,
+            reserveHandling,
             withdrawAll,
             priorBalance,
             journal);
diff --git a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
index 0dea5fa967..fd19037d4f 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -57,7 +58,8 @@ OfferCancel::doApply()
     if (!sle)
         return tefINTERNAL;  // LCOV_EXCL_LINE
 
-    if (auto sleOffer = view().peek(keylet::offer(accountID_, offerSequence)))
+    auto const seqProxy = SeqProxy::rawSequence(offerSequence);
+    if (auto sleOffer = view().peek(keylet::offer(accountID_, seqProxy)))
     {
         JLOG(j_.debug()) << "Trying to cancel offer #" << offerSequence;
         return offerDelete(view(), sleOffer, ctx_.registry.get().getJournal("View"));
diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
index fb47cf0f97..6c3c04f1a0 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
@@ -6,11 +6,11 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -34,6 +34,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -241,8 +242,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))
@@ -283,10 +307,23 @@ OfferCreate::checkAcceptAsset(
     return asset.visit(
         [&](Issue const& issue) -> TER {
             auto const& issuer = issue.getIssuer();
+            auto const trustLine = view.read(keylet::trustLine(id, issuer, issue.currency));
+
+            // Check if the issuer has lsfDisallowIncomingTrustline set.
+            // If so, the account must already have a trustline to receive tokens.
+            if (view.rules().enabled(fixCleanup3_4_0) &&
+                issuerAccount->isFlag(lsfDisallowIncomingTrustline))
+            {
+                if (!trustLine)
+                {
+                    JLOG(j.debug()) << "delay: can't receive IOUs from issuer with "
+                                       "DisallowIncomingTrustline set";
+                    return ((flags & TapRetry) != 0u) ? TER{terNO_LINE} : TER{tecNO_LINE};
+                }
+            }
+
             if (issuerAccount->isFlag(lsfRequireAuth))
             {
-                auto const trustLine = view.read(keylet::trustLine(id, issuer, issue.currency));
-
                 if (!trustLine)
                 {
                     return ((flags & TapRetry) != 0u) ? TER{terNO_LINE} : TER{tecNO_LINE};
@@ -309,8 +346,6 @@ OfferCreate::checkAcceptAsset(
                 }
             }
 
-            auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency));
-
             if (!trustLine)
             {
                 return tesSUCCESS;
@@ -598,7 +633,7 @@ OfferCreate::applyHybrid(
     bookArr.pushBack(std::move(bookInfo));
 
     if (!bookExists)
-        ctx_.registry.get().getOrderBookDB().addOrderBook(book);
+        ctx_.addOrderBook(book);
 
     sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
     return tesSUCCESS;
@@ -623,7 +658,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
 
     // Note that we use the value from the sequence or ticket as the
     // offer sequence.  For more explanation see comments in SeqProxy.h.
-    auto const offerSequence = ctx_.tx.getSeqValue();
+    auto const offerSequence = ctx_.tx.getSeqProxy();
 
     // This is the original rate of the offer, and is the rate at which
     // it will be placed, even if crossing offers change the amounts that
@@ -637,7 +672,8 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     // Process a cancellation request that's passed along with an offer.
     if (cancelSequence)
     {
-        auto const sleCancel = sb.peek(keylet::offer(accountID_, *cancelSequence));
+        auto const seqProxy = SeqProxy::rawSequence(*cancelSequence);
+        auto const sleCancel = sb.peek(keylet::offer(accountID_, seqProxy));
 
         // It's not an error to not find the offer to cancel: it might have
         // been consumed or removed. If it is found, however, it's an error
@@ -659,6 +695,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     }
 
     bool crossed = false;
+    bool const mptV2 = ctx_.view().rules().enabled(featureMPTokensV2);
 
     if (isTesSuccess(result))
     {
@@ -681,7 +718,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();
 
@@ -828,6 +870,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};
@@ -922,7 +978,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
 
     auto sleOffer = std::make_shared(offerIndex);
     sleOffer->setAccountID(sfAccount, accountID_);
-    sleOffer->setFieldU32(sfSequence, offerSequence);
+    sleOffer->setFieldU32(sfSequence, offerSequence.value());
     sleOffer->setFieldH256(sfBookDirectory, dir.key);
     sleOffer->setFieldAmount(sfTakerPays, saTakerPays);
     sleOffer->setFieldAmount(sfTakerGets, saTakerGets);
@@ -957,7 +1013,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     sb.insert(sleOffer);
 
     if (!bookExisted)
-        ctx_.registry.get().getOrderBookDB().addOrderBook(book);
+        ctx_.addOrderBook(book);
 
     JLOG(j_.debug()) << "final result: success";
 
@@ -967,6 +1023,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 feed43d410..32b56b909f 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -92,7 +93,8 @@ EscrowCancel::preclaim(PreclaimContext const& ctx)
 {
     if (ctx.view.rules().enabled(featureTokenEscrow))
     {
-        auto const k = keylet::escrow(ctx.tx[sfOwner], ctx.tx[sfOfferSequence]);
+        auto const seqProxy = SeqProxy::rawSequence(ctx.tx[sfOfferSequence]);
+        auto const k = keylet::escrow(ctx.tx[sfOwner], seqProxy);
         auto const slep = ctx.view.read(k);
         if (!slep)
             return tecNO_TARGET;
@@ -117,7 +119,8 @@ EscrowCancel::preclaim(PreclaimContext const& ctx)
 TER
 EscrowCancel::doApply()
 {
-    auto const k = keylet::escrow(ctx_.tx[sfOwner], ctx_.tx[sfOfferSequence]);
+    auto const seqProxy = SeqProxy::rawSequence(ctx_.tx[sfOfferSequence]);
+    auto const k = keylet::escrow(ctx_.tx[sfOwner], seqProxy);
     auto const slep = ctx_.view().peek(k);
     if (!slep)
     {
@@ -166,6 +169,12 @@ 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);
+    if (recycleReserve)
+        decreaseOwnerCountForObject(ctx_.view(), sle, slep, 1, ctx_.journal);
+
     // Transfer amount back to the owner
     if (isXRP(amount))
     {
@@ -209,7 +218,8 @@ EscrowCancel::doApply()
         }
     }
 
-    decreaseOwnerCountForObject(ctx_.view(), sle, slep, 1, ctx_.journal);
+    if (!recycleReserve)
+        decreaseOwnerCountForObject(ctx_.view(), sle, slep, 1, 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 50f2e8b859..589ad70230 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
@@ -88,8 +88,13 @@ EscrowCreate::checkExtraFeatures(PreflightContext const& ctx)
     // Only require featureMPTokensV1 when the escrow amount is an MPT and
     // fixCleanup3_2_0 is active; XRP/IOU escrows are unaffected by this gate.
     if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx[sfAmount].holds())
-        return ctx.rules.enabled(featureMPTokensV1);
-    return true;
+    {
+        if (!ctx.rules.enabled(featureMPTokensV1))
+            return false;
+    }
+
+    return (!ctx.tx.isFieldPresent(sfBytecode) && !ctx.tx.isFieldPresent(sfData)) ||
+        ctx.rules.enabled(featureSmartEscrow);
 }
 
 template 
@@ -304,11 +309,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
@@ -476,7 +481,7 @@ EscrowCreate::doApply()
 
     // Create escrow in ledger.  Note that we use the value from the
     // sequence or ticket.  For more explanation see comments in SeqProxy.h.
-    Keylet const escrowKeylet = keylet::escrow(accountID_, ctx_.tx.getSeqValue());
+    Keylet const escrowKeylet = keylet::escrow(accountID_, ctx_.tx.getSeqProxy());
     auto const slep = std::make_shared(escrowKeylet);
     (*slep)[sfAmount] = amount;
     (*slep)[sfAccount] = accountID_;
@@ -489,7 +494,7 @@ EscrowCreate::doApply()
 
     if (ctx_.view().rules().enabled(fixIncludeKeyletFields))
     {
-        (*slep)[sfSequence] = ctx_.tx.getSeqValue();
+        (*slep)[sfSequence] = ctx_.tx.getSeqProxy().value();
     }
 
     if (ctx_.view().rules().enabled(featureTokenEscrow) && !isXRP(amount))
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
index 8bc98c7aa8..59bec8ca4c 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
@@ -26,6 +26,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -64,7 +65,13 @@ checkCondition(Slice f, Slice c)
 bool
 EscrowFinish::checkExtraFeatures(PreflightContext const& ctx)
 {
-    return !ctx.tx.isFieldPresent(sfCredentialIDs) || ctx.rules.enabled(featureCredentials);
+    if (ctx.tx.isFieldPresent(sfCredentialIDs) && !ctx.rules.enabled(featureCredentials))
+        return false;
+
+    if (ctx.tx.isFieldPresent(sfGas) && !ctx.rules.enabled(featureSmartEscrow))
+        return false;
+
+    return true;
 }
 
 NotTEC
@@ -110,7 +117,7 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx)
         }
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -185,7 +192,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;
@@ -203,7 +210,8 @@ EscrowFinish::preclaim(PreclaimContext const& ctx)
 
     if (ctx.view.rules().enabled(featureTokenEscrow))
     {
-        auto const k = keylet::escrow(ctx.tx[sfOwner], ctx.tx[sfOfferSequence]);
+        auto const seqProxy = SeqProxy::rawSequence(ctx.tx[sfOfferSequence]);
+        auto const k = keylet::escrow(ctx.tx[sfOwner], seqProxy);
         auto const slep = ctx.view.read(k);
         if (!slep)
             return tecNO_TARGET;
@@ -228,7 +236,8 @@ EscrowFinish::preclaim(PreclaimContext const& ctx)
 TER
 EscrowFinish::doApply()
 {
-    auto const k = keylet::escrow(ctx_.tx[sfOwner], ctx_.tx[sfOfferSequence]);
+    auto const seqProxy = SeqProxy::rawSequence(ctx_.tx[sfOfferSequence]);
+    auto const k = keylet::escrow(ctx_.tx[sfOwner], seqProxy);
     auto const slep = ctx_.view().peek(k);
     if (!slep)
     {
@@ -340,14 +349,12 @@ EscrowFinish::doApply()
         }
     }
 
-    // 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, 1, ctx_.journal);
 
     STAmount const amount = slep->getFieldAmount(sfAmount);
@@ -399,8 +406,7 @@ EscrowFinish::doApply()
 
     ctx_.view().update(sled);
 
-    // Adjust source owner count (legacy position, pre-Sponsor)
-    if (!sponsorEnabled)
+    if (!recycleReserve)
         decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, 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 e9c153404c..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 
@@ -34,7 +36,7 @@ LoanBrokerSet::checkExtraFeatures(PreflightContext const& ctx)
 NotTEC
 LoanBrokerSet::preflight(PreflightContext const& ctx)
 {
-    using namespace Lending;
+    using namespace lending;
 
     auto const& tx = ctx.tx;
     if (auto const data = tx[~sfData];
@@ -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;
 
@@ -218,7 +234,7 @@ LoanBrokerSet::doApply()
         }
         auto const vaultPseudoID = sleVault->at(sfAccount);
         auto const vaultAsset = sleVault->at(sfAsset);
-        auto const sequence = tx.getSeqValue();
+        auto const sequence = tx.getSeqProxy();
 
         auto owner = view.peek(keylet::account(accountID_));
         if (!owner)
@@ -253,7 +269,7 @@ LoanBrokerSet::doApply()
             return ter;
 
         // Initialize data fields:
-        broker->at(sfSequence) = sequence;
+        broker->at(sfSequence) = sequence.value();
         broker->at(sfVaultID) = vaultID;
         broker->at(sfOwner) = accountID_;
         broker->at(sfAccount) = pseudoId;
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 a0aa948876..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;
@@ -127,23 +131,6 @@ LoanManage::preclaim(PreclaimContext const& ctx)
     return tesSUCCESS;
 }
 
-static Number
-owedToVault(SLE::ref loanSle)
-{
-    // Spec section 3.2.3.2, defines the default amount as
-    //
-    // DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding)
-    //
-    // Loan.InterestOutstanding is not stored directly on ledger.
-    // It is computed as
-    //
-    // Loan.TotalValueOutstanding - Loan.PrincipalOutstanding -
-    //      Loan.ManagementFeeOutstanding
-    //
-    // Add that to the original formula, and you get this:
-    return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
-}
-
 TER
 LoanManage::defaultLoan(
     ApplyView& view,
@@ -158,7 +145,7 @@ LoanManage::defaultLoan(
     std::int32_t const loanScale = loanSle->at(sfLoanScale);
     auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal);
 
-    Number const totalDefaultAmount = owedToVault(loanSle);
+    Number const totalDefaultAmount = loanVaultExposure(vaultSle, loanSle);
 
     // Apply the First-Loss Capital to the Default Amount
     TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
@@ -304,7 +291,15 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
-    Number const lossUnrealized = owedToVault(loanSle);
+    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
     // errors during the accounting by rounding some of the values to that
@@ -318,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);
 
@@ -353,7 +350,7 @@ LoanManage::unimpairLoan(
 
     // Update the Vault object(clear "paper loss")
     auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
-    Number const lossReversed = owedToVault(loanSle);
+    Number const lossReversed = loanVaultExposure(vaultSle, loanSle);
     if (vaultLossUnrealizedProxy < lossReversed)
     {
         // LCOV_EXCL_START
@@ -368,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 54ee85b186..624c4d0a84 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,43 @@
 
 namespace xrpl {
 
+namespace {
+// Returns true if the transaction's payment amount is malformed. A loan
+// payment must be strictly positive: zero would move nothing, and a negative
+// amount is not a payment at all.
+bool
+isPaymentAmountInvalid(STAmount const& amount)
+{
+    return amount <= beast::kZero;
+}
+
+// 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)
 {
@@ -51,7 +90,7 @@ LoanPay::preflight(PreflightContext const& ctx)
     if (ctx.tx[sfLoanID] == beast::kZero)
         return temINVALID;
 
-    if (ctx.tx[sfAmount] <= beast::kZero)
+    if (isPaymentAmountInvalid(ctx.tx[sfAmount]))
         return temBAD_AMOUNT;
 
     // The loan payment flags are all mutually exclusive. If more than one is
@@ -73,10 +112,19 @@ LoanPay::preflight(PreflightContext const& ctx)
 XRPAmount
 LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
 {
-    using namespace Lending;
+    auto fixEnabled313 = view.rules().enabled(fixCleanup3_1_3);
+    auto fixEnabled340 = view.rules().enabled(fixCleanup3_4_0);
+
+    using namespace lending;
 
     auto const normalCost = Transactor::calculateBaseFee(view, tx);
 
+    if (fixEnabled340 && isPaymentAmountInvalid(tx[sfAmount]))
+    {
+        // Let preflight worry about the error for this
+        return normalCost;
+    }
+
     if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment))
     {
         // The loan will be making one set of calculations for one full or late
@@ -103,10 +151,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;
     }
 
@@ -146,8 +197,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
     static constexpr std::int64_t kMaxFeeIncrements =
         kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement;
 
-    if (view.rules().enabled(fixCleanup3_1_3) &&
-        amount >= regularPayment * kLoanMaximumPaymentsPerTransaction)
+    if (fixEnabled313 && amount >= regularPayment * kLoanMaximumPaymentsPerTransaction)
     {
         // The payment handler will never process more than
         // loanMaximumPaymentsPerTransaction payments (including overpayments),
@@ -420,10 +470,13 @@ LoanPay::doApply()
         // LCOV_EXCL_STOP
     }
 
+    auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
+
     JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
                      << ", interest paid: " << paymentParts->interestPaid
                      << ", fee paid: " << paymentParts->feePaid
-                     << ", value change: " << paymentParts->valueChange;
+                     << ", assets total delta: " << assetsTotalDelta
+                     << ", debt total delta: " << debtTotalDelta;
 
     //------------------------------------------------------
     // LoanBroker object state changes
@@ -439,13 +492,6 @@ LoanPay::doApply()
         !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
         "xrpl::LoanPay::doApply",
         "rounding does nothing for integral asset");
-    // Account for value changes when reducing the broker's debt:
-    // - Positive value change (from full/late/overpayments): Subtract from the
-    //   amount credited toward debt to avoid over-reducing the debt.
-    // - Negative value change (from full/overpayments): Add to the amount
-    //   credited toward debt,effectively increasing the debt reduction.
-    auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange;
-
     auto const totalPaidToBroker = paymentParts->feePaid;
 
     XRPL_ASSERT_PARTS(
@@ -455,16 +501,16 @@ LoanPay::doApply()
         "payments add up");
 
     // Decrease LoanBroker Debt by the amount paid, add the Loan value change
-    // (which might be negative). totalPaidToVaultForDebt may be negative,
-    // increasing the debt
+    // (which might be negative). debtTotalDelta may be negative, increasing the
+    // debt
     XRPL_ASSERT_PARTS(
-        isRounded(asset, totalPaidToVaultForDebt, loanScale),
+        isRounded(asset, debtTotalDelta, loanScale),
         "xrpl::LoanPay::doApply",
-        "totalPaidToVaultForDebt rounding good");
+        "debtTotalDelta rounding good");
     // Despite our best efforts, it's possible for rounding errors to accumulate
     // in the loan broker's debt total. This is because the broker may have more
     // than one loan with significantly different scales.
-    adjustImpreciseNumber(debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale);
+    adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
 
     //------------------------------------------------------
     // Vault object state changes
@@ -490,7 +536,7 @@ LoanPay::doApply()
 #endif
 
     assetsAvailableProxy += totalPaidToVaultRounded;
-    assetsTotalProxy += paymentParts->valueChange;
+    assetsTotalProxy += assetsTotalDelta;
 
     XRPL_ASSERT_PARTS(
         *assetsAvailableProxy <= *assetsTotalProxy,
@@ -543,11 +589,11 @@ LoanPay::doApply()
         return tecPRECISION_LOSS;
         // LCOV_EXCL_STOP
     }
-    if (paymentParts->valueChange != beast::kZero && assetsTotalAfter == assetsTotalBefore)
+    if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore)
     {
-        // Non-zero valueChange with an unchanged assetsTotal indicates that the
-        // actual value change rounded to zero. That should be impossible, but I
-        // can't rule it out for extreme edge cases, so fail gracefully if it
+        // Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that
+        // the actual value change rounded to zero. That should be impossible, but
+        // I can't rule it out for extreme edge cases, so fail gracefully if it
         // happens.
         //
         // LCOV_EXCL_START
@@ -555,20 +601,21 @@ LoanPay::doApply()
             << "LoanPay: Vault assets expected change, but unchanged after rounding: "  //
             << "Before: " << assetsTotalBefore                                          //
             << ", After: " << assetsTotalAfter                                          //
-            << ", ValueChange: " << paymentParts->valueChange;
+            << ", AssetsTotalDelta: " << assetsTotalDelta;
         return tecPRECISION_LOSS;
         // LCOV_EXCL_STOP
     }
-    if (paymentParts->valueChange == beast::kZero && assetsTotalAfter != assetsTotalBefore)
+    if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore)
     {
-        // A change in assetsTotal when there was no valueChange indicates that
-        // something really weird happened. That should be flat out impossible.
+        // A change in assetsTotal when there was no assetsTotalDelta indicates
+        // that something really weird happened. That should be flat out
+        // impossible.
         //
         // LCOV_EXCL_START
         JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: "  //
                          << "Before: " << assetsTotalBefore                                //
                          << ", After: " << assetsTotalAfter                                //
-                         << ", ValueChange: " << paymentParts->valueChange;
+                         << ", AssetsTotalDelta: " << assetsTotalDelta;
         return tecINTERNAL;
         // LCOV_EXCL_STOP
     }
@@ -584,36 +631,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;
@@ -637,8 +668,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(
@@ -667,33 +701,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.
         //
@@ -816,7 +830,7 @@ LoanPay::doApply()
     XRPL_ASSERT_PARTS(
         vaultBalanceAfter >= beast::kZero && brokerBalanceAfter >= beast::kZero,
         "xrpl::LoanPay::doApply",
-        "positive vault and broker balances");
+        "non-negative vault and broker balances");
     XRPL_ASSERT_PARTS(
         vaultBalanceAfter >= vaultBalanceBefore,
         "xrpl::LoanPay::doApply",
diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
index 694d01c69f..da2eb609d8 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 
@@ -22,6 +23,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -38,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)
 {
@@ -53,7 +61,7 @@ LoanSet::getFlagsMask(PreflightContext const& ctx)
 NotTEC
 LoanSet::preflight(PreflightContext const& ctx)
 {
-    using namespace Lending;
+    using namespace lending;
 
     auto const& tx = ctx.tx;
 
@@ -224,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
@@ -237,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,
@@ -309,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;
+            }
+        }
+    }
+
+    // Instant interest recognition 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 instant-recognition 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;
@@ -333,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))
@@ -439,12 +494,14 @@ LoanSet::doApply()
         principalRequested,
         properties.loanState.managementFeeDue);
 
-    auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum);
     XRPL_ASSERT_PARTS(
-        vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy,
+        *vaultSle->at(sfAssetsMaximum) == 0 ||
+            getVaultVersion(vaultSle) == VaultVersion::CashBasis ||
+            *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
         "xrpl::LoanSet::doApply",
-        "Vault is below maximum limit");
-    if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy)
+        "instant-recognition vault is below maximum limit");
+
+    if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
     {
         JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault";
         return tecLIMIT_EXCEEDED;
@@ -490,8 +547,9 @@ LoanSet::doApply()
 
     auto const loanAssetsToBorrower = principalRequested - originationFee;
 
-    auto const newDebtDelta = principalRequested + state.interestDue;
-    auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta;
+    auto const [assetsTotalDelta, debtTotalDelta] =
+        loanOriginationDeltas(vaultSle, principalRequested, state.interestDue);
+    auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta;
     if (auto const debtMaximum = brokerSle->at(sfDebtMaximum);
         debtMaximum != 0 && debtMaximum < newDebtTotal)
     {
@@ -593,7 +651,8 @@ LoanSet::doApply()
     auto loanSequenceProxy = brokerSle->at(sfLoanSequence);
 
     // Create the loan
-    auto loan = std::make_shared(keylet::loan(brokerID, *loanSequenceProxy));
+    auto loan =
+        std::make_shared(keylet::loan(brokerID, SeqProxy::rawSequence(*loanSequenceProxy)));
 
     // Prevent copy/paste errors
     auto setLoanField = [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) {
@@ -634,7 +693,7 @@ LoanSet::doApply()
 
     // Update the balances in the vault
     vaultAvailableProxy -= principalRequested;
-    vaultTotalProxy += state.interestDue;
+    vaultTotalProxy += assetsTotalDelta;
     XRPL_ASSERT_PARTS(
         *vaultAvailableProxy <= *vaultTotalProxy,
         "xrpl::LoanSet::doApply",
@@ -642,7 +701,7 @@ LoanSet::doApply()
     view.update(vaultSle);
 
     // Update the balances in the loan broker
-    adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale);
+    adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);
     adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_);
     loanSequenceProxy += 1;
     // The sequence should be extremely unlikely to roll over, but fail if it
diff --git a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
index 41bb051768..0cf7af1463 100644
--- a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
+++ b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
@@ -8,12 +8,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +48,13 @@ NFTokenAcceptOffer::preflight(PreflightContext const& ctx)
 
         if (*bf <= beast::kZero)
             return temMALFORMED;
+
+        if (ctx.rules.enabled(fixCleanup3_4_0))
+        {
+            // We don't allow a non-native currency to use the currency code XRP.
+            if (badAsset() == bf->asset())
+                return temBAD_CURRENCY;
+        }
     }
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
index d3e2af86ef..c11c0ed916 100644
--- a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
+++ b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp
@@ -103,9 +103,16 @@ DepositPreauth::preclaim(PreclaimContext const& ctx)
     {
         // Verify that the Authorize account is present in the ledger.
         AccountID const auth{ctx.tx[sfAuthorize]};
-        if (!ctx.view.exists(keylet::account(auth)))
+        auto const sleAuth = ctx.view.read(keylet::account(auth));
+        if (!sleAuth)
             return tecNO_TARGET;
 
+        if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(sleAuth))
+        {
+            JLOG(ctx.j.debug()) << "Authorized account is a pseudo-account.";
+            return tecPSEUDO_ACCOUNT;
+        }
+
         // Verify that the Preauth entry they asked to add is not already
         // in the ledger.
         if (ctx.view.exists(keylet::depositPreauth(account, auth)))
diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp
index 17c96a1919..d2da173345 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;
@@ -339,17 +339,36 @@ Payment::checkGranularSemantics(
             bool const accountIsHolder =
                 accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero;
 
+            bool const mayIssue =
+                heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero;
+
             // PaymentMint requires the destination to be the holder and the account to be the
             // issuer. destLimit > 0: destination is willing to hold account's IOUs (account is the
             // issuer). !accountIsHolder: DirectStepI will issue, not redeem.
-            if (heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero &&
-                !accountIsHolder)
+            if (mayIssue && !accountIsHolder)
                 return tesSUCCESS;
 
             // PaymentBurn requires the source account to be the holder and the destination to be
             // the issuer. accountIsHolder: DirectStepI will redeem, not issue.
             if (heldGranularPermissions.contains(PaymentBurn) && accountIsHolder)
-                return tesSUCCESS;
+            {
+                if (view.rules().enabled(fixCleanup3_4_0))
+                {
+                    // Redeeming stops at the balance held; beyond that the payment engine
+                    // crosses zero and issues the account's own IOUs, which is a mint. So with
+                    // only PaymentBurn we must check the amount against the balance held. The
+                    // granular template forbids sfPaths, tfPartialPayment and a cross-asset
+                    // sfSendMax, so this is a single direct step, sfAmount is what the
+                    // trustline is debited.
+                    STAmount const held = accountIsLow ? rawBalance : -rawBalance;
+                    if (dstAmount <= held || mayIssue)
+                        return tesSUCCESS;
+                }
+                else
+                {
+                    return tesSUCCESS;
+                }
+            }
 
             return terNO_DELEGATE_PERMISSION;
         });
@@ -458,11 +477,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 +520,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/payment_channel/PaymentChannelCreate.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
index b17430948a..26d8ff4f04 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
@@ -169,7 +169,7 @@ PaymentChannelCreate::doApply()
     //
     // Note that we use the value from the sequence or ticket as the
     // payChan sequence.  For more explanation see comments in SeqProxy.h.
-    Keylet const payChanKeylet = keylet::payChannel(account, dst, ctx_.tx.getSeqValue());
+    Keylet const payChanKeylet = keylet::payChannel(account, dst, ctx_.tx.getSeqProxy());
     auto const slep = std::make_shared(payChanKeylet);
 
     // Funds held in this channel
@@ -185,7 +185,7 @@ PaymentChannelCreate::doApply()
     (*slep)[~sfDestinationTag] = ctx_.tx[~sfDestinationTag];
     if (ctx_.view().rules().enabled(fixIncludeKeyletFields))
     {
-        (*slep)[sfSequence] = ctx_.tx.getSeqValue();
+        (*slep)[sfSequence] = ctx_.tx.getSeqProxy().value();
     }
 
     ctx_.view().insert(slep);
diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
index 61ebdcf9c7..36c324eb80 100644
--- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
+++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -114,12 +115,13 @@ PermissionedDomainSet::doApply()
             return tecINSUFFICIENT_RESERVE;
 
         bool const fixEnabled = view().rules().enabled(fixCleanup3_1_3);
-        auto const seq = fixEnabled ? ctx_.tx.getSeqValue() : ctx_.tx.getFieldU32(sfSequence);
+        auto const seq = fixEnabled ? ctx_.tx.getSeqProxy()
+                                    : SeqProxy::rawSequence(ctx_.tx.getFieldU32(sfSequence));
         Keylet const pdKeylet = keylet::permissionedDomain(accountID_, seq);
         auto slePd = std::make_shared(pdKeylet);
 
         slePd->setAccountID(sfOwner, accountID_);
-        slePd->setFieldU32(sfSequence, seq);
+        slePd->setFieldU32(sfSequence, seq.value());
         slePd->peekFieldArray(sfAcceptedCredentials) = std::move(sortedLE);
         auto const page =
             view().dirInsert(keylet::ownerDir(accountID_), pdKeylet, describeOwnerDir(accountID_));
diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp
index 2b6ab8cf15..e717c626e4 100644
--- a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp
+++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp
@@ -3,13 +3,16 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -17,36 +20,62 @@
 #include 
 #include 
 
+#include 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
+// Compute the resulting RemainingOwnerCount using signed 64-bit arithmetic to
+// avoid unsigned wraparound. A missing SLE (object creation) or absent field
+// counts as zero. Callers handle the out-of-range results: a negative value is
+// clamped to zero (field absent) and overflow is rejected in preclaim.
+static std::int64_t
+totalRemainingOwnerCount(
+    SLE::const_ref sponsorshipSle,
+    std::optional const& remainingOwnerCountDelta)
+{
+    std::uint32_t const currentCount =
+        sponsorshipSle ? (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0u) : 0u;
+    return static_cast(currentCount) + remainingOwnerCountDelta.value_or(0);
+}
+
 static bool
 hasSponsorshipBudget(
     SLE::const_ref sponsorshipSle,
-    std::optional const& feeAmount,
-    std::optional const& remainingOwnerCount)
+    std::optional const& feeAmountDelta,
+    std::optional const& remainingOwnerCountDelta)
 {
-    // A field the transaction omits keeps whatever the existing object holds,
+    // sfFeeAmountDelta and sfRemainingOwnerCountDelta must be non-negative when creating a new
+    // Sponsorship object.
+    if (!sponsorshipSle)
+    {
+        if (feeAmountDelta.has_value() && *feeAmountDelta <= beast::kZero)
+            return false;
+
+        if (remainingOwnerCountDelta.has_value() && *remainingOwnerCountDelta <= 0)
+            return false;
+    }
+    // If the transaction omits a field, it keeps whatever the existing object holds,
     // so fall back to the current SLE value when the tx does not set it.
-    bool const hasFeeAmount = feeAmount
-        ? *feeAmount > beast::kZero
-        : sponsorshipSle && (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) > beast::kZero;
+    STAmount const currentFee =
+        sponsorshipSle ? (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) : STAmount{0};
+    STAmount const newFee = currentFee + feeAmountDelta.value_or(STAmount{0});
 
-    bool const hasRemainingOwnerCount = remainingOwnerCount
-        ? *remainingOwnerCount > 0
-        : sponsorshipSle && (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0) > 0;
+    std::int64_t const newCount =
+        totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta);
 
-    return hasFeeAmount || hasRemainingOwnerCount;
+    return newFee > beast::kZero || newCount > 0;
 }
 
 TxConsequences
 SponsorshipSet::makeTxConsequences(PreflightContext const& ctx)
 {
-    auto const feeAmount = ctx.tx[~sfFeeAmount];
-    return TxConsequences{ctx.tx, feeAmount.has_value() ? feeAmount->xrp() : beast::kZero};
+    auto const feeAmount = ctx.tx[~sfFeeAmountDelta];
+    auto const feeAmountDelta = std::max(STAmount{0}, feeAmount.value_or(STAmount{0}));
+    return TxConsequences{ctx.tx, feeAmountDelta.xrp()};
 }
 
 std::uint32_t
@@ -90,8 +119,8 @@ SponsorshipSet::preflight(PreflightContext const& ctx)
             return temINVALID_FLAG;
 
         // Transactions deleting `Sponsorship` cannot include modification fields.
-        if (ctx.tx.isFieldPresent(sfFeeAmount) || ctx.tx.isFieldPresent(sfRemainingOwnerCount) ||
-            ctx.tx.isFieldPresent(sfMaxFee))
+        if (ctx.tx.isFieldPresent(sfFeeAmountDelta) ||
+            ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) || ctx.tx.isFieldPresent(sfMaxFee))
             return temMALFORMED;
     }
     else
@@ -101,27 +130,26 @@ SponsorshipSet::preflight(PreflightContext const& ctx)
         if (account != sponsorID)
             return temMALFORMED;
 
-        // FeeAmount and MaxFee must be non-negative XRP amounts when present.
-        auto const checkOptionalAmountField = [&](SField const& field) -> NotTEC {
-            if (!ctx.tx.isFieldPresent(field))
-                return tesSUCCESS;
+        // FeeAmountDelta must be a non-zero XRP amount when present.
+        if (auto const feeAmt = ctx.tx[~sfFeeAmountDelta];
+            feeAmt && (!isXRP(*feeAmt) || *feeAmt == beast::kZero))
+            return temBAD_AMOUNT;
 
-            auto const amount = ctx.tx.getFieldAmount(field);
+        // MaxFee must be a non-negative XRP amount when present.
+        if (auto const maxFee = ctx.tx[~sfMaxFee];
+            maxFee && (!isXRP(*maxFee) || *maxFee < beast::kZero))
+            return temBAD_AMOUNT;
 
-            if (!isXRP(amount))
-                return temBAD_AMOUNT;
+        // RemainingOwnerCountDelta must be a non-zero integer when present.
+        if (auto const remainingOwnerCountDelta = ctx.tx[~sfRemainingOwnerCountDelta];
+            remainingOwnerCountDelta && *remainingOwnerCountDelta == 0)
+            return temINVALID;
 
-            if (amount.xrp() < beast::kZero)
-                return temBAD_AMOUNT;
-
-            return tesSUCCESS;
-        };
-
-        if (auto const ret = checkOptionalAmountField(sfFeeAmount); !isTesSuccess(ret))
-            return ret;
-
-        if (auto const ret = checkOptionalAmountField(sfMaxFee); !isTesSuccess(ret))
-            return ret;
+        // nothing specified in the tx
+        if (!ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) &&
+            !ctx.tx.isFieldPresent(sfFeeAmountDelta) && !ctx.tx.isFieldPresent(sfMaxFee) &&
+            ((ctx.tx.getFlags() & tfUniversalMask) == 0))
+            return temREDUNDANT;
     }
 
     return tesSUCCESS;
@@ -146,7 +174,7 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx)
 
     // Pseudo-accounts cannot participate in sponsorship.
     if (isPseudoAccount(sponsorAccSle) || isPseudoAccount(sponseeSle))
-        return tecNO_PERMISSION;
+        return tecPSEUDO_ACCOUNT;
 
     auto const sponsorshipSle = ctx.view.read(keylet::sponsorship(sponsorID, sponseeID));
 
@@ -154,12 +182,21 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx)
     if (ctx.tx.isFlag(tfDeleteObject) && !sponsorshipSle)
         return tecNO_ENTRY;
 
-    // Reject creating or updating a Sponsorship that would be left with no
-    // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount).
-    // Such an object is unusable yet still consumes the sponsor's reserve.
-    if (!ctx.tx.isFlag(tfDeleteObject) &&
-        !hasSponsorshipBudget(sponsorshipSle, ctx.tx[~sfFeeAmount], ctx.tx[~sfRemainingOwnerCount]))
-        return tecNO_PERMISSION;
+    if (!ctx.tx.isFlag(tfDeleteObject))
+    {
+        // Reject if applying the delta would overflow uint32_t. A negative delta
+        // that underflows is clamped to zero (field absent) rather than erroring.
+        if (totalRemainingOwnerCount(sponsorshipSle, ctx.tx[~sfRemainingOwnerCountDelta]) >
+            static_cast(std::numeric_limits::max()))
+            return tecLIMIT_EXCEEDED;
+
+        // Reject creating or updating a Sponsorship that would be left with no
+        // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount).
+        // Such an object is unusable yet still consumes the sponsor's reserve.
+        if (!hasSponsorshipBudget(
+                sponsorshipSle, ctx.tx[~sfFeeAmountDelta], ctx.tx[~sfRemainingOwnerCountDelta]))
+            return tecNO_PERMISSION;
+    }
 
     return tesSUCCESS;
 }
@@ -208,6 +245,91 @@ deleteSponsorship(ApplyView& view, SLE::ref sle, beast::Journal j)
     return tesSUCCESS;
 }
 
+TER
+SponsorshipSet::createSponsorship(
+    Keylet const& sponsorshipKeylet,
+    AccountID const& sponsorID,
+    AccountID const& sponseeID,
+    SLE::ref sponsorAccSle,
+    SLE::ref reserveSponsorAccSle)
+{
+    auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta];
+    auto const maxFee = ctx_.tx[~sfMaxFee];
+    auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta];
+
+    bool const hasPositiveFeeAmount = feeAmountDelta.has_value() && *feeAmountDelta > beast::kZero;
+
+    // Create a new Sponsorship object between the sponsor and sponsee.
+    auto newSle = std::make_shared(sponsorshipKeylet);
+    STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
+    // sfFeeAmountDelta must be positive if the sponsorship object doesn't exist. This is
+    // checked in preclaim.
+    XRPL_ASSERT(
+        !feeAmountDelta.has_value() || *feeAmountDelta > beast::kZero,
+        "xrpl::SponsorshipSet::doApply : new sponsorship has positive fee amount");
+
+    (*newSle)[sfOwner] = sponsorID;
+    (*newSle)[sfSponsee] = sponseeID;
+    if (feeAmountDelta && feeAmountDelta->xrp() > sponsorBalanceAfterFee.xrp())
+        return tecUNFUNDED;
+
+    if (hasPositiveFeeAmount)
+        sponsorBalanceAfterFee -= *feeAmountDelta;
+
+    if (auto const ret = checkReserve(
+            ctx_.getApplyViewContext(),
+            sponsorAccSle,
+            sponsorBalanceAfterFee.xrp(),
+            reserveSponsorAccSle,
+            {.ownerCountDelta = 1},
+            ctx_.journal,
+            tecUNFUNDED);
+        !isTesSuccess(ret))
+    {
+        return ret;
+    }
+
+    if (hasPositiveFeeAmount)
+    {
+        // New object: FeeAmount starts absent, so deduct and record the full amount
+        (*newSle)[sfFeeAmount] = *feeAmountDelta;
+        (*sponsorAccSle)[sfBalance] -= *feeAmountDelta;
+    }
+
+    if (maxFee && *maxFee > beast::kZero)
+        (*newSle)[sfMaxFee] = *maxFee;
+    if (remainingOwnerCountDelta && *remainingOwnerCountDelta > 0)
+        (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCountDelta;
+
+    std::uint32_t flags = 0;
+    if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee))
+        flags |= lsfSponsorshipRequireSignForFee;
+
+    if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve))
+        flags |= lsfSponsorshipRequireSignForReserve;
+
+    (*newSle)[sfFlags] = flags;
+
+    auto const sponsorPage = view().dirInsert(
+        keylet::ownerDir(sponsorID), sponsorshipKeylet, describeOwnerDir(sponsorID));
+    if (!sponsorPage)
+        return tecDIR_FULL;  // LCOV_EXCL_LINE
+    (*newSle)[sfOwnerNode] = *sponsorPage;
+
+    auto const sponseePage = view().dirInsert(
+        keylet::ownerDir(sponseeID), sponsorshipKeylet, describeOwnerDir(sponseeID));
+    if (!sponseePage)
+        return tecDIR_FULL;  // LCOV_EXCL_LINE
+    (*newSle)[sfSponseeNode] = *sponseePage;
+
+    // NOLINTNEXTLINE(readability-suspicious-call-argument)
+    increaseOwnerCount(view(), sponsorAccSle, reserveSponsorAccSle, 1, ctx_.journal);
+    addSponsorToLedgerEntry(newSle, reserveSponsorAccSle);
+
+    ctx_.view().insert(newSle);
+    return tesSUCCESS;
+}
+
 TER
 SponsorshipSet::doApply()
 {
@@ -224,8 +346,8 @@ SponsorshipSet::doApply()
     if (!ctx_.view().exists(keylet::account(sponseeID)))
         return tecINTERNAL;  // LCOV_EXCL_LINE
 
-    auto const sponsorKeylet = keylet::sponsorship(sponsorID, sponseeID);
-    auto const sponsorshipSle = ctx_.view().peek(sponsorKeylet);
+    auto const sponsorshipKeylet = keylet::sponsorship(sponsorID, sponseeID);
+    auto const sponsorshipSle = ctx_.view().peek(sponsorshipKeylet);
 
     if (ctx_.tx.isFlag(tfDeleteObject))
     {
@@ -235,11 +357,9 @@ SponsorshipSet::doApply()
         return deleteSponsorship(ctx_.view(), sponsorshipSle, ctx_.journal);
     }
 
-    auto const feeAmount = ctx_.tx[~sfFeeAmount];
+    auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta];
     auto const maxFee = ctx_.tx[~sfMaxFee];
-    auto const remainingOwnerCount = ctx_.tx[~sfRemainingOwnerCount];
-
-    bool const hasPositiveFeeAmount = feeAmount.has_value() && *feeAmount > beast::kZero;
+    auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta];
 
     auto reserveSponsorAccSle = getTxReserveSponsor(ctx_.getApplyViewContext());
     if (!reserveSponsorAccSle)
@@ -247,24 +367,33 @@ SponsorshipSet::doApply()
 
     if (!sponsorshipSle)
     {
-        // Create a new Sponsorship object between the sponsor and sponsee.
-        auto newSle = std::make_shared(sponsorKeylet);
+        return createSponsorship(
+            sponsorshipKeylet, sponsorID, sponseeID, sponsorAccSle, *reserveSponsorAccSle);
+    }
 
-        (*newSle)[sfOwner] = sponsorID;
-        (*newSle)[sfSponsee] = sponseeID;
-        if (feeAmount && (*feeAmount).xrp() > (*sponsorAccSle)[sfBalance])
+    // Update the existing Sponsorship object.
+    if (feeAmountDelta)
+    {
+        auto actualDelta = feeAmountDelta.value();
+        auto const currentFee = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0});
+
+        // Clamp negative delta to avoid underflow.
+        if (actualDelta < beast::kZero && -actualDelta > currentFee)
+            actualDelta = -currentFee;
+        // Reject if the sponsor cannot afford the (positive) delta.
+        if (actualDelta > beast::kZero && actualDelta > (*sponsorAccSle)[sfBalance])
             return tecUNFUNDED;
 
-        STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
-        if (hasPositiveFeeAmount)
-            sponsorBalanceAfterFee -= *feeAmount;
+        // Move the FeeAmount delta between the sponsor balance and Sponsorship
+        // object.
+        (*sponsorAccSle)[sfBalance] -= actualDelta;
 
         if (auto const ret = checkReserve(
                 ctx_.getApplyViewContext(),
                 sponsorAccSle,
-                sponsorBalanceAfterFee.xrp(),
+                (*sponsorAccSle)[sfBalance]->xrp(),
                 *reserveSponsorAccSle,
-                {.ownerCountDelta = 1},
+                {},
                 ctx_.journal,
                 tecUNFUNDED);
             !isTesSuccess(ret))
@@ -272,87 +401,19 @@ SponsorshipSet::doApply()
             return ret;
         }
 
-        if (hasPositiveFeeAmount)
+        STAmount const newFee = currentFee + actualDelta;
+        // checked in preclaim
+        XRPL_ASSERT(
+            newFee >= beast::kZero, "xrpl::SponsorshipSet::doApply : new fee is non-negative");
+        if (newFee == beast::kZero)
         {
-            // New object: FeeAmount starts absent, so deduct and record the full amount
-            (*newSle)[sfFeeAmount] = *feeAmount;
-            (*sponsorAccSle)[sfBalance] -= *feeAmount;
+            sponsorshipSle->makeFieldAbsent(sfFeeAmount);
         }
-
-        if (maxFee && *maxFee > beast::kZero)
-            (*newSle)[sfMaxFee] = *maxFee;
-        if (remainingOwnerCount && *remainingOwnerCount > 0)
-            (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCount;
-
-        std::uint32_t flags = 0;
-        if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee))
-            flags |= lsfSponsorshipRequireSignForFee;
-
-        if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve))
-            flags |= lsfSponsorshipRequireSignForReserve;
-
-        (*newSle)[sfFlags] = flags;
-
-        auto const sponsorPage = view().dirInsert(
-            keylet::ownerDir(sponsorID), sponsorKeylet, describeOwnerDir(sponsorID));
-        if (!sponsorPage)
-            return tecDIR_FULL;  // LCOV_EXCL_LINE
-        (*newSle)[sfOwnerNode] = *sponsorPage;
-
-        auto const sponseePage = view().dirInsert(
-            keylet::ownerDir(sponseeID), sponsorKeylet, describeOwnerDir(sponseeID));
-        if (!sponseePage)
-            return tecDIR_FULL;  // LCOV_EXCL_LINE
-        (*newSle)[sfSponseeNode] = *sponseePage;
-
-        // NOLINTNEXTLINE(readability-suspicious-call-argument)
-        increaseOwnerCount(view(), sponsorAccSle, *reserveSponsorAccSle, 1, ctx_.journal);
-        addSponsorToLedgerEntry(newSle, *reserveSponsorAccSle);
-
-        ctx_.view().insert(newSle);
-        return tesSUCCESS;
-    }
-
-    // Update the existing Sponsorship object.
-    if (feeAmount)
-    {
-        auto const currentFeeAmount = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0});
-        auto const feeAmountDelta = XRPAmount(*feeAmount - currentFeeAmount);
-
-        if (feeAmountDelta > beast::kZero && feeAmountDelta > (*sponsorAccSle)[sfBalance])
-            return tecUNFUNDED;
-
-        // Move the FeeAmount delta between the sponsor balance and Sponsorship
-        // object.
-        if (feeAmountDelta != beast::kZero)
+        else
         {
-            STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
-            sponsorBalanceAfterFee -= feeAmountDelta;
-
-            if (auto const ret = checkReserve(
-                    ctx_.getApplyViewContext(),
-                    sponsorAccSle,
-                    sponsorBalanceAfterFee.xrp(),
-                    *reserveSponsorAccSle,
-                    {},
-                    ctx_.journal,
-                    tecUNFUNDED);
-                !isTesSuccess(ret))
-            {
-                return ret;
-            }
-
-            (*sponsorAccSle)[sfBalance] -= feeAmountDelta;
-            if (*feeAmount == beast::kZero)
-            {
-                (*sponsorshipSle).makeFieldAbsent(sfFeeAmount);
-            }
-            else
-            {
-                (*sponsorshipSle).setFieldAmount(sfFeeAmount, *feeAmount);
-            }
-            ctx_.view().update(sponsorAccSle);
+            (*sponsorshipSle)[sfFeeAmount] = newFee;
         }
+        ctx_.view().update(sponsorAccSle);
     }
 
     if (maxFee)
@@ -367,15 +428,21 @@ SponsorshipSet::doApply()
         }
     }
 
-    if (remainingOwnerCount)
+    if (remainingOwnerCountDelta)
     {
-        if (*remainingOwnerCount == 0)
+        std::int64_t const newCount =
+            totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta);
+        // Overflow is rejected in preclaim; underflow clamps to zero (field absent).
+        XRPL_ASSERT(
+            newCount <= static_cast(std::numeric_limits::max()),
+            "xrpl::SponsorshipSet::doApply : RemainingOwnerCount does not overflow");
+        if (newCount <= 0)
         {
             sponsorshipSle->makeFieldAbsent(sfRemainingOwnerCount);
         }
         else
         {
-            sponsorshipSle->at(sfRemainingOwnerCount) = *remainingOwnerCount;
+            sponsorshipSle->at(sfRemainingOwnerCount) = static_cast(newCount);
         }
     }
 
diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
index 0e036649fd..642a415be7 100644
--- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
+++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
@@ -2,12 +2,14 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -205,6 +207,13 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx)
         return temMALFORMED;
     }
 
+    if (auto const objectID = ctx.tx[~sfObjectID];
+        ctx.rules.enabled(fixCleanup3_5_0) && objectID && *objectID == beast::kZero)
+    {
+        JLOG(ctx.j.debug()) << "preflight: sfObjectID must not be zero";
+        return temMALFORMED;
+    }
+
     return tesSUCCESS;
 }
 
@@ -412,9 +421,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/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp
index ccb113e07b..dcd06453aa 100644
--- a/src/libxrpl/tx/transactors/system/Batch.cpp
+++ b/src/libxrpl/tx/transactors/system/Batch.cpp
@@ -73,14 +73,23 @@ Batch::calculateBaseFeeImpl(ReadView const& view, STTx const& tx)
     for (auto const& stx : tx.getBatchTransactions())
     {
         auto const fee = xrpl::calculateBaseFee(view, *stx);
-        // LCOV_EXCL_START
-        if (txnFees > maxAmount - fee)
+        if (!fee)
         {
+            JLOG(debugLog().error())
+                << "BatchTrace: base fee of inner transaction " << stx->getTransactionID()
+                << " could not be computed: " << transToken(fee.error());
+            return std::nullopt;
+        }
+
+        // LCOV_EXCL_START
+        if (txnFees > maxAmount - *fee)
+        {
+            UNREACHABLE("XRPAmount overflow in txnFees calculation");
             JLOG(debugLog().error()) << "BatchTrace: XRPAmount overflow in txnFees calculation.";
             return std::nullopt;
         }
         // LCOV_EXCL_STOP
-        txnFees += fee;
+        txnFees += *fee;
     }
 
     // Calculate the Signers/BatchSigners Fees
diff --git a/src/libxrpl/tx/transactors/system/Change.cpp b/src/libxrpl/tx/transactors/system/Change.cpp
index f27855a5c8..0d50b80af4 100644
--- a/src/libxrpl/tx/transactors/system/Change.cpp
+++ b/src/libxrpl/tx/transactors/system/Change.cpp
@@ -123,6 +123,12 @@ Change::preclaim(PreclaimContext const& ctx)
                     ctx.tx.isFieldPresent(sfReserveIncrementDrops))
                     return temDISABLED;
             }
+            // The ttFEE transaction format defines these fields as optional,
+            // but they are unconditionally forbidden until FeeVoteImpl is
+            // updated to populate them (SmartEscrow behavioral port).
+            if (ctx.tx.isFieldPresent(sfGasLimit) || ctx.tx.isFieldPresent(sfBytecodeSizeLimit) ||
+                ctx.tx.isFieldPresent(sfGasPrice))
+                return temDISABLED;
             return tesSUCCESS;
         case ttAMENDMENT:
         case ttUNL_MODIFY:
diff --git a/src/libxrpl/tx/transactors/system/TicketCreate.cpp b/src/libxrpl/tx/transactors/system/TicketCreate.cpp
index e19dc9fe96..8844d325a8 100644
--- a/src/libxrpl/tx/transactors/system/TicketCreate.cpp
+++ b/src/libxrpl/tx/transactors/system/TicketCreate.cpp
@@ -99,7 +99,7 @@ TicketCreate::doApply()
     for (std::uint32_t i = 0; i < ticketCount; ++i)
     {
         std::uint32_t const curTicketSeq = firstTicketSeq + i;
-        Keylet const ticketKeylet = keylet::ticket(accountID_, curTicketSeq);
+        Keylet const ticketKeylet = keylet::ticket(accountID_, SeqProxy::rawTicket(curTicketSeq));
         SLE::pointer const sleTicket = std::make_shared(ticketKeylet);
 
         sleTicket->setAccountID(sfAccount, accountID_);
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
index 6366e99105..b3bd276e5f 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
@@ -1,9 +1,11 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -70,7 +72,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 +136,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 +153,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,18 +184,38 @@ 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);
     }
 
+    // Allow clawback on stale mirrors since the issuer can still generate the
+    // proof using the corresponding stale private key. The mirrors are updated
+    // to the current epoch during execution.
+    if (view().rules().enabled(featureConfidentialMPTKeyRotation))
+        setMirrorEpochs(*sleIssuance, *sleHolderMPToken);
+
     // Decrease Global Confidential Outstanding Amount
     auto const oldCOA = (*sleIssuance)[sfConfidentialOutstandingAmount];
     if (clawAmount > oldCOA)
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
index 454eb39ead..0855f3230d 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
@@ -3,10 +3,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,7 +91,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);
@@ -103,6 +112,17 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx)
     if (!sleMptoken)
         return tecOBJECT_NOT_FOUND;
 
+    // An already-initialized holder has their new ciphertexts homomorphically
+    // added to their existing mirrors, so those mirrors must be encrypted under
+    // the currently registered keys. A first-time convert creates the mirrors
+    // under those keys instead, and has nothing to be stale.
+    if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) &&
+        sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) &&
+        !areMirrorsCurrent(*sleIssuance, *sleMptoken))
+    {
+        return tecNO_PERMISSION;
+    }
+
     auto const mptIssue = MPTIssue{issuanceID};
 
     // Explicit freeze and auth checks are required because accountHolds
@@ -207,11 +227,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 +307,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)
@@ -302,13 +343,24 @@ ConfidentialMPTConvert::doApply()
         if (auditorEc)
             (*sleMptoken)[sfAuditorEncryptedBalance] = *auditorEc;
 
+        // Initialize key epochs when registering the keys.
+        if (view().rules().enabled(featureConfidentialMPTKeyRotation))
+            setMirrorEpochs(*sleIssuance, *sleMptoken);
+
         // Spending balance starts at zero. Must use canonical zero encryption
         // (deterministic ciphertext) so the ledger state is reproducible.
         auto zeroBalance = encryptCanonicalZeroAmount(
             (*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 +368,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..0c9b5d3345 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
@@ -2,10 +2,12 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -72,7 +74,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 +178,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)
@@ -182,10 +198,25 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
 
+    // Converting back homomorphically subtracts from the holder's mirrors, so
+    // those mirrors must be current.
+    if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) &&
+        !areMirrorsCurrent(*sleIssuance, *sleMptoken))
+    {
+        return tecNO_PERMISSION;
+    }
+
     // 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 +246,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/ConfidentialMPTMirrorUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp
new file mode 100644
index 0000000000..c00486e5e6
--- /dev/null
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp
@@ -0,0 +1,273 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+bool
+ConfidentialMPTMirrorUpdate::checkExtraFeatures(PreflightContext const& ctx)
+{
+    // Key rotation makes sense only when featureConfidentialTransfer is enabled.
+    return ctx.rules.enabled(featureConfidentialTransfer);
+}
+
+NotTEC
+ConfidentialMPTMirrorUpdate::preflight(PreflightContext const& ctx)
+{
+    auto const account = ctx.tx[sfAccount];
+    auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer();
+    auto const holder = ctx.tx[~sfHolder];
+    bool const hasHolder = holder.has_value();
+
+    // The rotation mode is determined by the presence of the
+    // Holder field: Holder present is issuer mode, Holder absent is
+    // holder self-migration.
+    if (hasHolder)
+    {
+        // Issuer mode: account must be the issuer
+        if (account != issuer)
+            return temMALFORMED;
+
+        if (account == *holder)
+            return temMALFORMED;
+    }
+    else
+    {
+        // Holder self-migration: the submitter is the holder, account must not be the issuer.
+        if (account == issuer)
+            return temMALFORMED;
+    }
+
+    // At least one ciphertext will be updated.
+    bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount);
+    bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+    if (!hasIssuerAmount && !hasAuditorAmount)
+        return temMALFORMED;
+
+    // Check the length of the encrypted amounts. Length check is cheaper than format check so put
+    // it before the format check.
+    if (hasIssuerAmount && ctx.tx[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+        return temBAD_CIPHERTEXT;
+
+    if (hasAuditorAmount &&
+        ctx.tx[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength)
+        return temBAD_CIPHERTEXT;
+
+    // Check proof length.
+    if (ctx.tx[sfZKProof].length() != kEcEqualityProofLength)
+        return temMALFORMED;
+
+    // Check the encrypted amount formats. It is more expensive so put it at the end of preflight.
+    if (hasIssuerAmount && !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount]))
+        return temBAD_CIPHERTEXT;
+
+    if (hasAuditorAmount && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount]))
+        return temBAD_CIPHERTEXT;
+
+    return tesSUCCESS;
+}
+
+XRPAmount
+ConfidentialMPTMirrorUpdate::calculateBaseFee(ReadView const& view, STTx const& tx)
+{
+    return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier);
+}
+
+TER
+ConfidentialMPTMirrorUpdate::preclaim(PreclaimContext const& ctx)
+{
+    // Check if account exists
+    auto const account = ctx.tx[sfAccount];
+    if (!ctx.view.exists(keylet::account(account)))
+        return terNO_ACCOUNT;  // LCOV_EXCL_LINE
+
+    // The issuance must exist and have confidential balances enabled with a
+    // registered issuer encryption key; otherwise there is no mirror to update.
+    auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID];
+    auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+        return tecOBJECT_NOT_FOUND;
+
+    // The issuance must have confidential balances enabled with a registered issuer encryption key.
+    if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) ||
+        !sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
+        return tecNO_PERMISSION;
+
+    // Sanity check: preflight already enforced the issuer holder combination
+    // under different rotation modes.
+    auto const holder = ctx.tx[~sfHolder];
+    bool const hasHolder = holder.has_value();
+    auto const issuer = sleIssuance->getAccountID(sfIssuer);
+    if (hasHolder ? (issuer != account) : (issuer == account))
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMirrorUpdate::preclaim : invalid issuer holder combination");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
+    // The holder is sfHolder in issuer mode and is sfAccount in holder mode.
+    auto const holderID = hasHolder ? *holder : account;
+
+    // In issuer mode, the holder must exist. In holder mode, the account existence was checked
+    // already.
+    if (hasHolder && !ctx.view.exists(keylet::account(holderID)))
+        return tecNO_TARGET;
+
+    // In either issuer or holder mode, check the existence of the MPToken object.
+    auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, holderID));
+    if (!sleMptoken)
+        return tecOBJECT_NOT_FOUND;
+
+    // The holder must already hold an issuer confidential balance.
+    if (!sleMptoken->isFieldPresent(sfIssuerEncryptedBalance))
+        return tecNO_PERMISSION;
+
+    bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount);
+    bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
+
+    // Migrating the auditor mirror requires the issuance to have a registered
+    // auditor encryption key.
+    if (hasAuditorAmount && !sleIssuance->isFieldPresent(sfAuditorEncryptionKey))
+        return tecNO_PERMISSION;
+
+    // An issuer mirror may only be re-encrypted while it is stale, reject if it is already current.
+    if (hasIssuerAmount && isIssuerMirrorCurrent(*sleIssuance, *sleMptoken))
+        return tecNO_PERMISSION;
+
+    if (hasAuditorAmount)
+    {
+        // An issuer-mode auditor-only migration: the issuer mirror must already be up to date.
+        if (hasHolder && !hasIssuerAmount && !isIssuerMirrorCurrent(*sleIssuance, *sleMptoken))
+            return tecNO_PERMISSION;
+
+        // An auditor mirror may only be re-encrypted while it is stale, reject if it is already
+        // current. isAuditorMirrorCurrent reports an absent auditor mirror as stale, which is what
+        // allows an auditor-only migration to create one for the first time.
+        if (isAuditorMirrorCurrent(*sleIssuance, *sleMptoken))
+            return tecNO_PERMISSION;
+    }
+
+    // Holder self-migration re-encrypts the mirror from the holder's own
+    // spending balance, which reflects the holder's full balance only once the
+    // inbox has been merged into it. Require the inbox to be canonical zero,
+    // i.e. ConfidentialMPTMergeInbox has already been applied.
+    if (!hasHolder)
+    {
+        // Sanity check: a holder that already carries an issuer mirror
+        // necessarily has a holder encryption key and a spending balance
+        if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) ||
+            !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending))
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTMirrorUpdate::preclaim : an issuer mirror implies a holder "
+                "key and spending balance");
+            return tefINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        auto const expectedZeroInbox = encryptCanonicalZeroAmount(
+            (*sleMptoken)[sfHolderEncryptionKey], holderID, mptIssuanceID);
+        if (!expectedZeroInbox)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTMirrorUpdate::preclaim : canonical zero encryption cannot "
+                "fail for an already-valid holder public key");
+            return tefINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        bool const inboxIsCanonicalZero = sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) &&
+            Slice((*sleMptoken)[sfConfidentialBalanceInbox]) == Slice(*expectedZeroInbox);
+        if (!inboxIsCanonicalZero)
+            return tecNO_PERMISSION;
+    }
+
+    return tesSUCCESS;
+}
+
+TER
+ConfidentialMPTMirrorUpdate::doApply()
+{
+    auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
+
+    auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID));
+    if (!sleIssuance)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the "
+            "issuance exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
+    // The holderID is sfHolder in issuer mode and sfAccount in holder mode.
+    auto const holder = ctx_.tx[~sfHolder];
+    auto const holderID = holder.value_or(accountID_);
+
+    auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, holderID));
+    if (!sleMptoken)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the "
+            "MPToken exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
+
+    // Re-encrypt the requested mirror(s) and advance the corresponding mirror
+    // epoch to match the issuance key epoch. Each mirror is stamped separately
+    // because this transaction may migrate either one or both.
+    if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount))
+    {
+        (*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount];
+        setIssuerMirrorEpoch(*sleIssuance, *sleMptoken);
+    }
+
+    if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount))
+    {
+        (*sleMptoken)[sfAuditorEncryptedBalance] = ctx_.tx[sfAuditorEncryptedAmount];
+        setAuditorMirrorEpoch(*sleIssuance, *sleMptoken);
+    }
+
+    view().update(sleMptoken);
+    return tesSUCCESS;
+}
+
+void
+ConfidentialMPTMirrorUpdate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
+{
+}
+
+bool
+ConfidentialMPTMirrorUpdate::finalizeInvariants(
+    STTx const&,
+    TER,
+    XRPAmount,
+    ReadView const&,
+    beast::Journal const&)
+{
+    return true;
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
index d121ec2634..14ab874102 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));
@@ -232,13 +247,27 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
 
+    // A send homomorphically updates the mirrors of both parties, so both must
+    // be current.
+    if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) &&
+        (!areMirrorsCurrent(*sleIssuance, *sleSenderMPToken) ||
+         !areMirrorsCurrent(*sleIssuance, *sleDestinationMPToken)))
+    {
+        return tecNO_PERMISSION;
+    }
+
     // Sanity check: Both MPTokens' auditor fields must be present if auditing
     // is enabled
     if (requiresAuditor &&
         (!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 +312,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 +389,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 +416,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 +444,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/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
index aad1642f68..cd0f839030 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp
@@ -35,18 +35,24 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx)
           ctx.rules.enabled(featureSingleAssetVault)))
         return false;
 
-    if (ctx.tx.isFieldPresent(sfMutableFlags) && !ctx.rules.enabled(featureDynamicMPT))
+    if (ctx.tx.isFieldPresent(sfImmutableFlags) && !ctx.rules.enabled(featureDynamicMPT))
         return false;
 
     if (ctx.tx.isFlag(tfMPTCanHoldConfidentialBalance) &&
         !ctx.rules.enabled(featureConfidentialTransfer))
         return false;
 
-    // can not set tmfMPTCannotEnableCanHoldConfidentialBalance without featureConfidentialTransfer
-    auto const mutableFlags = ctx.tx[~sfMutableFlags];
-    return !mutableFlags ||
-        ((*mutableFlags & tmfMPTCannotEnableCanHoldConfidentialBalance) == 0u) ||
-        ctx.rules.enabled(featureConfidentialTransfer);
+    // can not set tifMPTCanHoldConfidentialBalance without featureConfidentialTransfer
+    auto const immutableFlags = ctx.tx[~sfImmutableFlags];
+    // NOLINTBEGIN(readability-simplify-boolean-expr)
+    if (immutableFlags && ((*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u) &&
+        !ctx.rules.enabled(featureConfidentialTransfer))
+    {
+        return false;
+    }
+    // NOLINTEND(readability-simplify-boolean-expr)
+
+    return true;
 }
 
 std::uint32_t
@@ -64,10 +70,10 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx)
     if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx.isFieldPresent(sfReferenceHolding))
         return temMALFORMED;
 
-    // If the mutable flags field is included, at least one flag must be
-    // specified.
-    if (auto const mutableFlags = ctx.tx[~sfMutableFlags]; mutableFlags &&
-        ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceCreateMutableMask) != 0u)))
+    // If the immutable flags field is included, at least one flag must be
+    // specified, and undefined flags must not be specified.
+    if (auto const immutableFlags = ctx.tx[~sfImmutableFlags]; immutableFlags &&
+        ((*immutableFlags == 0u) || ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u)))
         return temINVALID_FLAG;
 
     if (auto const fee = ctx.tx[~sfTransferFee])
@@ -170,8 +176,8 @@ MPTokenIssuanceCreate::create(
         if (args.domainId)
             (*mptIssuance)[sfDomainID] = *args.domainId;
 
-        if (args.mutableFlags)
-            (*mptIssuance)[sfMutableFlags] = *args.mutableFlags;
+        if (args.immutableFlags)
+            (*mptIssuance)[sfImmutableFlags] = *args.immutableFlags;
 
         if (args.referenceHolding)
         {
@@ -210,14 +216,14 @@ MPTokenIssuanceCreate::doApply()
         {
             .priorBalance = preFeeBalance_,
             .account = accountID_,
-            .sequence = tx.getSeqValue(),
+            .sequence = tx.getSeqProxy().value(),
             .flags = tx.getFlags(),
             .maxAmount = tx[~sfMaximumAmount],
             .assetScale = tx[~sfAssetScale],
             .transferFee = tx[~sfTransferFee],
             .metadata = tx[~sfMPTokenMetadata],
             .domainId = tx[~sfDomainID],
-            .mutableFlags = tx[~sfMutableFlags],
+            .immutableFlags = tx[~sfImmutableFlags],
         });
     return result ? tesSUCCESS : result.error();
 }
diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
index d526251069..6bd7140631 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp
@@ -20,7 +20,6 @@
 #include 
 
 #include 
-#include 
 #include 
 
 namespace xrpl {
@@ -39,56 +38,29 @@ MPTokenIssuanceSet::getFlagsMask(PreflightContext const& ctx)
     return tfMPTokenIssuanceSetMask;
 }
 
-// Maps each MPTokenIssuanceSet MutableFlags to the corresponding mutable
-// flag and the target ledger flag to mutate.
-struct MPTMutabilityFlags
-{
-    std::uint32_t setFlag;
-    std::uint32_t canEnableFlag;
-    std::uint32_t ledgerFlag;
-};
-
-static constexpr std::array kMptMutabilityFlags = {
-    {{.setFlag = tmfMPTSetCanLock,
-      .canEnableFlag = lsmfMPTCanEnableCanLock,
-      .ledgerFlag = lsfMPTCanLock},
-     {.setFlag = tmfMPTSetRequireAuth,
-      .canEnableFlag = lsmfMPTCanEnableRequireAuth,
-      .ledgerFlag = lsfMPTRequireAuth},
-     {.setFlag = tmfMPTSetCanEscrow,
-      .canEnableFlag = lsmfMPTCanEnableCanEscrow,
-      .ledgerFlag = lsfMPTCanEscrow},
-     {.setFlag = tmfMPTSetCanTrade,
-      .canEnableFlag = lsmfMPTCanEnableCanTrade,
-      .ledgerFlag = lsfMPTCanTrade},
-     {.setFlag = tmfMPTSetCanTransfer,
-      .canEnableFlag = lsmfMPTCanEnableCanTransfer,
-      .ledgerFlag = lsfMPTCanTransfer},
-     {.setFlag = tmfMPTSetCanClawback,
-      .canEnableFlag = lsmfMPTCanEnableCanClawback,
-      .ledgerFlag = lsfMPTCanClawback}}};
-
 NotTEC
 MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
 {
-    auto const mutableFlags = ctx.tx[~sfMutableFlags];
+    auto const txFlags = ctx.tx.getFlags();
+    auto const enableFlags = txFlags & tfMPTokenIssuanceSetEnableFlagMask;
     auto const metadata = ctx.tx[~sfMPTokenMetadata];
     auto const transferFee = ctx.tx[~sfTransferFee];
-    auto const isMutate = mutableFlags || metadata || transferFee;
+    auto const immutableFlags = ctx.tx[~sfImmutableFlags];
+    auto const isMutate = (enableFlags != 0u) || metadata || transferFee || immutableFlags;
     auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey);
     auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey);
-    auto const txFlags = ctx.tx.getFlags();
-
-    bool const enablePrivacy =
-        mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u;
 
+    bool const enablePrivacy = (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u;
     auto const hasDomain = ctx.tx.isFieldPresent(sfDomainID);
     auto const hasHolder = ctx.tx.isFieldPresent(sfHolder);
 
     if (isMutate && !ctx.rules.enabled(featureDynamicMPT))
         return temDISABLED;
 
-    if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy) &&
+    bool const setConfidentialBalanceImmutable =
+        immutableFlags && (*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u;
+    if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy ||
+         setConfidentialBalanceImmutable) &&
         !ctx.rules.enabled(featureConfidentialTransfer))
         return temDISABLED;
 
@@ -122,8 +94,9 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
         if (isMutate && holderID)
             return temMALFORMED;
 
-        // Can not set flags when mutating MPTokenIssuance
-        if (isMutate && ((ctx.tx.getFlags() & tfUniversalMask) != 0u))
+        // A single transaction may either lock/unlock or mutate capability
+        // flags, but not both.
+        if (isMutate && (ctx.tx.isFlag(tfMPTLock) || ctx.tx.isFlag(tfMPTUnlock)))
             return temMALFORMED;
 
         if (transferFee && *transferFee > kMaxTransferFee)
@@ -135,17 +108,26 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx)
         if (metadata && metadata->length() > kMaxMpTokenMetadataLength)
             return temMALFORMED;
 
-        if (mutableFlags)
-        {
-            if ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceSetMutableMask) != 0u))
-                return temINVALID_FLAG;
-        }
+        // If the immutable flags field is included, at least one flag must be
+        // specified, and undefined flags must not be specified.
+        if (immutableFlags &&
+            ((*immutableFlags == 0u) ||
+             ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u)))
+            return temINVALID_FLAG;
     }
 
     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]))
@@ -207,40 +189,32 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
         }
     }
 
-    // sfMutableFlags is soeDEFAULT, defaulting to 0 if not specified on
+    // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not specified on
     // the ledger.
-    auto const currentMutableFlags = sleMptIssuance->getFieldU32(sfMutableFlags);
+    auto const currentImmutableFlags = sleMptIssuance->getFieldU32(sfImmutableFlags);
 
-    auto isMutableFlag = [&](std::uint32_t mutableFlag) -> bool {
-        return currentMutableFlags & mutableFlag;
-    };
+    auto isImmutable = [&](std::uint32_t flag) -> bool { return currentImmutableFlags & flag; };
 
-    auto const mutableFlags = ctx.tx[~sfMutableFlags];
-    // Whether the transaction is enabling confidential amounts.
-    bool const enablesConfidentialAmount =
-        mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u;
-    if (mutableFlags)
+    auto const enableFlags = ctx.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask;
+    if (enableFlags != 0u)
     {
-        if (std::ranges::any_of(kMptMutabilityFlags, [mutableFlags, &isMutableFlag](auto const& f) {
-                return !isMutableFlag(f.canEnableFlag) && ((*mutableFlags & f.setFlag) != 0u);
+        // If any of the flags to be set is immutable, return tecNO_PERMISSION.
+        if (std::ranges::any_of(flagMapping, [&](auto const& f) {
+                return isImmutable(f.immutableFlag) && ctx.tx.isFlag(f.setFlag);
             }))
             return tecNO_PERMISSION;
-
-        if (enablesConfidentialAmount &&
-            isMutableFlag(lsmfMPTCannotEnableCanHoldConfidentialBalance))
-            return tecNO_PERMISSION;
     }
 
-    if (!isMutableFlag(lsmfMPTCanMutateMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata))
+    if (isImmutable(lsifMPTMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata))
         return tecNO_PERMISSION;
 
     if (auto const fee = ctx.tx[~sfTransferFee])
     {
         // A non-zero TransferFee is only valid if the lsfMPTCanTransfer flag
-        // was previously enabled (at issuance or via a prior mutation). Setting
-        // it by tmfMPTSetCanTransfer in the current transaction does not meet
-        // this requirement.
-        if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer))
+        // is already set on the ledger object, or is being enabled by this
+        // same transaction. The Immutability of lsfMPTCanTransfer is checked above.
+        if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer) &&
+            (enableFlags & tfMPTSetCanTransfer) == 0u)
             return tecNO_PERMISSION;
 
         // Cannot set a non-zero TransferFee on an issuance that has confidential
@@ -248,49 +222,96 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
         if (fee > 0u && sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance))
             return tecNO_PERMISSION;
 
-        if (!isMutableFlag(lsmfMPTCanMutateTransferFee))
+        // Cannot set TransferFee if it is immutable
+        if (isImmutable(lsifMPTTransferFee))
             return tecNO_PERMISSION;
     }
 
-    // cannot update issuer public key
-    if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) &&
-        sleMptIssuance->isFieldPresent(sfIssuerEncryptionKey))
+    // Updating an existing encryption key requires the
+    // ConfidentialMPTKeyRotation amendment.
+    bool const canRotateKey = ctx.view.rules().enabled(featureConfidentialMPTKeyRotation);
+
+    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;
+        // 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
     }
 
-    // cannot update auditor public key
-    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
-        sleMptIssuance->isFieldPresent(sfAuditorEncryptionKey))
-    {
-        return tecNO_PERMISSION;  // LCOV_EXCL_LINE
-    }
-
-    if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) &&
+    auto const enablesConfidentialBalance =
+        (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u;
+    if (enablesConfidentialBalance && sleMptIssuance->isFieldPresent(sfTransferFee) &&
         (*sleMptIssuance)[sfTransferFee] > 0u)
         return tecNO_PERMISSION;
 
     // 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) && !enablesConfidentialAmount)
+    if (txHasIssuerKey && !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) &&
+        !enablesConfidentialBalance)
     {
         return tecNO_PERMISSION;
     }
 
-    if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
-        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount)
+    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) || enablesConfidentialAmount) &&
-        (*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;
 }
@@ -327,23 +348,41 @@ MPTokenIssuanceSet::doApply()
         flagsOut &= ~lsfMPTLocked;
     }
 
-    if (auto const mutableFlags = ctx_.tx[~sfMutableFlags].value_or(0))
+    if (auto const enableFlags = (ctx_.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask);
+        enableFlags != 0u)
     {
-        for (auto const& f : kMptMutabilityFlags)
+        for (auto const& f : flagMapping)
         {
-            if ((mutableFlags & f.setFlag) != 0u)
+            if (ctx_.tx.isFlag(f.setFlag))
             {
                 flagsOut |= f.ledgerFlag;
             }
         }
-
-        if ((mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u)
-            flagsOut |= lsfMPTCanHoldConfidentialBalance;
     }
 
     if (flagsIn != flagsOut)
         sle->setFieldU32(sfFlags, flagsOut);
 
+    if (auto const immutableFlags = ctx_.tx[~sfImmutableFlags])
+    {
+        // sle is guaranteed to be an ltMPTOKEN_ISSUANCE rather than an ltMPTOKEN.
+        // Preflight verification ensures that sfHolder and sfImmutableFlags can
+        // never both be present in the same transaction. Therefore, if
+        // sfImmutableFlags is present, sfHolder must be absent.
+        //
+        // In doApply, the absence of sfHolder causes the MPTokenIssuance keylet
+        // to be peeked. The runtime check below is a defensive fallback in case
+        // this invariant is ever broken by a future change.
+        XRPL_ASSERT(
+            sle->getType() == ltMPTOKEN_ISSUANCE,
+            "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance");
+
+        if (sle->getType() != ltMPTOKEN_ISSUANCE)
+            return tecINTERNAL;  // LCOV_EXCL_LINE
+
+        (*sle)[sfImmutableFlags] = (*sle)[sfImmutableFlags] | *immutableFlags;
+    }
+
     if (auto const transferFee = ctx_.tx[~sfTransferFee])
     {
         // TransferFee uses soeDEFAULT style:
@@ -390,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 e1f5873a89..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 
@@ -30,6 +31,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -42,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;
 }
 
@@ -98,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;
 }
 
@@ -130,11 +153,21 @@ VaultCreate::preclaim(PreclaimContext const& ctx)
             return tecOBJECT_NOT_FOUND;
     }
 
-    auto const sequence = ctx.tx.getSeqValue();
+    auto const sequence = ctx.tx.getSeqProxy();
     if (auto const accountId = pseudoAccountAddress(ctx.view, keylet::vault(account, sequence).key);
         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;
 }
 
@@ -147,7 +180,7 @@ VaultCreate::doApply()
 
     auto const& tx = ctx_.tx;
     auto applyViewContext = ctx_.getApplyViewContext();
-    auto const sequence = tx.getSeqValue();
+    auto const sequence = tx.getSeqProxy();
     auto const owner = view().peek(keylet::account(accountID_));
     if (owner == nullptr)
         return tefINTERNAL;  // LCOV_EXCL_LINE
@@ -209,7 +242,6 @@ VaultCreate::doApply()
             .transferFee = std::nullopt,
             .metadata = tx[~sfMPTokenMetadata],
             .domainId = tx[~sfDomainID],
-            .mutableFlags = std::nullopt,
             .referenceHolding = referenceHolding,
         });
     if (!maybeShare)
@@ -218,7 +250,7 @@ VaultCreate::doApply()
 
     vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
     vault->at(sfFlags) = tx.getFlags() & tfVaultPrivate;
-    vault->at(sfSequence) = sequence;
+    vault->at(sfSequence) = sequence.value();
     vault->at(sfOwner) = accountID_;
     vault->at(sfAccount) = pseudoId;
     vault->at(sfAssetsTotal) = Number(0);
@@ -241,6 +273,18 @@ 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 bf0bc5c7d7..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)
     {
@@ -7142,6 +7336,210 @@ private:
         }
     }
 
+    void
+    testDepositIntegralOverflowMPT(FeatureBitset features)
+    {
+        testcase("Deposit integral overflow (MPT)");
+
+        using namespace jtx;
+
+        // Without fixCleanup3_4_0 the exception escapes and is converted to
+        // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it
+        // and fails cleanly with tecAMM_FAILED.
+        auto const err = features[fixCleanup3_4_0] ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION);
+
+        // MPT counterpart of AMM_test::testDepositIntegralOverflow. A two-asset
+        // deposit with a huge Amount against a tiny pool leg makes
+        // frac = Amount / balance enormous, so the computed deposit for the
+        // other (integral) leg exceeds Number's int64 range (kMaxRep ~=
+        // 9.22e18) and the conversion to an integral STAmount throws out of
+        // doApply - which applySteps would surface as tefEXCEPTION.
+        //
+        // The default amendments include fixCleanup3_4_0, under which applyGuts
+        // guards the overflow and fails cleanly with tecAMM_FAILED. This
+        // verifies the guarded path: no overflow escapes.
+
+        // XRP/MPT - the exact pool the report (Antithesis) calls out. A tiny
+        // mpt(1) balance and a huge MPT Amount drive frac; the XRP leg is what
+        // overflows: XRP(10) is 1e7 drops, so getRoundedAsset(XRP, frac) is
+        // 1e7 * 1e13 = 1e20 drops, well past kMaxRep.
+        {
+            // The deposit intentionally overflows, which logs at error.
+            // Disable the log threshold to keep the test output clean.
+            Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled);
+            if (!features[fixCleanup3_4_0])
+                env.disableFeature(fixCleanup3_4_0);
+            env.fund(XRP(30'000), gw_, alice_);
+            env.close();
+
+            // kMptDexFlags (CanTrade | CanTransfer), which AMMs require, is
+            // the default. alice must hold enough MPT to fund the pool and the
+            // oversized deposit.
+            MPT const mpt = MPTTester(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 100'000'000'000'000,         // 1e14
+                 .maxAmt = 1'000'000'000'000'000});  // 1e15
+            env.close();
+
+            AMM amm(env, alice_, XRP(10), mpt(1));
+            amm.deposit(
+                DepositArg{
+                    .account = alice_,
+                    .asset1In = mpt(10'000'000'000'000),  // 1e13
+                    .asset2In = XRP(1),
+                    .err = err});
+        }
+
+        // IOU/MPT - the MPT leg is the one that overflows. A classic IOU
+        // trustline drives frac (huge USD Amount vs USD(1) balance); the
+        // MPT-side deposit is then mptBalance * frac = 10'000 * 1e16 = 1e20.
+        {
+            // The deposit intentionally overflows, which logs at error.
+            // Disable the log threshold to keep the test output clean.
+            Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled);
+            env.fund(XRP(30'000), gw_, alice_);
+            env(trust(alice_, STAmount{USD, 1, 20}));
+            env(pay(gw_, alice_, STAmount{USD, 1, 18}));
+            env.close();
+
+            MPT const mpt =
+                MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000});
+            env.close();
+
+            AMM amm(env, alice_, mpt(10'000), USD(1));
+            amm.deposit(
+                DepositArg{
+                    .account = alice_,
+                    .asset1In = STAmount{USD, 1, 16},
+                    .asset2In = mpt(1),
+                    .err = err});
+        }
+    }
+
+    void
+    testWithdrawIntegralNoOverflowMPT()
+    {
+        testcase("Withdraw integral no overflow (MPT)");
+
+        using namespace jtx;
+
+        // MPT counterpart of AMM_test::testWithdrawIntegralNoOverflow and the
+        // sibling of testDepositIntegralOverflowMPT. AMMWithdraw::
+        // equalWithdrawLimit has the same getRoundedAsset(integralBalance,
+        // frac) structure as the deposit path and is likewise not wrapped in a
+        // try/catch. It is safe only because withdraw preclaim (checkAmount)
+        // rejects a requested Amount greater than the pool balance with
+        // tecAMM_BALANCE *before* the math runs, so frac = Amount / balance
+        // stays <= 1 and the Number -> integral STAmount conversion cannot
+        // overflow. Deposit has no such bound, which is why only the deposit
+        // path was exposed.
+        //
+        // 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.
+
+        // XRP/MPT - the pool the report calls out. Requesting far more of the
+        // tiny MPT leg than the pool holds is rejected before the math.
+        {
+            Env env(*this);
+            env.fund(XRP(30'000), gw_, alice_);
+            env.close();
+
+            MPT const mpt = MPTTester(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 100'000'000'000'000,         // 1e14
+                 .maxAmt = 1'000'000'000'000'000});  // 1e15
+            env.close();
+
+            // alice holds all LPTokens of a tiny XRP/MPT pool.
+            AMM amm(env, alice_, XRP(10), mpt(1));
+            amm.withdraw(
+                WithdrawArg{
+                    .account = alice_,
+                    .asset1Out = mpt(10'000'000'000'000),  // 1e13 > mpt(1)
+                    .asset2Out = XRP(1),
+                    .err = Ter(tecAMM_BALANCE)});
+        }
+
+        // IOU/MPT - requesting far more of the tiny IOU leg than the pool
+        // holds is likewise rejected.
+        {
+            Env env(*this);
+            env.fund(XRP(30'000), gw_, alice_);
+            env(trust(alice_, STAmount{USD, 1, 20}));
+            env(pay(gw_, alice_, STAmount{USD, 1, 18}));
+            env.close();
+
+            MPT const mpt =
+                MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000});
+            env.close();
+
+            AMM amm(env, alice_, mpt(10'000), USD(1));
+            amm.withdraw(
+                WithdrawArg{
+                    .account = alice_,
+                    .asset1Out = STAmount{USD, 1, 16},  // > USD(1)
+                    .asset2Out = mpt(1),
+                    .err = Ter(tecAMM_BALANCE)});
+        }
+    }
+
+    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
     {
@@ -7153,10 +7551,12 @@ private:
         testDeposit();
         testInvalidWithdraw();
         testWithdraw();
+        testWithdrawReserveUsesLiveBalance();
         testInvalidFeeVote();
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testClawback();
         testClawbackFromAMMAccount(all);
         testClawbackFromAMMAccount(all - featureSingleAssetVault);
@@ -7165,6 +7565,7 @@ private:
         testAMMTokens();
         testAmendment();
         testAMMAndCLOB(all);
+        testAMMOfferGenerationPolicy(all);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
         testAdjustedTokens(all);
@@ -7178,6 +7579,10 @@ private:
         testAMMDepositWithFrozenAssets();
         testAMMWithVaultShares();
         testAutoDelete();
+        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 f19743026c..0212035c6e 100644
--- a/src/test/app/AMM_test.cpp
+++ b/src/test/app/AMM_test.cpp
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -2292,8 +2293,9 @@ private:
         // ePrice = lptAMMBalance(100) * f(0.001) / amountBalance(100) = 0.001
         testAMM(
             [&](AMM& ammAlice, Env& env) {
-                auto const err =
-                    env.enabled(fixCleanup3_3_0) ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION);
+                auto const err = env.enabled(fixCleanup3_3_0) || env.enabled(fixCleanup3_4_0)
+                    ? Ter(tecAMM_FAILED)
+                    : Ter(tefEXCEPTION);
                 ammAlice.withdraw(
                     WithdrawArg{
                         .account = alice_,
@@ -2304,7 +2306,7 @@ private:
             {{USD(100), EUR(100)}},
             1000,
             std::nullopt,
-            {all - fixCleanup3_3_0, all});
+            {all - fixCleanup3_3_0 - fixCleanup3_4_0, all - fixCleanup3_4_0, all});
     }
 
     void
@@ -3125,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,
@@ -3776,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
@@ -3786,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)}},
@@ -6024,7 +6073,7 @@ private:
 
     void
     // NOLINTNEXTLINE(readability-convert-member-functions-to-static)
-    testFixOverflowOffer(FeatureBitset featuresInitial)
+    testOverflowOffer(FeatureBitset featuresInitial)
     {
         using namespace jtx;
         using namespace std::chrono;
@@ -6259,7 +6308,7 @@ private:
              })
         {
             testcase(input.testCase);
-            for (auto const& features : {all - fixAMMOverflowOffer - fixAMMv1_1 - fixAMMv1_3, all})
+            for (auto const& features : {all - fixAMMv1_1 - fixAMMv1_3, all})
             {
                 Env env(*this, features, std::make_unique(&logs));
 
@@ -6308,11 +6357,6 @@ private:
                     return input.lpTokenBalanceAlt.value_or(input.lpTokenBalance);
                 }();
 
-                if (!features[fixAMMOverflowOffer])
-                {
-                    BEAST_EXPECT(amm.expectBalances(failUsdGH, failUsdBIT, lpTokenBalance));
-                }
-                else
                 {
                     BEAST_EXPECT(amm.expectBalances(goodUsdGH, goodUsdBIT, lpTokenBalance));
 
@@ -6454,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),
@@ -6467,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
@@ -6478,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}}}}));
+            }
         }
     }
 
@@ -7210,6 +7287,172 @@ private:
         }
     }
 
+    void
+    testDepositIntegralOverflow()
+    {
+        testcase("Deposit integral overflow");
+
+        using namespace jtx;
+        auto const all = testableAmendments();
+
+        // Found by Antithesis: two-asset deposit with a huge Amount against a
+        // tiny pool leg makes frac = Amount/amountBalance enormous, so the
+        // computed XRP-side deposit exceeds the integral asset's range and the
+        // conversion to an STAmount throws out of doApply.
+        //
+        // applyGuts catches std::runtime_error around the deposit math, which
+        // covers both ways the conversion can throw:
+        //   - value beyond int64 range: Number::operator rep() throws
+        //     std::overflow_error (a std::runtime_error); and
+        //   - value within int64 but above the asset maximum (kMaxNativeN):
+        //     STAmount::canonicalize throws std::runtime_error.
+        // XRP(10) is 1e7 drops, so the computed XRP leg is 1e7 * frac:
+        //   asset1In 1e15 => frac ~1e15 => ~1e22 drops, past int64max; and
+        //   asset1In 1e11 => frac ~1e11 => ~1e18 drops, in [kMaxNativeN=1e17,
+        //   int64max) - the canonicalize band, which would otherwise escape.
+        //
+        // Without fixCleanup3_4_0 the exception escapes and is converted to
+        // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it
+        // and fails cleanly with tecAMM_FAILED.
+        auto const test = [this](FeatureBitset features, STAmount const& asset1In, TER expected) {
+            // These deposits intentionally trigger the overflow, which logs
+            // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the
+            // log threshold to keep the test output clean.
+            Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled);
+            env.fund(XRP(30'000), gw_, alice_);
+            env(trust(alice_, STAmount{USD, 1, 20}));
+            env(pay(gw_, alice_, STAmount{USD, 1, 18}));
+            env.close();
+
+            AMM amm(env, gw_, XRP(10), USD(1));
+            amm.deposit(
+                DepositArg{
+                    .account = alice_,
+                    .asset1In = asset1In,
+                    .asset2In = XRP(1),
+                    .err = Ter(expected)});
+        };
+
+        // int64-range band (overflow_error): legacy escapes as tefEXCEPTION,
+        // fixed returns a tec.
+        test(all - fixCleanup3_4_0, STAmount{USD, 1, 15}, tefEXCEPTION);
+        test(all, STAmount{USD, 1, 15}, tecAMM_FAILED);
+        // canonicalize band (runtime_error): same behavior. Regression guard
+        // for the band a plain overflow_error catch would miss.
+        test(all - fixCleanup3_4_0, STAmount{USD, 1, 11}, tefEXCEPTION);
+        test(all, STAmount{USD, 1, 11}, tecAMM_FAILED);
+    }
+
+    void
+    testDepositEPriceIntegralOverflow()
+    {
+        testcase("Deposit EPrice integral overflow");
+
+        using namespace jtx;
+        auto const all = testableAmendments();
+
+        // Found by Antithesis: a one-sided tfLimitLPToken deposit (Amount and
+        // EPrice) with Amount = 0 and a large EPrice makes the solved pool-side
+        // deposit enormous, so it exceeds the integral asset's range and the
+        // conversion to an STAmount throws out of doApply. This is the
+        // singleDepositEPrice sibling of testDepositIntegralOverflow.
+        //
+        // applyGuts catches std::runtime_error around the deposit math, which
+        // covers both ways the conversion can throw:
+        //   - value beyond int64 range: Number::operator rep() throws
+        //     std::overflow_error (a std::runtime_error); and
+        //   - value within int64 but above the asset maximum (kMaxNativeN):
+        //     STAmount::canonicalize throws std::runtime_error.
+        //
+        // Without fixCleanup3_4_0 the exception escapes and is converted to
+        // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it
+        // and fails cleanly with tecAMM_FAILED.
+        auto const test = [this](FeatureBitset features, STAmount const& ePrice, TER expected) {
+            // These deposits intentionally trigger the overflow, which logs
+            // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the
+            // log threshold to keep the test output clean.
+            Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled);
+            env.fund(XRP(30'000), gw_, alice_);
+            env(trust(alice_, STAmount{USD, 1, 20}));
+            env(pay(gw_, alice_, STAmount{USD, 1, 18}));
+            env.close();
+
+            AMM amm(env, gw_, XRP(10), USD(1));
+            // Amount = 0 (XRP), EPrice large => tfLimitLPToken. The solved XRP
+            // leg blows past the integral range.
+            amm.deposit(
+                DepositArg{
+                    .account = alice_, .asset1In = XRP(0), .maxEP = ePrice, .err = Ter(expected)});
+        };
+
+        // For this XRP(10)/USD(1) pool the LPToken balance is
+        // sqrt(1e7 drops * 1) = 3162, so T^2/B = 1e7/1e7 = 1 and the solved
+        // XRP-side deposit is ~EPrice^2 drops.
+        //
+        // int64-range band (overflow_error): legacy escapes as tefEXCEPTION,
+        // fixed returns a tec. EPrice ~1e17 drops => solved deposit ~1e34 drops,
+        // past int64max, so Number::operator rep() throws.
+        auto const bigEP = STAmount{XRPAmount{99'999'999'999'999'999}};
+        test(all - fixCleanup3_4_0, bigEP, tefEXCEPTION);
+        test(all, bigEP, tecAMM_FAILED);
+        // canonicalize band (runtime_error): same behavior. Regression guard
+        // for the band a plain overflow_error catch would miss. EPrice 1e9 drops
+        // => solved deposit ~1e18 drops, in [kMaxNativeN=1e17, int64max), so
+        // STAmount::canonicalize throws.
+        auto const midEP = STAmount{XRPAmount{1'000'000'000}};
+        test(all - fixCleanup3_4_0, midEP, tefEXCEPTION);
+        test(all, midEP, tecAMM_FAILED);
+    }
+
+    void
+    testWithdrawIntegralNoOverflow()
+    {
+        testcase("Withdraw integral no overflow");
+
+        using namespace jtx;
+        auto const all = testableAmendments();
+
+        // Regression guard for the sibling of testDepositIntegralOverflow.
+        // AMMWithdraw::equalWithdrawLimit has the same
+        // getRoundedAsset(integralBalance, frac) structure as the deposit
+        // path and is likewise not wrapped in a try/catch. It is safe only
+        // because withdraw preclaim (checkAmount) rejects a requested Amount
+        // greater than the pool balance with tecAMM_BALANCE *before* the math
+        // runs, so frac = Amount / balance stays <= 1 and the Number ->
+        // integral STAmount conversion cannot overflow. Deposit has no such
+        // bound (depositing more than the pool holds is legal), which is why
+        // only the deposit path was exposed.
+        //
+        // This asserts the withdrawal analog of the deposit repro fails cleanly
+        // with a tec. 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 test.
+        auto const test = [this](FeatureBitset features) {
+            Env env(*this, features);
+            env.fund(XRP(30'000), gw_, alice_);
+            env(trust(alice_, STAmount{USD, 1, 20}));
+            env(pay(gw_, alice_, STAmount{USD, 1, 18}));
+            env.close();
+
+            // gw holds all LPTokens of a tiny XRP/USD pool.
+            AMM amm(env, gw_, XRP(10), USD(1));
+
+            // Two-asset limit withdraw (tfTwoAsset) requesting far more of the
+            // tiny USD leg than the pool holds - the mirror of the deposit
+            // repro. Rejected upstream, so no overflow is possible.
+            amm.withdraw(
+                WithdrawArg{
+                    .account = gw_,
+                    .asset1Out = STAmount{USD, 1, 15},
+                    .asset2Out = XRP(1),
+                    .err = Ter(tecAMM_BALANCE)});
+        };
+
+        // Bound holds regardless of the deposit-side fix amendment.
+        test(all - featureMPTokensV2);
+        test(all);
+    }
+
     void
     run() override
     {
@@ -7225,6 +7468,7 @@ private:
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testBid(all - fixAMMv1_3);
         testBid(all - fixAMMv1_1 - fixAMMv1_3);
         testInvalidAMMPayment();
@@ -7237,6 +7481,7 @@ private:
         testFlags();
         testRippling();
         testAMMAndCLOB(all);
+        testAMMAndCLOB(all - featureMPTokensV2);
         testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
@@ -7251,13 +7496,15 @@ private:
         testSelection(all - fixAMMv1_1 - fixAMMv1_3);
         testFixDefaultInnerObj();
         testMalformed();
-        testFixOverflowOffer(all);
-        testFixOverflowOffer(all - fixAMMv1_3);
-        testFixOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3);
+        testOverflowOffer(all);
+        testOverflowOffer(all - fixAMMv1_3);
+        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);
@@ -7282,6 +7529,9 @@ private:
         testFailedPseudoAccount();
         testStaleAuthAccountsAfterReinit(all);
         testStaleAuthAccountsAfterReinit(all - fixCleanup3_2_0);
+        testDepositIntegralOverflow();
+        testDepositEPriceIntegralOverflow();
+        testWithdrawIntegralNoOverflow();
     }
 };
 
diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp
index 399696ec0d..aa7fe898e3 100644
--- a/src/test/app/AccountDelete_test.cpp
+++ b/src/test/app/AccountDelete_test.cpp
@@ -23,18 +23,23 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -64,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)))
@@ -75,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.
@@ -214,8 +235,10 @@ public:
             BEAST_EXPECT(env.closed()->exists(keylet::account(carol.id())));
             BEAST_EXPECT(env.closed()->exists(keylet::ownerDir(carol.id())));
             BEAST_EXPECT(env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id())));
-            BEAST_EXPECT(env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq)));
-            BEAST_EXPECT(env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq)));
+            BEAST_EXPECT(env.closed()->exists(
+                keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq))));
+            BEAST_EXPECT(env.closed()->exists(
+                keylet::ticket(carol.id(), SeqProxy::rawTicket(carolTicketSeq))));
             BEAST_EXPECT(env.closed()->exists(keylet::signerList(carol.id())));
 
             // Delete carol's account even with stuff in her directory.  Show
@@ -228,8 +251,10 @@ public:
             BEAST_EXPECT(!env.closed()->exists(keylet::account(carol.id())));
             BEAST_EXPECT(!env.closed()->exists(keylet::ownerDir(carol.id())));
             BEAST_EXPECT(!env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id())));
-            BEAST_EXPECT(!env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq)));
-            BEAST_EXPECT(!env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq)));
+            BEAST_EXPECT(!env.closed()->exists(
+                keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq))));
+            BEAST_EXPECT(!env.closed()->exists(
+                keylet::ticket(carol.id(), SeqProxy::rawTicket(carolTicketSeq))));
             BEAST_EXPECT(!env.closed()->exists(keylet::signerList(carol.id())));
 
             // Verify that Carol's XRP, minus the fee, was transferred to becky.
@@ -323,7 +348,7 @@ public:
         // alice writes a check to becky.  Until that check is cashed or
         // canceled it will prevent alice's and becky's accounts from being
         // deleted.
-        uint256 const checkId = keylet::check(alice, env.seq(alice)).key;
+        uint256 const checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(check::create(alice, becky, XRP(1)));
         env.close();
 
@@ -386,7 +411,8 @@ public:
         env(escrow::cancel(becky, alice, escrowSeq));
         env.close();
 
-        Keylet const alicePayChanKey{keylet::payChannel(alice, becky, env.seq(alice))};
+        Keylet const alicePayChanKey{
+            keylet::payChannel(alice, becky, SeqProxy::rawSequence(env.seq(alice)))};
 
         env(payChanCreate(alice, becky, XRP(57), 4s, env.now() + 2s, alice.pk()));
         env.close();
@@ -417,7 +443,8 @@ public:
 
         // gw creates a PayChannel with alice as the destination, this should
         // prevent alice from deleting her account.
-        Keylet const gwPayChanKey{keylet::payChannel(gw, alice, env.seq(gw))};
+        Keylet const gwPayChanKey{
+            keylet::payChannel(gw, alice, SeqProxy::rawSequence(env.seq(gw)))};
 
         env(payChanCreate(gw, alice, XRP(68), 4s, env.now() + 2s, alice.pk()));
         env.close();
@@ -503,7 +530,10 @@ public:
 
             // alice's offers.
             for (std::uint32_t i{0}; i < kOfferCount; ++i)
-                BEAST_EXPECT(closed->exists(keylet::offer(alice.id(), offerSeq0 + i)));
+            {
+                BEAST_EXPECT(closed->exists(
+                    keylet::offer(alice.id(), SeqProxy::rawSequence(offerSeq0 + i))));
+            }
         }
 
         // Delete alice's account.  Should fail because she has too many
@@ -537,7 +567,10 @@ public:
 
             // alice's former offers.
             for (std::uint32_t i{0}; i < kOfferCount; ++i)
-                BEAST_EXPECT(!closed->exists(keylet::offer(alice.id(), offerSeq0 + i)));
+            {
+                BEAST_EXPECT(!closed->exists(
+                    keylet::offer(alice.id(), SeqProxy::rawSequence(offerSeq0 + i))));
+            }
         }
     }
 
@@ -662,7 +695,8 @@ public:
             BEAST_EXPECT(closed->exists(keylet::account(bob.id())));
             for (std::uint32_t i = 0; i < 250; ++i)
             {
-                BEAST_EXPECT(closed->exists(keylet::ticket(bob.id(), ticketSeq + i)));
+                BEAST_EXPECT(
+                    closed->exists(keylet::ticket(bob.id(), SeqProxy::rawTicket(ticketSeq + i))));
             }
         }
 
@@ -681,13 +715,14 @@ public:
             BEAST_EXPECT(!closed->exists(keylet::account(bob.id())));
             for (std::uint32_t i = 0; i < 250; ++i)
             {
-                BEAST_EXPECT(!closed->exists(keylet::ticket(bob.id(), ticketSeq + i)));
+                BEAST_EXPECT(
+                    !closed->exists(keylet::ticket(bob.id(), SeqProxy::rawTicket(ticketSeq + i))));
             }
         }
     }
 
     void
-    testDest()
+    testDest(FeatureBitset features)
     {
         testcase("Destination Constraints");
 
@@ -698,7 +733,7 @@ public:
         Account const carol{"carol"};
         Account const daria{"daria"};
 
-        Env env{*this};
+        Env env{*this, features};
         env.fund(XRP(100000), alice, becky, carol);
         env.close();
 
@@ -711,6 +746,16 @@ public:
         env(fset(carol, asfRequireDest));
         env.close();
 
+        // Need to create a pseudo-account
+        Vault const vault{env};
+        auto [tx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()});
+        env(tx);
+        env.close();
+        auto const sleVault = env.le(keylet);
+        if (!BEAST_EXPECT(sleVault))
+            return;
+        Account const vaultPseudo{"vaultPseudo", sleVault->at(sfAccount)};
+
         // Close enough ledgers to be able to delete becky's account.
         incLgrSeqForAccDel(env, becky);
 
@@ -730,6 +775,10 @@ public:
         env(acctdelete(becky, alice), Fee(acctDelFee), Ter(tecNO_PERMISSION));
         env.close();
 
+        // becky attempts to delete her account using a pseudo-account as the
+        // destination, which fails since pseudo-accounts have deposit auth enabled.
+        env(acctdelete(becky, vaultPseudo), Fee(acctDelFee), Ter(tecNO_PERMISSION));
+
         // alice preauthorizes deposits from becky.  Now becky can delete her
         // account and forward the leftovers to alice.
         env(deposit::auth(alice, becky));
@@ -1076,6 +1125,7 @@ public:
     void
     run() override
     {
+        auto const all{jtx::testableAmendments()};
         testBasics();
         testDirectories();
         testOwnedTypes();
@@ -1083,7 +1133,8 @@ public:
         testImplicitlyCreatedTrustline();
         testBalanceTooSmallForFee();
         testWithTickets();
-        testDest();
+        testDest(all);
+        testDest(all - fixCleanup3_3_0);
         testDestinationDepositAuthCredentials();
         testDeleteCredentialsOwner();
     }
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index ffaf26b5a7..7e6ecfb8ca 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -54,6 +54,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -166,7 +167,7 @@ class Batch_test : public beast::unit_test::Suite
     static uint256
     getCheckIndex(AccountID const& account, std::uint32_t uSequence)
     {
-        return keylet::check(account, uSequence).key;
+        return keylet::check(account, SeqProxy::rawSequence(uSequence)).key;
     }
 
     static std::unique_ptr
@@ -648,7 +649,7 @@ class Batch_test : public beast::unit_test::Suite
             serializeBatch(
                 msg,
                 jt.stx->getAccountID(sfAccount),
-                jt.stx->getSeqValue(),
+                jt.stx->getSeqProxy().value(),
                 tfAllOrNothing,
                 jt.stx->getBatchTransactionIDs());
             finishMultiSigningData(bob.id(), msg);
@@ -3168,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));
@@ -3176,10 +3182,14 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender));
+        // Move into the Investment phase before creating loans.
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerKeylet =
+            keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
         {
-            using namespace loanBroker;
+            using namespace loan_broker;
             env(set(lender, vaultKeylet.key),
                 kManagementFeeRate(TenthBips16(100)),
                 kDebtMaximum(debtMaximumValue),
@@ -3198,7 +3208,7 @@ class Batch_test : public beast::unit_test::Suite
             auto const lenderSeq = env.seq(lender);
             auto const batchFee = batch::calcBatchFee(env, 0, 2);
 
-            auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
             {
                 auto const [txIDs, batchID] = submitBatch(
                     env,
diff --git a/src/test/app/CheckMPT_test.cpp b/src/test/app/CheckMPT_test.cpp
index 66cc582201..ffc9fb21b4 100644
--- a/src/test/app/CheckMPT_test.cpp
+++ b/src/test/app/CheckMPT_test.cpp
@@ -31,6 +31,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -653,6 +654,32 @@ class CheckMPT_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, alice) == 1);
             BEAST_EXPECT(ownerCount(env, bob) == 1);
         }
+
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(1'000), gw, alice, bob);
+
+            // MPT DeliverMin should not be capped at half of the legal range.
+            std::uint64_t constexpr deliverMin = (kMaxMpTokenAmount / 2) + 1;
+            MPT const usd = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+
+            env(pay(gw, alice, usd(deliverMin)));
+            env.close();
+
+            uint256 const chkId{getCheckIndex(alice, env.seq(alice))};
+            env(check::create(alice, bob, usd(deliverMin)));
+            env.close();
+
+            env(check::cash(bob, chkId, check::DeliverMin(usd(deliverMin))));
+            verifyDeliveredAmount(env, usd(deliverMin));
+            env.require(Balance(alice, usd(0)));
+            env.require(Balance(bob, usd(deliverMin)));
+            BEAST_EXPECT(checksOnAccount(env, alice).empty());
+            BEAST_EXPECT(checksOnAccount(env, bob).empty());
+        }
+
         {
             // Examine the effects of the asfRequireAuth flag.
             Env env(*this, features);
@@ -807,6 +834,32 @@ class CheckMPT_test : public beast::unit_test::Suite
         env.require(Balance(bob, usd(0 + 100)));
         BEAST_EXPECT(checksOnAccount(env, alice).empty());
         BEAST_EXPECT(checksOnAccount(env, bob).empty());
+
+        // With the maximum transfer fee, this is the largest output whose
+        // fee-adjusted debit is still within SendMax.
+        std::uint64_t constexpr maxDeliver = (kMaxMpTokenAmount / 3) * 2;
+        MPT const eur = MPTTester(
+            {.env = env,
+             .issuer = gw,
+             .holders = {alice, bob},
+             .transferFee = kMaxTransferFee,
+             .maxAmt = kMaxMpTokenAmount});
+
+        env(pay(gw, alice, eur(kMaxMpTokenAmount)));
+        env.close();
+
+        uint256 const chkIdMax{getCheckIndex(alice, env.seq(alice))};
+        env(check::create(alice, bob, eur(kMaxMpTokenAmount)));
+        env.close();
+
+        // The DeliverMin cap must divide SendMax by the rate before flow()
+        // computes the fee-adjusted input.
+        env(check::cash(bob, chkIdMax, check::DeliverMin(eur(maxDeliver))));
+        verifyDeliveredAmount(env, eur(maxDeliver));
+        env.require(Balance(alice, eur(1)));
+        env.require(Balance(bob, eur(maxDeliver)));
+        BEAST_EXPECT(checksOnAccount(env, alice).empty());
+        BEAST_EXPECT(checksOnAccount(env, bob).empty());
     }
 
     void
diff --git a/src/test/app/Check_test.cpp b/src/test/app/Check_test.cpp
index 840c06bd84..364f66c03a 100644
--- a/src/test/app/Check_test.cpp
+++ b/src/test/app/Check_test.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -53,7 +54,7 @@ class Check_test : public beast::unit_test::Suite
     static uint256
     getCheckIndex(AccountID const& account, std::uint32_t uSequence)
     {
-        return keylet::check(account, uSequence).key;
+        return keylet::check(account, SeqProxy::rawSequence(uSequence)).key;
     }
 
     // Helper function that returns the Checks on an account.
diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp
new file mode 100644
index 0000000000..0db9e29d27
--- /dev/null
+++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp
@@ -0,0 +1,2498 @@
+#include 
+#include 
+#include 
+#include 
+
+#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
+    testConfidentialMPTConvertEpoch(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTConvert mirror epoch");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const auditor("auditor");
+
+        // A first-time convert with no rotation leaves both mirror
+        // epochs absent.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor});
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+
+            // Both mirrors are current, so converting again is allowed and
+            // leaves the epochs untouched.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+        }
+
+        // Every remaining case needs key rotation to be enabled.
+        if (!features[featureConfidentialMPTKeyRotation])
+            return;
+
+        // A first-time convert stamps the mirrors with whatever epochs the
+        // issuance currently sits at. Only issuer key rotated in this case.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // Ten rotations, issuance's issuer epoch is 10.
+            for (int i = 0; i < 10; ++i)
+            {
+                mptAlice.generateKeyPair(alice);
+                mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+            }
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(10u, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+
+            // carol converts for the first time, and her mirrors are stamped with the current
+            // issuer epoch of 10.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 10u, std::nullopt));
+        }
+
+        // A first-time convert stamps the mirrors with whatever epochs the
+        // issuance currently sits at. Both keys rotated in this case.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // 100 rotations of both keys, so both epochs are 100.
+            for (int i = 0; i < 100; ++i)
+            {
+                mptAlice.generateKeyPair(alice);
+                mptAlice.generateKeyPair(auditor);
+                mptAlice.set({
+                    .account = alice,
+                    .issuerPubKey = mptAlice.getPubKey(alice),
+                    .auditorPubKey = mptAlice.getPubKey(auditor),
+                });
+            }
+
+            // 5 more rotations of the auditor key alone, so the auditor epoch is 105 now.
+            for (int i = 0; i < 5; ++i)
+            {
+                mptAlice.generateKeyPair(auditor);
+                mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
+            }
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(100u, 105u));
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+
+            // carol converts for the first time, and each of her mirrors is stamped with the epoch
+            // of the key it was encrypted under.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 100u, 105u));
+        }
+
+        // An issuer key rotation leaves an existing holder's issuer mirror
+        // behind, converting will be blocked until the holder's mirror is updated to the new epoch.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // carol initializes before any rotation, so her mirrors carry no epoch
+            // at all, the state every holder is in before the amendment.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+
+            // Rotate the issuer key to epoch 1.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+
+            // bob converts for the first time which is allowed when registering the key.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+
+            // carol's absent epoch reads as 0 which is stale.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 20,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+
+            // Rotate the issuer key to epoch 2, leaving bob's issuer mirror stale.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, std::nullopt));
+
+            // This is not the first time convert, and bob's issuer mirror is behind the current
+            // epoch, so the convert is rejected.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .err = tecNO_PERMISSION,
+            });
+
+            // The rejected convert leaves bob's mirrors exactly as they were.
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+
+            // carol still cannot convert.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 20,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+        }
+
+        // The auditor mirror is checked the same way, so rotating only the
+        // auditor key blocks the convert on its own, with the issuer epoch
+        // untouched.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
+
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // bob initializes his confidential balance at epoch 0, so both of his
+            // mirrors are current.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+
+            // Rotate the auditor key only, leaving bob's auditor mirror behind
+            // while his issuer mirror stays current.
+            mptAlice.generateKeyPair(auditor);
+            mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
+            BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor));
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+
+            // Carol converts for the first time, and her auditor mirror is stamped with the current
+            // auditor epoch of 1.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, 1u));
+        }
+
+        // A late auditor key registration bumps no epoch.
+        // Although both epochs are still zero, the convert is blocked
+        // because auditor mirror is missing.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor});
+
+            // Register the issuer key only.
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+            });
+
+            // The issuance has no auditor yet, so no auditor mirror is created.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .fillAuditorEncryptedAmt = false,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+
+            // Register the auditor key later, which bumps no epoch.
+            mptAlice.set({
+                .account = alice,
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt));
+
+            // bob's auditor mirror is still missing, so the convert is rejected.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 20,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+        }
+    }
+
+    void
+    testConfidentialMPTSendEpoch(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTSend mirror epoch");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const auditor("auditor");
+
+        // Two holders that both initialized after a rotation are current, so a
+        // send between them succeeds and leaves both mirrors untouched.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol});
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // Rotate the issuer key to epoch 1 before anyone holds a confidential
+            // balance.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+
+            for (auto const& holder : {bob, carol})
+            {
+                mptAlice.convert({
+                    .account = holder,
+                    .amt = 50,
+                    .holderPubKey = mptAlice.getPubKey(holder),
+                });
+                mptAlice.mergeInbox({.account = holder});
+            }
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 1u, std::nullopt));
+
+            mptAlice.send({.account = bob, .dest = carol, .amt = 10});
+
+            // The epochs are unchanged after send.
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 1u, std::nullopt));
+        }
+
+        // Either the sender or the destination being stale will be rejected.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol});
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // carol initializes at epoch 0.
+            mptAlice.convert({
+                .account = carol,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(carol),
+            });
+            mptAlice.mergeInbox({.account = carol});
+
+            // Rotate the issuer key to epoch 1, leaving carol behind.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // bob initializes after the rotation, so his mirrors are current.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({.account = bob});
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+
+            // This is rejected because the sender is the stale even though the destination is
+            // current.
+            mptAlice.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // This is rejected because the destination is the stale even though the sender is
+            // current.
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // The rejected sends leave both mirrors as they were.
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+        }
+
+        // Auditor mirror is stale, the send will be rejected.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor});
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            for (auto const& holder : {bob, carol})
+            {
+                mptAlice.convert({
+                    .account = holder,
+                    .amt = 50,
+                    .holderPubKey = mptAlice.getPubKey(holder),
+                });
+                mptAlice.mergeInbox({.account = holder});
+            }
+
+            // Rotate the auditor key only
+            mptAlice.generateKeyPair(auditor);
+            mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
+
+            mptAlice.send({
+                .account = bob,
+                .dest = carol,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            mptAlice.send({
+                .account = carol,
+                .dest = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt));
+        }
+    }
+
+    void
+    testConfidentialMPTConvertBackEpoch(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTConvertBack mirror epoch");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+
+        // A holder who initialized after a rotation is current, so converting
+        // back is allowed and leaves the epoch it was stamped with alone.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            setupConfidentialIssuance(mptAlice, alice, {bob});
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // Rotate the issuer key to epoch 1 before bob holds a confidential
+            // balance.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({.account = bob});
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+
+            mptAlice.convertBack({.account = bob, .amt = 20});
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt));
+        }
+
+        // Converting back with stale mirrors is rejected.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            setupConfidentialIssuance(mptAlice, alice, {bob});
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // bob initializes at epoch 0.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({.account = bob});
+
+            // Converting back is allowed while his mirrors are still current.
+            mptAlice.convertBack({.account = bob, .amt = 20});
+
+            // Rotate the issuer key to epoch 1, leaving bob behind.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            // The rejected convert back leaves bob's mirrors as they were.
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+        }
+
+        // Converting back with a stale auditor mirror is rejected, even if the issuer mirror is
+        // current.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor});
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            mptAlice.mergeInbox({.account = bob});
+
+            // Rotate the auditor key only, leaving bob behind on that mirror alone.
+            mptAlice.generateKeyPair(auditor);
+            mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u));
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = 10,
+                .err = tecNO_PERMISSION,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+        }
+    }
+
+    void
+    testConfidentialMPTClawbackEpoch(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTClawback mirror epoch");
+        using namespace test::jtx;
+
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+
+        std::uint32_t const clawbackFlags =
+            tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback;
+
+        // Clawback is not blocked on
+        // a stale auditor mirror.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor}, clawbackFlags);
+            mptAlice.set({
+                .account = alice,
+                .issuerPubKey = mptAlice.getPubKey(alice),
+                .auditorPubKey = mptAlice.getPubKey(auditor),
+            });
+
+            // bob initializes both mirrors at epoch 0.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // Rotate the auditor key twice, leaving bob's auditor mirror behind.
+            for (int i = 0; i < 2; ++i)
+            {
+                mptAlice.generateKeyPair(auditor);
+                mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)});
+            }
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 2u));
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+
+            mptAlice.confidentialClaw({.account = alice, .holder = bob, .amt = 50});
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, 2u));
+        }
+
+        // A holder who initialized after a rotation is clawed back successfully, and
+        // the issuer mirror is updated to the current epoch.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {}, clawbackFlags);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // Rotate the issuer key five times, issuance's issuer epoch is 5.
+            for (int i = 0; i < 5; ++i)
+            {
+                mptAlice.generateKeyPair(alice);
+                mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+            }
+
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 5u, std::nullopt));
+
+            mptAlice.confidentialClaw({.account = alice, .holder = bob, .amt = 50});
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 5u, std::nullopt));
+        }
+
+        // Clawback is not blocked on
+        // a stale issuer mirror. For now the proof cannot verify: it is checked
+        // against the key registered on the issuance, while the mirror is still
+        // encrypted under the key it was written with, and that older key is
+        // nowhere on the ledger yet. This will be added in a separate PR.
+        {
+            Env env{*this, features};
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            setupConfidentialIssuance(mptAlice, alice, {bob}, {}, clawbackFlags);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // bob initializes at epoch 0.
+            mptAlice.convert({
+                .account = bob,
+                .amt = 50,
+                .holderPubKey = mptAlice.getPubKey(bob),
+            });
+
+            // Rotate the issuer key to epoch 1, leaving bob behind.
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt));
+
+            mptAlice.confidentialClaw({
+                .account = alice,
+                .holder = bob,
+                .amt = 50,
+                .err = tecBAD_PROOF,
+            });
+
+            BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt));
+        }
+    }
+
+    void
+    testConfidentialMPTMirrorUpdatePreflight(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTMirrorUpdate preflight");
+        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, carol}});
+
+        // A well-formed 66-byte ElGamal ciphertext
+        Buffer const& validCipher = getTrivialCiphertext();
+
+        // Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer.
+        if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer])
+        {
+            mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
+            mptAlice.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = temDISABLED,
+            });
+            return;
+        }
+
+        mptAlice.create({
+            .ownerCount = 1,
+            .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+        });
+        // Issuer mode but account is not the issuer.
+        mptAlice.mirrorUpdate({
+            .account = bob,
+            .holder = carol,
+            .issuerEncryptedAmount = validCipher,
+            .err = temMALFORMED,
+        });
+
+        // Issuer mode but the holder is the same as the issuer.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = alice,
+            .issuerEncryptedAmount = validCipher,
+            .err = temMALFORMED,
+        });
+
+        // Issuer mode but holder is not provided.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .issuerEncryptedAmount = validCipher,
+            .err = temMALFORMED,
+        });
+
+        // At least one of issuer or auditor amount must be present.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .err = temMALFORMED,
+        });
+
+        // Issuer amount has the wrong length.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = gMakeZeroBuffer(10),
+            .err = temBAD_CIPHERTEXT,
+        });
+
+        // Auditor amount has the wrong length.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .auditorEncryptedAmount = gMakeZeroBuffer(10),
+            .err = temBAD_CIPHERTEXT,
+        });
+
+        // The proof has the wrong length.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = validCipher,
+            .zkProof = gMakeZeroBuffer(kEcEqualityProofLength - 1),
+            .err = temMALFORMED,
+        });
+
+        // Issuer amount is the right length but not a valid ciphertext.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = getBadCiphertext(),
+            .err = temBAD_CIPHERTEXT,
+        });
+
+        // Auditor amount is the right length but not a valid ciphertext.
+        mptAlice.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = validCipher,
+            .auditorEncryptedAmount = getBadCiphertext(),
+            .err = temBAD_CIPHERTEXT,
+        });
+    }
+
+    void
+    testConfidentialMPTMirrorUpdatePreclaim(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTMirrorUpdate preclaim");
+        using namespace test::jtx;
+
+        Buffer const& validCipher = getTrivialCiphertext();
+
+        // The issuance does not exist.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+
+            mptAlice.create({
+                .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
+            });
+            // Destroy the issuance to test issuance not found.
+            mptAlice.destroy();
+
+            mptAlice.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // The issuance has not enabled confidential balances.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
+
+            mptAlice.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // The issuer encryption key was not already registered.
+        {
+            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.authorize({.account = bob});
+
+            mptAlice.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // In issuer mode, the specified holder account does not exist.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const carol("carol");
+            MPTTester mptAlice(env, alice);
+            mptAlice.create(
+                {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            // Carol never got funded so it does not exist.
+            mptAlice.mirrorUpdate({
+                .account = alice,
+                .holder = carol,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_TARGET,
+            });
+        }
+
+        // The holder's MPToken does not exist (holder never authorized).
+        {
+            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.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecOBJECT_NOT_FOUND,
+            });
+        }
+
+        // The holder has an MPToken but no confidential issuer balance (sfIssuerEncryptedBalance).
+        {
+            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.authorize({.account = bob});
+            mptAlice.generateKeyPair(alice);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)});
+
+            mptAlice.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Auditor mirror migration on an issuance with no auditor key.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+
+            // This setup has issuer key but no auditor key.
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Issuer mirror is already most up-to-date so
+        // there is nothing to migrate.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Issuer-mode auditor-only migration while the issuer mirror is stale:
+        // the issuer mirror must be brought up to date before the auditor
+        // mirror can be migrated.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newIssuerKey("newIssuerKey");
+
+            // Issuance has both an issuer key and an auditor key, and bob holds
+            // both mirrors at epoch 0.
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate the issuer key: issuer key epoch 0 -> 1, while bob's
+            // issuer-mirror epoch stays 0 (stale).
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Auditor mirror is already current (the auditor key has not rotated),
+        // so there is nothing to migrate.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+
+            // Issuance has both keys and bob holds both mirrors at epoch 0.
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // No key has rotated, so the auditor mirror is up to date
+            // so there is nothing to migrate.
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // In an issuer-mode simultaneous migration, both mirrors must be stale. Here
+        // only the issuer key has rotated so its mirror is stale but the auditor mirror is not.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newIssuerKey("newIssuerKey");
+
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key
+            // epoch stays 0. The issuer mirror is now stale but the auditor
+            // mirror is still current.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // In an issuer-mode simultaneous migration, both mirrors must be stale.
+        // Here only the auditor key has rotated so its mirror is stale but the
+        // issuer mirror is not.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newAuditorKey("newAuditorKey");
+
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key
+            // epoch stays 0. The auditor mirror is now stale but the issuer
+            // mirror is still current.
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)});
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Holder self-migration mode runs the same staleness checks.
+        // No key has rotated, so the holder's own issuer mirror is current and
+        // there is nothing to migrate.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Holder self-migration mode, simultaneously migrating both keys: only the issuer key
+        // has rotated, so the holder's issuer mirror is stale but the auditor
+        // mirror is still current.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newIssuerKey("newIssuerKey");
+
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key
+            // epoch stays 0.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            // Holder mode (no Holder field) needs no previous issuer key.
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Holder self-migration mode, simultaneously migrating both keys:
+        // only the auditor key has rotated, so the holder's auditor mirror is stale but the issuer
+        // mirror is still current.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newAuditorKey("newAuditorKey");
+
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key
+            // epoch stays 0.
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)});
+
+            // Auditor mirror is stale but issuer mirror is current so this is rejected.
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .auditorEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Holder self-migration requires the holder's inbox to be canonical
+        // zero, because the cross-key equality proof anchors on the spending
+        // balance, which only reflects the full balance after the inbox is
+        // merged. A holder with a non-zero inbox is rejected.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const newIssuerKey("newIssuerKey");
+
+            ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}};
+
+            // Carol sends Bob a confidential amount; Bob does NOT merge it, so
+            // his inbox is no longer canonical zero.
+            ct.mpt.send({.account = carol, .dest = bob, .amt = 10});
+
+            // Rotate the issuer key so the issuer mirror is stale and the
+            // migration gets past the epoch check to reach the inbox check.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tecNO_PERMISSION,
+            });
+
+            // Merging the inbox makes the migration succeed.
+            ct.mpt.mergeInbox({.account = bob});
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tesSUCCESS,
+            });
+        }
+
+        // A lock does not block a migration.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const carol("carol");
+            Account const newIssuerKey("newIssuerKey");
+            Account const newerIssuerKey("newerIssuerKey");
+
+            ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}};
+            ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock});
+            ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTLock});
+
+            // Rotate the issuer key so both holders' mirrors are stale.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            // The issuer migrates an individually locked holder.
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tesSUCCESS,
+            });
+
+            // An individually locked holder migrates itself.
+            ct.mpt.mirrorUpdate({
+                .account = carol,
+                .issuerEncryptedAmount = validCipher,
+                .err = tesSUCCESS,
+            });
+
+            // Release the individual locks and lock the whole issuance instead. Rotate again so
+            // both mirrors are stale once more.
+            ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTUnlock});
+            ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTUnlock});
+            ct.mpt.set({.account = alice, .flags = tfMPTLock});
+            ct.mpt.generateKeyPair(newerIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newerIssuerKey)});
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = validCipher,
+                .err = tesSUCCESS,
+            });
+
+            ct.mpt.mirrorUpdate({
+                .account = carol,
+                .issuerEncryptedAmount = validCipher,
+                .err = tesSUCCESS,
+            });
+        }
+    }
+
+    void
+    testConfidentialMPTMirrorUpdateDoApply(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTMirrorUpdate doApply");
+        using namespace test::jtx;
+
+        // The holder's confidential balance, matching the ConfidentialEnv default
+        // convertAmount. The migration re-encrypts this amount under the new key.
+        std::uint64_t const amount = 100;
+
+        // Issuer mode issuer-mirror migration. The new issuer mirror is written
+        // and the auditor mirror epoch advances to the issuer key epoch.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const newIssuerKey("newIssuerKey");
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            // Rotate the issuer key: issuer key epoch 0 -> 1.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            // Re-encrypt Bob's balance under the new issuer key.
+            Buffer const newIssuerCipher =
+                ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor());
+
+            // The previous issuer key is the pre-rotation issuer key (alice's),
+            // no longer on-ledger after the rotation, provide it in the transaction.
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = newIssuerCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u);
+
+            // The issuer mirror is now current, so re-migrating it is rejected.
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = newIssuerCipher,
+                .err = tecNO_PERMISSION,
+            });
+        }
+
+        // Issuer mode auditor-mirror migration. The new auditor mirror is written
+        // and the auditor mirror epoch advances to the auditor key epoch.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newAuditorKey("newAuditorKey");
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate only the auditor key: auditor key epoch 0 -> 1.
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)});
+
+            // Re-encrypt Bob's balance under the new auditor key.
+            Buffer const newAuditorCipher =
+                ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor());
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .auditorEncryptedAmount = newAuditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u);
+        }
+
+        // Issuer mode simultaneous migration: both mirrors are written in one transaction and
+        // both epochs advance.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newIssuerKey("newIssuerKey");
+            Account const newAuditorKey("newAuditorKey");
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            // Rotate both keys: both key epochs 0 -> 1.
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({
+                .account = alice,
+                .issuerPubKey = ct.mpt.getPubKey(newIssuerKey),
+                .auditorPubKey = ct.mpt.getPubKey(newAuditorKey),
+            });
+
+            // Re-encrypt Bob's balance under each new key.
+            Buffer const bf = generateBlindingFactor();
+            Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf);
+            Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf);
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .issuerEncryptedAmount = newIssuerCipher,
+                .auditorEncryptedAmount = newAuditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u);
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u);
+        }
+
+        // Issuer mode auditor late-registration: the auditor key is registered for the first
+        // time (key epoch absent), so setting the initial auditor mirror leaves
+        // the auditor mirror epoch absent as well.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            // No auditor in the confidential setup, so bob has no auditor mirror.
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            // Register an auditor key for the first time (auditor key epoch stays
+            // absent).
+            ct.mpt.generateKeyPair(auditor);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)});
+
+            // Encrypt Bob's balance under the newly registered auditor key.
+            Buffer const auditorCipher =
+                ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor());
+
+            ct.mpt.mirrorUpdate({
+                .account = alice,
+                .holder = bob,
+                .auditorEncryptedAmount = auditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher));
+            // First-time registration leaves the mirror epoch absent (== 0).
+            BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch));
+        }
+
+        // Holder self-migration migrates from the holder's own spending balance
+        // (Holder being Account field, no Holder field, and no previous issuer key in any flow
+        // because the anchor is the spending balance, not the old issuer mirror). ConfidentialEnv
+        // already merged the inbox so the holder's inbox is canonical zero.
+
+        // Holder issuer-mirror migration.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const newIssuerKey("newIssuerKey");
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)});
+
+            // The holder re-encrypts their own balance under the new issuer key.
+            Buffer const newIssuerCipher =
+                ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor());
+
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = newIssuerCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u);
+        }
+
+        // Holder auditor-mirror migration.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newAuditorKey("newAuditorKey");
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)});
+
+            // The holder re-encrypts their own balance under the new auditor key.
+            Buffer const newAuditorCipher =
+                ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor());
+
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .auditorEncryptedAmount = newAuditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u);
+        }
+
+        // Holder simultaneous migration of both mirrors.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            Account const newIssuerKey("newIssuerKey");
+            Account const newAuditorKey("newAuditorKey");
+            ConfidentialEnv ct{
+                env,
+                alice,
+                {{.account = bob}},
+                tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+                auditor};
+
+            ct.mpt.generateKeyPair(newIssuerKey);
+            ct.mpt.generateKeyPair(newAuditorKey);
+            ct.mpt.set({
+                .account = alice,
+                .issuerPubKey = ct.mpt.getPubKey(newIssuerKey),
+                .auditorPubKey = ct.mpt.getPubKey(newAuditorKey),
+            });
+
+            Buffer const bf = generateBlindingFactor();
+            Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf);
+            Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf);
+
+            // Holder mode needs no previous issuer key even for the issuer mirror.
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .issuerEncryptedAmount = newIssuerCipher,
+                .auditorEncryptedAmount = newAuditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u);
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u);
+        }
+
+        // Holder auditor late-registration: the auditor key is registered for the first time (key
+        // epoch absent), so the holder setting their initial auditor mirror leaves the auditor
+        // mirror epoch absent as well.
+        {
+            Env env{*this, features};
+            Account const alice("alice");
+            Account const bob("bob");
+            Account const auditor("auditor");
+            // No auditor in the confidential setup, so bob has no auditor mirror.
+            ConfidentialEnv ct{env, alice, {{.account = bob}}};
+
+            // Register an auditor key for the first time (auditor key epoch stays
+            // absent).
+            ct.mpt.generateKeyPair(auditor);
+            ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)});
+
+            // The holder encrypts their own balance under the newly registered auditor key.
+            Buffer const auditorCipher =
+                ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor());
+
+            ct.mpt.mirrorUpdate({
+                .account = bob,
+                .auditorEncryptedAmount = auditorCipher,
+            });
+
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher));
+            // First-time registration leaves the mirror epoch absent.
+            BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch));
+        }
+    }
+
+    void
+    testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTMirrorUpdate issuer migrates after several rotations");
+        using namespace test::jtx;
+
+        std::uint64_t const amount = 100;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        Account const issuerKey1("issuerKey1");
+        Account const issuerKey2("issuerKey2");
+        Account const issuerKey3("issuerKey3");
+        Account const issuerKey4("issuerKey4");
+        Account const issuerKey5("issuerKey5");
+        Account const auditorKey1("auditorKey1");
+        Account const auditorKey2("auditorKey2");
+        Account const auditorKey3("auditorKey3");
+        Account const auditorKey4("auditorKey4");
+        ConfidentialEnv ct{
+            env,
+            alice,
+            {{.account = bob}},
+            tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+            auditor};
+
+        // Rotate the issuer key three times: issuer key epoch 0 -> 3. Bob never
+        // migrates in between, so his issuer mirror stays at mirror epoch 0 and
+        // is still encrypted under the original issuer key (alice's).
+        ct.mpt.generateKeyPair(issuerKey1);
+        ct.mpt.generateKeyPair(issuerKey2);
+        ct.mpt.generateKeyPair(issuerKey3);
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)});
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)});
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)});
+
+        {
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID()));
+            BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u);
+        }
+
+        // A single migration re-encrypts the mirror under the newest key and
+        // jumps the mirror epoch straight to the current key epoch (3), rather
+        // than advancing one rotation at a time. The previous issuer key is the
+        // original key (alice's) that the stale mirror is still encrypted under,
+        // not any intermediate rotation.
+        Buffer const newIssuerCipher =
+            ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = newIssuerCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u);
+        }
+
+        // The issuer mirror is now current (epoch 3 == key epoch 3), so a second
+        // issuer migration is rejected.
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = newIssuerCipher,
+            .err = tecNO_PERMISSION,
+        });
+
+        // Now rotate the auditor key twice: auditor key epoch 0 -> 2. Bob's
+        // auditor mirror is still at mirror epoch 0, under the original auditor
+        // key. The issuer key and its epoch are untouched.
+        ct.mpt.generateKeyPair(auditorKey1);
+        ct.mpt.generateKeyPair(auditorKey2);
+        ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)});
+        ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)});
+
+        {
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID()));
+            BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 2u);
+            BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u);
+        }
+
+        // A single auditor-only migration jumps the auditor mirror epoch straight
+        // to the current auditor key epoch (2). This is an issuer-mode
+        // auditor-only migration, which is allowed because the issuer mirror is
+        // already current; no previous issuer key is needed for an auditor
+        // migration.
+        Buffer const newAuditorCipher =
+            ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .auditorEncryptedAmount = newAuditorCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u);
+            // The issuer mirror and its epoch are unaffected by the auditor
+            // migration.
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u);
+        }
+
+        // The auditor mirror is now current (epoch 2 == key epoch 2), so a second
+        // auditor migration is rejected.
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .auditorEncryptedAmount = newAuditorCipher,
+            .err = tecNO_PERMISSION,
+        });
+
+        // Now rotate BOTH keys together twice: issuer key epoch 3 -> 5, auditor
+        // key epoch 2 -> 4. Bob's mirrors stay at epoch 3 / 2 (stale again).
+        ct.mpt.generateKeyPair(issuerKey4);
+        ct.mpt.generateKeyPair(issuerKey5);
+        ct.mpt.generateKeyPair(auditorKey3);
+        ct.mpt.generateKeyPair(auditorKey4);
+        ct.mpt.set({
+            .account = alice,
+            .issuerPubKey = ct.mpt.getPubKey(issuerKey4),
+            .auditorPubKey = ct.mpt.getPubKey(auditorKey3),
+        });
+        ct.mpt.set({
+            .account = alice,
+            .issuerPubKey = ct.mpt.getPubKey(issuerKey5),
+            .auditorPubKey = ct.mpt.getPubKey(auditorKey4),
+        });
+
+        {
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID()));
+            BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 5u);
+            BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 4u);
+        }
+
+        // A single simultaneous migration brings both mirrors current in one
+        // transaction: issuer mirror epoch 3 -> 5, auditor mirror epoch 2 -> 4.
+        // The previous issuer key is issuerKey3, which is the key Bob's current
+        // (stale) issuer mirror is encrypted under after the earlier issuer
+        // migration, not alice's original key nor any intermediate rotation.
+        Buffer const bothIssuerCipher =
+            ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor());
+        Buffer const bothAuditorCipher =
+            ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = bothIssuerCipher,
+            .auditorEncryptedAmount = bothAuditorCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher));
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u);
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u);
+        }
+
+        // Both mirrors are current now, so a second simultaneous migration is
+        // rejected.
+        ct.mpt.mirrorUpdate({
+            .account = alice,
+            .holder = bob,
+            .issuerEncryptedAmount = bothIssuerCipher,
+            .auditorEncryptedAmount = bothAuditorCipher,
+            .err = tecNO_PERMISSION,
+        });
+    }
+
+    void
+    testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(FeatureBitset features)
+    {
+        testcase("ConfidentialMPTMirrorUpdate holder migrates after several rotations");
+        using namespace test::jtx;
+
+        std::uint64_t const amount = 100;
+
+        Env env{*this, features};
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const auditor("auditor");
+        Account const issuerKey1("issuerKey1");
+        Account const issuerKey2("issuerKey2");
+        Account const issuerKey3("issuerKey3");
+        Account const issuerKey4("issuerKey4");
+        Account const issuerKey5("issuerKey5");
+        Account const auditorKey1("auditorKey1");
+        Account const auditorKey2("auditorKey2");
+        Account const auditorKey3("auditorKey3");
+        Account const auditorKey4("auditorKey4");
+        ConfidentialEnv ct{
+            env,
+            alice,
+            {{.account = bob}},
+            tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer,
+            auditor};
+
+        // In holder self-migration mode the holder submits (account = bob, no
+        // Holder field) and never provides a previous issuer key.
+        // Bob's inbox is canonical zero after the ConfidentialEnv merge.
+
+        // Rotate the issuer key three times: issuer key epoch 0 -> 3.
+        ct.mpt.generateKeyPair(issuerKey1);
+        ct.mpt.generateKeyPair(issuerKey2);
+        ct.mpt.generateKeyPair(issuerKey3);
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)});
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)});
+        ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)});
+
+        // A single holder migration jumps the issuer mirror epoch straight to 3.
+        Buffer const newIssuerCipher =
+            ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .issuerEncryptedAmount = newIssuerCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u);
+        }
+
+        // The issuer mirror is current, so a second holder issuer migration is
+        // rejected.
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .issuerEncryptedAmount = newIssuerCipher,
+            .err = tecNO_PERMISSION,
+        });
+
+        // Rotate the auditor key twice: auditor key epoch 0 -> 2.
+        ct.mpt.generateKeyPair(auditorKey1);
+        ct.mpt.generateKeyPair(auditorKey2);
+        ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)});
+        ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)});
+
+        // A single holder auditor migration jumps the auditor mirror epoch to 2.
+        Buffer const newAuditorCipher =
+            ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .auditorEncryptedAmount = newAuditorCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u);
+            // The issuer mirror is unaffected.
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u);
+        }
+
+        // The auditor mirror is current, so a second holder auditor migration is
+        // rejected.
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .auditorEncryptedAmount = newAuditorCipher,
+            .err = tecNO_PERMISSION,
+        });
+
+        // Rotate both keys together twice: issuer key epoch 3 -> 5, auditor key
+        // epoch 2 -> 4.
+        ct.mpt.generateKeyPair(issuerKey4);
+        ct.mpt.generateKeyPair(issuerKey5);
+        ct.mpt.generateKeyPair(auditorKey3);
+        ct.mpt.generateKeyPair(auditorKey4);
+        ct.mpt.set({
+            .account = alice,
+            .issuerPubKey = ct.mpt.getPubKey(issuerKey4),
+            .auditorPubKey = ct.mpt.getPubKey(auditorKey3),
+        });
+        ct.mpt.set({
+            .account = alice,
+            .issuerPubKey = ct.mpt.getPubKey(issuerKey5),
+            .auditorPubKey = ct.mpt.getPubKey(auditorKey4),
+        });
+
+        // A single holder migration brings both mirrors current: issuer mirror
+        // epoch 3 -> 5, auditor mirror epoch 2 -> 4. Still no previous issuer key.
+        Buffer const bothIssuerCipher =
+            ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor());
+        Buffer const bothAuditorCipher =
+            ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor());
+
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .issuerEncryptedAmount = bothIssuerCipher,
+            .auditorEncryptedAmount = bothAuditorCipher,
+        });
+
+        {
+            auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher));
+            BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher));
+            BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u);
+            BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u);
+        }
+
+        // Both mirrors are current, so a second holder migration is rejected.
+        ct.mpt.mirrorUpdate({
+            .account = bob,
+            .issuerEncryptedAmount = bothIssuerCipher,
+            .auditorEncryptedAmount = bothAuditorCipher,
+            .err = tecNO_PERMISSION,
+        });
+    }
+
+public:
+    void
+    testMPTokenIssuanceSetWithFeats(FeatureBitset features)
+    {
+        testMPTokenIssuanceSetRotateIssuerKey(features);
+        testMPTokenIssuanceSetRotateBothKeys(features);
+        testMPTokenIssuanceSetRotateAuditorKeyOnly(features);
+        testMPTokenIssuanceSetRegisterAuditorKeyLater(features);
+        testMPTokenIssuanceSetRegisterAuditorKeyLaterWithCOA(features);
+        testMPTokenIssuanceSetAuditorKeyWithoutIssuerKey(features);
+        testMPTokenIssuanceSetRotateWithCOA(features);
+        testMPTokenIssuanceSetKeyEpochAtMax(features);
+    }
+
+    void
+    run() override
+    {
+        using namespace test::jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        testMPTokenIssuanceSetWithFeats(all);
+        testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation);
+
+        testConfidentialMPTConvertEpoch(all);
+        testConfidentialMPTConvertEpoch(all - featureConfidentialMPTKeyRotation);
+        testConfidentialMPTSendEpoch(all);
+        testConfidentialMPTConvertBackEpoch(all);
+        testConfidentialMPTClawbackEpoch(all);
+
+        testConfidentialMPTMirrorUpdatePreflight(all);
+        testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialMPTKeyRotation);
+        testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialTransfer);
+        testConfidentialMPTMirrorUpdatePreclaim(all);
+        testConfidentialMPTMirrorUpdateDoApply(all);
+        testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all);
+        testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ConfidentialMPTKeyRotation, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/ConfidentialTransferExtended_test.cpp b/src/test/app/ConfidentialTransferExtended_test.cpp
index 953325a6e9..fe5e0b3064 100644
--- a/src/test/app/ConfidentialTransferExtended_test.cpp
+++ b/src/test/app/ConfidentialTransferExtended_test.cpp
@@ -1653,30 +1653,35 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         mptAlice.generateKeyPair(carol);
         mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
 
-        // Bob delegates Convert, MergeInbox to dave.
-        env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"}));
+        // ConfidentialMPTConvert is not delegable: attempting to grant it as a
+        // delegated permission is rejected at preflight of DelegateSet.
+        env(delegate::set(bob, dave, {"ConfidentialMPTConvert"}), Ter(temMALFORMED));
         env.close();
 
-        // Carol has no permission from bob to convert on his behalf.
+        // Bob delegates MergeInbox to dave.
+        env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"}));
+        env.close();
+
+        // A Convert carrying a Delegate is rejected at preflight because the
+        // transaction type is not delegable at all.
         mptAlice.convert({
             .account = bob,
             .amt = 10,
             .holderPubKey = mptAlice.getPubKey(bob),
-            .delegate = carol,
-            .err = terNO_DELEGATE_PERMISSION,
+            .delegate = dave,
+            .err = temINVALID,
         });
 
-        // Dave executes Convert on behalf of bob, registering bob's key.
+        // Bob converts, registering bob's key.
         mptAlice.convert({
             .account = bob,
             .amt = 100,
             .holderPubKey = mptAlice.getPubKey(bob),
-            .delegate = dave,
         });
         env.require(MptBalance(mptAlice, bob, 100));
 
-        // Dave executes Convert again on behalf of bob (no key registration).
-        mptAlice.convert({.account = bob, .amt = 50, .delegate = dave});
+        // Bob converts again (no key registration).
+        mptAlice.convert({.account = bob, .amt = 50});
 
         // Dave executes MergeInbox on behalf of bob.
         mptAlice.mergeInbox({.account = bob, .delegate = dave});
@@ -1698,10 +1703,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
              .err = terNO_DELEGATE_PERMISSION});
 
         // Bob delegates ConfidentialMPTSend to dave.
-        env(delegate::set(
-            bob,
-            dave,
-            {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox", "ConfidentialMPTSend"}));
+        env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend"}));
         env.close();
 
         // Dave executes Send on behalf of bob.
@@ -1716,10 +1718,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         env(delegate::set(
             bob,
             dave,
-            {"ConfidentialMPTConvert",
-             "ConfidentialMPTMergeInbox",
-             "ConfidentialMPTSend",
-             "ConfidentialMPTConvertBack"}));
+            {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
         env.close();
 
         // Dave executes ConvertBack on behalf of bob.
@@ -1766,16 +1765,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
 
         // Creating the Delegate SLE consumes one owner reserve slot for bob.
         auto const bobOwnersBefore = ownerCount(env, bob);
-        env(delegate::set(bob, carol, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"}));
+        env(delegate::set(bob, carol, {"ConfidentialMPTMergeInbox"}));
         env.close();
         env.require(Owners(bob, bobOwnersBefore + 1));
 
-        // Carol converts and merge inbox on behalf of bob.
+        // Bob converts; carol merges inbox on behalf of bob.
         mptAlice.convert({
             .account = bob,
             .amt = 50,
             .holderPubKey = mptAlice.getPubKey(bob),
-            .delegate = carol,
         });
         mptAlice.mergeInbox({.account = bob, .delegate = carol});
 
@@ -1784,16 +1782,18 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         env.close();
         env.require(Owners(bob, bobOwnersBefore));
 
-        // Carol can no longer convert on behalf of bob.
-        mptAlice.convert({
+        // Bob converts again to populate a fresh inbox.
+        mptAlice.convert({.account = bob, .amt = 30});
+
+        // Carol can no longer merge inbox on behalf of bob.
+        mptAlice.mergeInbox({
             .account = bob,
-            .amt = 30,
             .delegate = carol,
             .err = terNO_DELEGATE_PERMISSION,
         });
 
-        // Bob can still convert by himself.
-        mptAlice.convert({.account = bob, .amt = 30});
+        // Bob can still merge his inbox.
+        mptAlice.mergeInbox({.account = bob});
     }
 
     // Verifies that a delegated confidential transfer works correctly when an
@@ -1833,16 +1833,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
             .auditorPubKey = mptAlice.getPubKey(auditor),
         });
 
-        // Bob delegates Convert and Send permissions to dave.
-        env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"}));
+        // Bob delegates Send permission to dave (Convert is not delegable).
+        env(delegate::set(bob, dave, {"ConfidentialMPTSend"}));
         env.close();
 
-        // Dave converts on behalf of bob.
+        // Bob converts.
         mptAlice.convert({
             .account = bob,
             .amt = 50,
             .holderPubKey = mptAlice.getPubKey(bob),
-            .delegate = dave,
         });
         mptAlice.mergeInbox({.account = bob});
 
@@ -2229,7 +2228,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         mpt.pay(alice, frank, 40);
         mpt.generateKeyPair(frank);
 
-        env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTConvertBack"}));
+        env(delegate::set(bob, dave, {"ConfidentialMPTConvertBack"}));
         env(delegate::set(carol, erin, {"ConfidentialMPTSend"}));
         env(delegate::set(bob, erin, {"ConfidentialMPTMergeInbox"}));
         env.close();
@@ -2238,15 +2237,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         auto const bobSeq = env.seq(bob);
         auto const carolSeq = env.seq(carol);
         auto const frankSeq = env.seq(frank);
-        auto const batchFee = batch::calcConfidentialBatchFee(env, 3, 6);
+        auto const batchFee = batch::calcConfidentialBatchFee(env, 4, 6);
 
-        // Dave submits the batch. Bob's convert and convertback use Dave as Delegate;
+        // Dave submits the batch. Bob's convertback uses Dave as Delegate;
+        // Convert is not delegable, so Bob signs his own convert inner tx.
         // Carol's send and Bob's mergeInbox use Erin as Delegate. Frank's
         // convert and mergeInbox are non-delegated.
         auto jv1 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq);
         jv1[jss::Delegate] = dave.human();
-        auto jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1);
-        jv2[jss::Delegate] = dave.human();
+        auto const jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1);
         auto jv3 = mpt.sendJV({.account = carol, .dest = bob, .amt = 15}, carolSeq);
         jv3[jss::Delegate] = erin.human();
         auto const jv4 = mpt.convertJV(
@@ -2262,7 +2261,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
             batch::Inner(jv4, frankSeq),
             batch::Inner(jv5, frankSeq + 1),
             batch::Inner(jv6, bobSeq + 2),
-            batch::Sig(erin, frank),
+            batch::Sig(erin, frank, bob),
             Ter(tesSUCCESS));
         env.close();
 
@@ -2283,7 +2282,10 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         BEAST_EXPECT(mpt.getIssuanceConfidentialBalance() == 175);
     }
 
-    // Test invalid scenarios for delegation with tickets.
+    // Test invalid scenarios for delegation with tickets. ConfidentialMPTConvert
+    // is not delegable, so ConfidentialMPTConvertBack (which is delegable and
+    // whose ZK proof also binds to the transaction/ticket sequence) is used as
+    // the delegated operation. Carol acts as bob's delegate throughout.
     void
     testInvalidDelegationWithTickets(FeatureBitset features)
     {
@@ -2309,33 +2311,52 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         mptAlice.generateKeyPair(bob);
         mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
 
-        // Bob grants carol permissions.
-        env(delegate::set(bob, carol, {"ConfidentialMPTConvert"}));
+        // Give bob a confidential spending balance to convert back from.
+        mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)});
+        mptAlice.mergeInbox({.account = bob});
+
+        // Bob delegates ConfidentialMPTConvertBack to carol.
+        env(delegate::set(bob, carol, {"ConfidentialMPTConvertBack"}));
         env.close();
 
         uint64_t const amt = 10;
-        auto const bf = generateBlindingFactor();
-        auto const holderCt = mptAlice.encryptAmount(bob, amt, bf);
-        auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf);
+
+        // Every case below fails, so bob's spending balance and version never
+        // change; capture the crypto material needed to build proofs once.
+        auto const spendingBalance = requireOptional(
+            mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing spending balance.");
+        auto const encSpending = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing encrypted spending balance.");
+        auto const version = mptAlice.getMPTokenVersion(bob);
+        auto const pcBf = generateBlindingFactor();
+        auto const pc = mptAlice.getPedersenCommitment(spendingBalance, pcBf);
+
+        // Build a ConvertBack proof bound to a given sequence.
+        auto proofForSeq = [&](std::uint32_t seq) {
+            return mptAlice.getConvertBackProof(
+                bob,
+                amt,
+                getConvertBackContextHash(bob, mptAlice.issuanceID(), seq, version),
+                {
+                    .pedersenCommitment = pc,
+                    .amt = spendingBalance,
+                    .encryptedAmt = encSpending,
+                    .blindingFactor = pcBf,
+                });
+        };
 
         // Invalid: proof built with wrong ticket sequence (ticketSeq + 1).
         {
             auto const ticketSeq = env.seq(bob) + 1;
             env(ticket::create(bob, 1));
 
-            auto const badCtxHash =
-                getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1);
-            auto const badProof = requireOptional(
-                mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
-
-            mptAlice.convert({
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .proof = strHex(badProof),
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
+                .proof = proofForSeq(ticketSeq + 1),
+                .pedersenCommitment = pc,
                 .delegate = carol,
                 .ticketSeq = ticketSeq,
                 .err = tecBAD_PROOF,
@@ -2346,18 +2367,12 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         {
             auto const ticketSeq = env.seq(bob) + 1;
             env(ticket::create(bob, 1));
-            auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob));
-            auto const badProof = requireOptional(
-                mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof.");
 
-            mptAlice.convert({
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .proof = strHex(badProof),
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
+                .proof = proofForSeq(env.seq(bob)),
+                .pedersenCommitment = pc,
                 .delegate = carol,
                 .ticketSeq = ticketSeq,
                 .err = tecBAD_PROOF,
@@ -2366,13 +2381,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
 
         // Invalid: ticket sequence is far in the future and hasn't been created yet.
         {
-            mptAlice.convert({
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
                 .delegate = carol,
                 .ticketSeq = env.seq(bob) + 100,
                 .err = terPRE_TICKET,
@@ -2381,13 +2392,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
 
         // Invalid: ticket sequence is in the past but was never created.
         {
-            mptAlice.convert({
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
                 .delegate = carol,
                 .ticketSeq = 1,
                 .err = tefNO_TICKET,
@@ -2395,17 +2402,14 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         }
 
         // Invalid: the delegated account, carol, creates a ticket and uses it.
+        // The ticket must belong to the delegator (bob), not the delegate.
         {
             auto const carolTicketSeq = env.seq(carol) + 1;
             env(ticket::create(carol, 1));
 
-            mptAlice.convert({
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
                 .delegate = carol,
                 .ticketSeq = carolTicketSeq,
                 .err = tefNO_TICKET,
@@ -2418,25 +2422,31 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
             auto const ticketSeq = env.seq(bob) + 1;
             env(ticket::create(bob, 1));
 
-            // Build proof using ticketSeq.
-            auto const ctxHashForTicket =
-                getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq);
-            auto const proof = requireOptional(
-                mptAlice.getSchnorrProof(bob, ctxHashForTicket), "Missing Schnorr Proof.");
-
-            // Submit without ticket.
-            mptAlice.convert({
+            // Submit without a ticket; proof is bound to ticketSeq.
+            mptAlice.convertBack({
                 .account = bob,
                 .amt = amt,
-                .proof = strHex(proof),
-                .holderPubKey = mptAlice.getPubKey(bob),
-                .holderEncryptedAmt = holderCt,
-                .issuerEncryptedAmt = issuerCt,
-                .blindingFactor = bf,
+                .proof = proofForSeq(ticketSeq),
+                .pedersenCommitment = pc,
                 .delegate = carol,
                 .err = tecBAD_PROOF,
             });
         }
+
+        // Valid: carol converts back on bob's behalf using a ticket owned by bob,
+        // with a proof correctly bound to that ticket sequence. bob's spending
+        // balance drops from 100 to 90.
+        {
+            auto const ticketSeq = env.seq(bob) + 1;
+            env(ticket::create(bob, 1));
+
+            mptAlice.convertBack({
+                .account = bob,
+                .amt = amt,
+                .delegate = carol,
+                .ticketSeq = ticketSeq,
+            });
+        }
     }
 
     // Verifies that delegation works correctly when the delegating account uses
@@ -2471,19 +2481,16 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
         mptAlice.generateKeyPair(carol);
         mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)});
 
-        // Bob grants dave permissions.
+        // Bob grants dave permissions (Convert is not delegable).
         env(delegate::set(
             bob,
             dave,
-            {"ConfidentialMPTConvert",
-             "ConfidentialMPTMergeInbox",
-             "ConfidentialMPTSend",
-             "ConfidentialMPTConvertBack"}));
+            {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"}));
         // Alice grants dave permission to clawback on her behalf.
         env(delegate::set(alice, dave, {"ConfidentialMPTClawback"}));
         env.close();
 
-        // Dave executes Convert on behalf of bob using ticket.
+        // Bob converts using a ticket.
         auto ticketSeq = env.seq(bob) + 1;
         env(ticket::create(bob, 1));
         BEAST_EXPECT(env.seq(bob) != ticketSeq);
@@ -2491,7 +2498,6 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase
             .account = bob,
             .amt = 100,
             .holderPubKey = mptAlice.getPubKey(bob),
-            .delegate = dave,
             .ticketSeq = ticketSeq,
         });
         env.require(MptBalance(mptAlice, bob, 100));
diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp
index d3e0182db5..9ff9270a7d 100644
--- a/src/test/app/ConfidentialTransfer_test.cpp
+++ b/src/test/app/ConfidentialTransfer_test.cpp
@@ -616,7 +616,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
 
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
             });
         }
 
@@ -637,7 +637,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
 
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .issuerPubKey = mptAlice.getPubKey(alice),
                 .auditorPubKey = mptAlice.getPubKey(auditor),
             });
@@ -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");
@@ -880,11 +877,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Account const alice("alice");
             MPTTester mptAlice(env, alice, {.holders = {}});
 
-            // Create with tmfMPTCannotEnableCanHoldConfidentialBalance
+            // Create with tifMPTCanHoldConfidentialBalance
             mptAlice.create({
                 .ownerCount = 1,
                 .flags = tfMPTCanTransfer | tfMPTCanLock,
-                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .immutableFlags = tifMPTCanHoldConfidentialBalance,
             });
 
             mptAlice.generateKeyPair(alice);
@@ -893,15 +890,16 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             // because the issuance cannot mutate canConfidentialAmount
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .issuerPubKey = mptAlice.getPubKey(alice),
                 .err = tecNO_PERMISSION,
             });
         }
 
         // 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});
@@ -965,15 +963,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Account const alice("alice");
             MPTTester mptAlice(env, alice, {.holders = {}});
 
-            mptAlice.create({
-                .ownerCount = 1,
-                .flags = tfMPTCanTransfer | tfMPTCanLock,
-                .mutableFlags = tmfMPTCanMutateTransferFee,
-            });
+            mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock});
 
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .transferFee = 100,
                 .err = temBAD_TRANSFER_FEE,
             });
@@ -986,16 +980,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Account const alice("alice");
             MPTTester mptAlice(env, alice, {.holders = {}});
 
-            mptAlice.create({
-                .transferFee = 100,
-                .ownerCount = 1,
-                .flags = tfMPTCanTransfer | tfMPTCanLock,
-                .mutableFlags = tmfMPTCanMutateTransferFee,
-            });
+            mptAlice.create(
+                {.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock});
 
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .err = tecNO_PERMISSION,
             });
         }
@@ -1007,11 +997,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Account const alice("alice");
             MPTTester mptAlice(env, alice, {.holders = {}});
 
-            mptAlice.create({
-                .ownerCount = 1,
-                .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance,
-                .mutableFlags = tmfMPTCanMutateTransferFee,
-            });
+            mptAlice.create(
+                {.ownerCount = 1,
+                 .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance});
 
             mptAlice.set({
                 .account = alice,
@@ -2203,6 +2191,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .account = bob,
                 .dest = bob,
                 .amt = 10,
+                .proof = getTrivialSendProofHex(),
                 .err = temMALFORMED,
             });
 
@@ -2909,22 +2898,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         auto& mptAlice = confEnv.mpt;
 
         {
-            // Bob has 60, tries to send 70. Invalid remaining balance.
-            mptAlice.send({
-                .account = bob,
-                .dest = carol,
-                .amt = 70,
-                .err = tecBAD_PROOF,
-            });
-
-            // Bob has 60, tries to send 61. Invalid remaining balance.
-            mptAlice.send({
-                .account = bob,
-                .dest = carol,
-                .amt = 61,
-                .err = tecBAD_PROOF,
-            });
-
             // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance.
             mptAlice.send({
                 .account = bob,
@@ -2945,12 +2918,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             });
 
             // Bob has 100, tries to send 2^64-1. Invalid remaining balance.
-            mptAlice.send({
-                .account = bob,
-                .dest = carol,
-                .amt = std::numeric_limits::max(),
-                .err = tecBAD_PROOF,
-            });
+            {
+                ConfidentialSendSetup const setup(
+                    mptAlice, bob, carol, alice, std::numeric_limits::max());
+                auto const forged = getForgedSendProof(mptAlice, env, bob, carol, setup);
+                mptAlice.send(setup.sendArgs(bob, carol, forged, tecBAD_PROOF));
+            }
 
             // Bob sends 1, remaining 99.
             mptAlice.send({
@@ -2959,14 +2932,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .amt = 1,
                 .err = tesSUCCESS,
             });
-
-            // Bob sends 100, but only has 99. Invalid remaining balance.
-            mptAlice.send({
-                .account = bob,
-                .dest = carol,
-                .amt = 100,
-                .err = tecBAD_PROOF,
-            });
         }
 
         // send when spending balance is 0 (key registered, inbox merged, but nothing converted)
@@ -2983,18 +2948,13 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
 
             // Trying to send any amount with 0 spending balance must fail:
             // the range proof for < 0 is invalid.
-            mptAlice2.send({
-                .account = bob2,
-                .dest = carol2,
-                .amt = 1,
-                .err = tecBAD_PROOF,
-            });
+            ConfidentialSendSetup const setup(mptAlice2, bob2, carol2, alice2, 1);
+            auto const forged = getForgedSendProof(mptAlice2, env2, bob2, carol2, setup);
+            mptAlice2.send(setup.sendArgs(bob2, carol2, forged, tecBAD_PROOF));
 
             BEAST_EXPECT(
                 mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0);
         }
-
-        // todo: test m exceeding range, require using scala and refactor
     }
 
     /* The equality proof library and range proof library do not
@@ -3474,7 +3434,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const convertBackContextHash =
                 getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 convertBackAmt,
                 convertBackContextHash,
@@ -3484,6 +3444,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             {
                 json::Value jv;
@@ -3495,7 +3457,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext);
                 jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor);
                 jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment);
-                jv[sfZKProof.jsonName] = strHex(proof);
+                jv[sfZKProof.jsonName] = strHex(requireOptionalRef(proof, "Missing proof"));
 
                 env(jv, Ter(tesSUCCESS));
             }
@@ -5087,7 +5049,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         testcase("mutate lsfMPTCanHoldConfidentialBalance");
         using namespace test::jtx;
 
-        // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance
+        // can not create mpt issuance with tifMPTCanHoldConfidentialBalance
         // when featureDynamicMPT is disabled
         {
             Env env{*this, features - featureDynamicMPT};
@@ -5097,12 +5059,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
 
             mptAlice.create({
                 .ownerCount = 0,
-                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .immutableFlags = tifMPTCanHoldConfidentialBalance,
                 .err = temDISABLED,
             });
         }
 
-        // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance when
+        // can not create mpt issuance with tifMPTCanHoldConfidentialBalance when
         // featureConfidentialTransfer is disabled
         {
             Env env{*this, features - featureConfidentialTransfer};
@@ -5112,12 +5074,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
 
             mptAlice.create({
                 .ownerCount = 0,
-                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .immutableFlags = tifMPTCanHoldConfidentialBalance,
                 .err = temDISABLED,
             });
         }
 
-        // if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear
+        // if lsifMPTCanHoldConfidentialBalance is set, can not set/clear
         // lsfMPTCanHoldConfidentialBalance
         {
             Env env{*this, features};
@@ -5128,12 +5090,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             mptAlice.create({
                 .ownerCount = 1,
                 .flags = tfMPTCanTransfer,
-                .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance,
+                .immutableFlags = tifMPTCanHoldConfidentialBalance,
             });
 
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .err = tecNO_PERMISSION,
             });
         }
@@ -5148,7 +5110,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             mptAlice.create({
                 .ownerCount = 1,
                 .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
-                .mutableFlags = tmfMPTCanEnableCanLock,
+                .immutableFlags = tifMPTCanLock,
             });
 
             mptAlice.authorize({
@@ -5200,14 +5162,14 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             // lsfMPTCanHoldConfidentialBalance was already set
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
             });
             verifyToggle(tesSUCCESS, 10);
 
-            // set tmfMPTSetCanHoldConfidentialBalance again
+            // set tfMPTSetCanHoldConfidentialBalance again
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
             });
             verifyToggle(tesSUCCESS, 30);
         }
@@ -5220,7 +5182,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Account const bob("bob");
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            // lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default,
+            // lsifMPTCanHoldConfidentialBalance is false by default,
             // so that lsfMPTCanHoldConfidentialBalance can be mutated
             mptAlice.create({
                 .ownerCount = 1,
@@ -5243,7 +5205,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             // confidential outstanding balance
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .err = tecNO_PERMISSION,
             });
         }
@@ -5295,7 +5257,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
             Buffer const badPedersenCommitment =
                 mptAlice.getPedersenCommitment(1, pcBlindingFactor);
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5305,6 +5267,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5325,7 +5289,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5335,6 +5299,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = generateBlindingFactor(),  // wrong blinding factor
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5349,22 +5315,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         }
 
         // Test 3: Proof generated with wrong balance value.
-        // The proof claims balance=1 but the encrypted spending balance contains
-        // the actual balance. Verification fails because the values don't match.
+        // The sigma proof claims balance=20 but the pedersen commitment and
+        // encrypted spending balance were built for the actual balance (40).
+        // we cannot call mpt_get_convert_back_proof because it has client-side
+        // verification.
         {
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            uint64_t constexpr claimedBalance = 20;  // wrong: real balance is 40
+
+            auto const proof = getForgedConvertBackProof(
+                mptAlice,
                 bob,
+                claimedBalance,
+                spendingBalance,
                 amt,
-                contextHash,
-                {
-                    .pedersenCommitment = pedersenCommitment,
-                    .amt = 1,  // wrong balance
-                    .encryptedAmt = encryptedSpendingBalance,
-                    .blindingFactor = pcBlindingFactor,
-                });
+                pedersenCommitment,
+                encryptedSpendingBalance,
+                pcBlindingFactor,
+                contextHash);
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5387,7 +5357,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
             Buffer const badPedersenCommitment =
                 mptAlice.getPedersenCommitment(1, pcBlindingFactor);
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5397,6 +5367,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5417,7 +5389,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         {
             uint256 const badContextHash{1};
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 badContextHash,  // wrong context hash
@@ -5427,6 +5399,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5446,7 +5420,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5456,6 +5430,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5469,6 +5445,429 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         }
     }
 
+    void
+    testSendOverdraftBulletproof(FeatureBitset features)
+    {
+        uint64_t const balance = 100;
+        testSendOverdraftBulletproofImpl(features, balance, balance);      // SUCCEED
+        testSendOverdraftBulletproofImpl(features, balance, balance + 1);  // FAIL
+    }
+
+    void
+    testSendOverdraftBulletproofImpl(FeatureBitset features, unsigned balance, unsigned amt)
+    {
+        testcase("Send: overdraft prevention via bulletproof");
+        using namespace test::jtx;
+
+        // Attack scenario: Alice has 100 tokens, tries to send 101 to Bob.
+        // The client-side check in mpt-crypto:mpt_utility.cpp:743 prevents honest
+        // clients from creating this proof. We bypass it by manually
+        // constructing a forged proof to demonstrate that the ledger's
+        // range proof verification catches the overdraft.
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), issuer("issuer");
+
+        uint64_t const aliceBalance = balance;
+        uint64_t const aliceAmount = amt;
+        uint64_t const aliceRemaining = aliceBalance - aliceAmount;
+
+        // Setup: Alice has 100 tokens converted to confidential
+        ConfidentialEnv confEnv{
+            env,
+            issuer,
+            {{.account = alice, .payAmount = 1000, .convertAmount = aliceBalance},
+             {.account = bob, .payAmount = 1000, .convertAmount = 30}}};
+        auto& mptIssuer = confEnv.mpt;
+
+        std::pair errors = aliceAmount > aliceBalance
+            ? std::make_pair(-1, TER(tecBAD_PROOF))
+            : std::make_pair(0, TER(tesSUCCESS));
+
+        unsigned const numParticipants = 3;
+
+        // Verify Alice's actual balance before attack
+        {
+            auto const balance = requireOptional(
+                mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending),
+                "Missing Alice's balance");
+            BEAST_EXPECT(balance == aliceBalance);
+        }
+
+        // We cannot use ConfidentialSendSetup directly because it would
+        // call mpt_get_confidential_send_proof which has a client-side
+        // check (amount > balance) at line 743 in mpt_utility.cpp.
+        // Instead, we manually construct the transaction components.
+
+        Buffer const randomElgamal = generateBlindingFactor();
+        Buffer const randomBalance = generateBlindingFactor();
+
+        // Create encrypted amounts (using the OVERDRAFT amount)
+        Buffer const aliceEncAmt = mptIssuer.encryptAmount(alice, aliceAmount, randomElgamal);
+        Buffer const bobEncAmt = mptIssuer.encryptAmount(bob, aliceAmount, randomElgamal);
+        Buffer const issuerEncAmt = mptIssuer.encryptAmount(issuer, aliceAmount, randomElgamal);
+
+        // Create commitments
+        // IMPORTANT: Amount commitment uses same randomness as ElGamal encryption!
+        Buffer const amtCommit = mptIssuer.getPedersenCommitment(aliceAmount, randomElgamal);
+        Buffer const balanceCommit = mptIssuer.getPedersenCommitment(aliceBalance, randomBalance);
+
+        // Get Alice's current encrypted spending balance
+        Buffer const aliceEncBalance = requireOptional(
+            mptIssuer.getEncryptedBalance(alice, MPTTester::holderEncryptedSpending),
+            "Missing Alice's encrypted spending balance");
+
+        uint32_t const version = mptIssuer.getMPTokenVersion(alice);
+        auto const ctxHash = getSendContextHash(
+            alice.id(), mptIssuer.issuanceID(), env.seq(alice), bob.id(), version);
+
+        // Now we need to manually generate the sigma proof part.
+        // The sigma proof verifies ciphertext consistency and commitments,
+        // but doesn't check the range. We'll construct it with the overdraft
+        // amount to bypass the client-side check.
+
+        // Generate the sigma proof manually using the lower-level secp256k1 API
+        auto* ctx = mpt_secp256k1_context();
+        Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
+
+        // Parse all public keys and ciphertexts
+        secp256k1_pubkey c1, c2Alice, c2Bob, c2Issuer;
+        // Parse sender's ciphertext C1 (first 33(kCompressedEcPointLength) bytes)
+        auto x = secp256k1_ec_pubkey_parse(ctx, &c1, aliceEncAmt.data(), kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1, "Failed to parse C1"))
+            return;
+        // Parse C2 components for all recipients
+        x = secp256k1_ec_pubkey_parse(
+            ctx, &c2Alice, aliceEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength);
+        auto y = secp256k1_ec_pubkey_parse(
+            ctx, &c2Bob, bobEncAmt.data() + kCompressedEcPointLength, kCompressedEcPointLength);
+        auto z = secp256k1_ec_pubkey_parse(
+            ctx,
+            &c2Issuer,
+            issuerEncAmt.data() + kCompressedEcPointLength,
+            kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse C2 components"))
+            return;
+        secp256k1_pubkey c2Vec[] = {c2Alice, c2Bob, c2Issuer};
+
+        // Parse public keys
+        secp256k1_pubkey pkAlice, pkBob, pkIssuer;
+        auto alicePubKey = requireOptional(mptIssuer.getPubKey(alice), "Missing alice pubkey");
+        auto bobPubKey = requireOptional(mptIssuer.getPubKey(bob), "Missing bob pubkey");
+        auto issuerPubKey = requireOptional(mptIssuer.getPubKey(issuer), "Missing issuer pubkey");
+        x = secp256k1_ec_pubkey_parse(ctx, &pkAlice, alicePubKey.data(), kCompressedEcPointLength);
+        y = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength);
+        z = secp256k1_ec_pubkey_parse(
+            ctx, &pkIssuer, issuerPubKey.data(), kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1 && y == 1 && z == 1, "Failed to parse public keys"))
+            return;
+        secp256k1_pubkey pkVec[] = {pkAlice, pkBob, pkIssuer};
+
+        // Parse commitments
+        secp256k1_pubkey pcAmount, pcBalance, b1, b2;
+        x = secp256k1_ec_pubkey_parse(ctx, &pcAmount, amtCommit.data(), kCompressedEcPointLength);
+        y = secp256k1_ec_pubkey_parse(
+            ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse commitments"))
+            return;
+        // Parse balance ciphertext
+        x = secp256k1_ec_pubkey_parse(ctx, &b1, aliceEncBalance.data(), kCompressedEcPointLength);
+        y = secp256k1_ec_pubkey_parse(
+            ctx, &b2, aliceEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext"))
+            return;
+
+        // Get Alice's private key
+        auto alicePrivKey = requireOptional(mptIssuer.getPrivKey(alice), "Missing alice privkey");
+
+        // Generate the compact sigma proof (part of mpt_get_confidential_send_proof)
+        // This will succeed because sigma proof doesn't check amount vs balance
+        x = secp256k1_compact_standard_prove(
+            ctx,
+            sigmaProof.data(),
+            aliceAmount,
+            aliceBalance,
+            randomElgamal.data(),
+            alicePrivKey.data(),
+            randomBalance.data(),
+            numParticipants,
+            &c1,
+            c2Vec,
+            pkVec,
+            &pcAmount,
+            &pkAlice,
+            &pcBalance,
+            &b1,
+            &b2,
+            ctxHash.data());
+        if (!BEAST_EXPECTS(x == 1, "Failed to generate sigma proof"))
+            return;
+
+        // Direct verification
+        x = secp256k1_compact_standard_verify(
+            ctx,
+            sigmaProof.data(),
+            numParticipants,
+            &c1,
+            c2Vec,
+            pkVec,
+            &pcAmount,
+            &pkAlice,
+            &pcBalance,
+            &b1,
+            &b2,
+            ctxHash.data());
+        if (!BEAST_EXPECTS(x == 1, "Sigma verification failed"))
+            return;
+
+        // Compute the remaining blinding factor: r_remaining = r_balance - r_amount
+        // This is required because the ledger homomorphically computes:
+        // C_remaining = C_balance - C_amount = Commit(remaining, r_balance - r_amount)
+        Buffer randomRemaining(kEcBlindingFactorLength);
+        Buffer negRandomElgamal(kEcBlindingFactorLength);
+        secp256k1_mpt_scalar_negate(negRandomElgamal.data(), randomElgamal.data());
+        secp256k1_mpt_scalar_add(
+            randomRemaining.data(), randomBalance.data(), negRandomElgamal.data());
+
+        // Now forge the bulletproof claiming
+        auto const forgedBulletproof = getForgedBulletproof(
+            {aliceAmount, aliceRemaining}, {randomElgamal, randomRemaining}, ctxHash);
+
+        // Combine sigma proof + forged bulletproof
+        Buffer combinedProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE + kEcDoubleBulletproofLength);
+        std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
+        std::memcpy(
+            combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE,
+            forgedBulletproof.data(),
+            kEcDoubleBulletproofLength);
+
+        // Direct verification
+        x = mpt_verify_send_range_proof(
+            combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE,
+            amtCommit.data(),
+            balanceCommit.data(),
+            ctxHash.data());
+        if (!BEAST_EXPECTS(x == errors.first, "Forged proof passed validation"))
+            return;
+
+        // Attempt the transaction with forged proof
+        // Expected to FAIL with tecBAD_PROOF
+        mptIssuer.send({
+            .account = alice,
+            .dest = bob,
+            .amt = aliceAmount,
+            .proof = strHex(combinedProof),
+            .senderEncryptedAmt = aliceEncAmt,
+            .destEncryptedAmt = bobEncAmt,
+            .issuerEncryptedAmt = issuerEncAmt,
+            .amountCommitment = amtCommit,
+            .balanceCommitment = balanceCommit,
+            .err = errors.second,
+        });
+
+        // Verify Alice's balance unchanged (attack prevented!)
+        {
+            auto const balance = requireOptional(
+                mptIssuer.getDecryptedBalance(alice, MPTTester::holderEncryptedSpending),
+                "Missing post-attack balance");
+            if (aliceAmount > aliceBalance)
+            {
+                BEAST_EXPECT(balance == aliceBalance);
+            }
+            else
+            {
+                BEAST_EXPECT(balance < aliceBalance);
+            }
+        }
+    }
+
+    void
+    testConvertBackOverdraftBulletproof(FeatureBitset features)
+    {
+        uint64_t const balance = 100;
+        testConvertBackOverdraftBulletproofImpl(features, balance, balance);      // SUCCEED
+        testConvertBackOverdraftBulletproofImpl(features, balance, balance + 1);  // FAIL
+    }
+
+    void
+    testConvertBackOverdraftBulletproofImpl(FeatureBitset features, uint64_t balance, uint64_t amt)
+    {
+        testcase("Convert back: overdraft prevention via bulletproof");
+        using namespace test::jtx;
+
+        // Attack scenario: Bob has 100 confidential tokens, tries to convert back 101.
+        // The client-side check in mpt_get_convert_back_proof would prevent honest
+        // clients from creating this proof. We bypass it by manually constructing
+        // a forged proof to demonstrate that the ledger's bulletproof verification
+        // catches the overdraft.
+
+        Env env{*this, features};
+        Account const alice("alice"), bob("bob"), carol("carol");
+
+        uint64_t const bobBalance = balance;
+        uint64_t const convertAmount = amt;
+        uint64_t const bobRemaining = bobBalance - convertAmount;
+
+        // Setup: Bob and Carol both have confidential balance
+        // Carol ensures outstanding amount >= convertAmount (bypass preclaim check)
+        // This allows us to test the bulletproof specifically
+        ConfidentialEnv confEnv{
+            env,
+            alice,
+            {
+                {.account = bob, .payAmount = 1000, .convertAmount = bobBalance},
+                {.account = carol,
+                 .payAmount = 1000,
+                 .convertAmount = std::max(convertAmount, bobBalance + 1)},
+            }};
+        auto& mptAlice = confEnv.mpt;
+
+        std::pair errors = convertAmount > bobBalance
+            ? std::make_pair(-1, TER(tecBAD_PROOF))
+            : std::make_pair(0, TER(tesSUCCESS));
+
+        // Verify Bob's actual balance before attack
+        {
+            auto const balance = requireOptional(
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+                "Missing Bob's balance");
+            BEAST_EXPECT(balance == bobBalance);
+        }
+
+        // We cannot use the standard getConvertBackProof because it calls
+        // mpt_get_convert_back_proof which has client-side validation.
+        // Instead, we manually construct the sigma proof and forge the bulletproof.
+
+        Buffer const blindingFactor = generateBlindingFactor();
+        Buffer const pcBlindingFactor = generateBlindingFactor();
+
+        // Create encrypted amounts for the conversion
+        Buffer const bobEncAmt = mptAlice.encryptAmount(bob, convertAmount, blindingFactor);
+        Buffer const issuerEncAmt = mptAlice.encryptAmount(alice, convertAmount, blindingFactor);
+
+        // Create Pedersen commitment to the current balance
+        Buffer const balanceCommit = mptAlice.getPedersenCommitment(bobBalance, pcBlindingFactor);
+
+        // Get Bob's current encrypted spending balance
+        Buffer const bobEncBalance = requireOptional(
+            mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending),
+            "Missing Bob's encrypted spending balance");
+
+        uint32_t const version = mptAlice.getMPTokenVersion(bob);
+        auto const ctxHash =
+            getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
+
+        // Now manually generate the compact sigma proof for ConvertBack
+        auto* ctx = mpt_secp256k1_context();
+        Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
+
+        // Parse the holder's public key
+        secp256k1_pubkey pkBob;
+        auto bobPubKey = requireOptional(mptAlice.getPubKey(bob), "Missing bob pubkey");
+        auto x = secp256k1_ec_pubkey_parse(ctx, &pkBob, bobPubKey.data(), kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1, "Failed to parse Bob's public key"))
+            return;
+
+        // Parse balance commitment
+        secp256k1_pubkey pcBalance;
+        x = secp256k1_ec_pubkey_parse(
+            ctx, &pcBalance, balanceCommit.data(), kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1, "Failed to parse balance commitment"))
+            return;
+
+        // Parse balance ciphertext (B1, B2)
+        secp256k1_pubkey b1, b2;
+        x = secp256k1_ec_pubkey_parse(ctx, &b1, bobEncBalance.data(), kCompressedEcPointLength);
+        auto y = secp256k1_ec_pubkey_parse(
+            ctx, &b2, bobEncBalance.data() + kCompressedEcPointLength, kCompressedEcPointLength);
+        if (!BEAST_EXPECTS(x == 1 && y == 1, "Failed to parse balance ciphertext"))
+            return;
+
+        // Get Bob's private key
+        auto bobPrivKey = requireOptional(mptAlice.getPrivKey(bob), "Missing bob privkey");
+
+        // Generate the compact sigma proof for ConvertBack
+        // This verifies balance ownership and commitment linkage
+        x = secp256k1_compact_convertback_prove(
+            ctx,
+            sigmaProof.data(),
+            bobBalance,
+            bobPrivKey.data(),
+            pcBlindingFactor.data(),
+            &pkBob,
+            &b1,
+            &b2,
+            &pcBalance,
+            ctxHash.data());
+        if (!BEAST_EXPECTS(x == 1, "Failed to generate convertback sigma proof"))
+            return;
+
+        // Verify the sigma proof passes (it doesn't check range)
+        x = secp256k1_compact_convertback_verify(
+            ctx, sigmaProof.data(), &pkBob, &b1, &b2, &pcBalance, ctxHash.data());
+        if (!BEAST_EXPECTS(x == 1, "Sigma verification failed"))
+            return;
+
+        // Now forge the single bulletproof claiming the remaining balance is valid
+        // For ConvertBack, we need to prove: (balance - convertAmount) >= 0
+        // We create a commitment to the remainder and generate a bulletproof for it
+
+        // The bulletproof needs the blinding factor for the remainder commitment
+        // The ledger computes: C_remainder = C_balance - convertAmount*G
+        // So the blinding factor is just pcBlindingFactor (no randomness in convertAmount*G)
+
+        auto const forgedBulletproof =
+            getForgedSingleBulletproof(bobRemaining, pcBlindingFactor, ctxHash);
+
+        // Combine sigma proof + forged bulletproof
+        Buffer combinedProof(kEcConvertBackProofLength);
+        std::memcpy(
+            combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
+        std::memcpy(
+            combinedProof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE,
+            forgedBulletproof.data(),
+            kEcSingleBulletproofLength);
+
+        // Direct verification of the full proof
+        x = mpt_verify_convert_back_proof(
+            combinedProof.data(),
+            bobPubKey.data(),
+            bobEncBalance.data(),
+            balanceCommit.data(),
+            convertAmount,
+            ctxHash.data());
+        if (!BEAST_EXPECTS(x == errors.first, "Forged proof verification mismatch"))
+            return;
+
+        // Attempt the transaction with forged proof
+        // Expected to FAIL with tecBAD_PROOF when convertAmount > bobBalance
+        mptAlice.convertBack({
+            .account = bob,
+            .amt = convertAmount,
+            .proof = combinedProof,
+            .holderEncryptedAmt = bobEncAmt,
+            .issuerEncryptedAmt = issuerEncAmt,
+            .blindingFactor = blindingFactor,
+            .pedersenCommitment = balanceCommit,
+            .err = errors.second,
+        });
+
+        // Verify Bob's balance unchanged (attack prevented!)
+        {
+            auto const postBalance = requireOptional(
+                mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending),
+                "Missing post-attack balance");
+            if (convertAmount > bobBalance)
+            {
+                BEAST_EXPECT(postBalance == bobBalance);
+            }
+            else
+            {
+                BEAST_EXPECT(postBalance < bobBalance);
+            }
+        }
+    }
+
     void
     testConvertBackBulletproof(FeatureBitset features)
     {
@@ -5508,22 +5907,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         // linkage, and that the remaining balance is non-negative.
 
         // Test 1: Proof generated with wrong balance value.
-        // The sigma proof claims balance=1 but the spending balance contains the
-        // actual balance. The compact proof's balance-linkage check fails.
+        // The sigma proof claims balance=20 but the pedersen commitment and
+        // encrypted spending balance were built for the actual balance (40).
+        // we cannot call mpt_get_convert_back_proof because it has client-side
+        // verification.
         {
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            uint64_t constexpr claimedBalance = 20;  // wrong: real balance is 40
+
+            auto const proof = getForgedConvertBackProof(
+                mptAlice,
                 bob,
+                claimedBalance,
+                spendingBalance,
                 amt,
-                contextHash,
-                {
-                    .pedersenCommitment = pedersenCommitment,
-                    .amt = 1,  // wrong balance (actual balance is ~40)
-                    .encryptedAmt = encryptedSpendingBalance,
-                    .blindingFactor = pcBlindingFactor,
-                });
+                pedersenCommitment,
+                encryptedSpendingBalance,
+                pcBlindingFactor,
+                contextHash);
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5545,7 +5948,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5555,6 +5958,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = generateBlindingFactor(),  // wrong blinding factor
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5574,7 +5979,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         // makes the proof invalid for this transaction, preventing replay attacks.
         {
             uint256 const badContextHash{1};
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 badContextHash,  // wrong context hash
@@ -5584,6 +5989,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5603,7 +6010,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const contextHash =
                 getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 contextHash,
@@ -5613,6 +6020,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpendingBalance,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5662,7 +6071,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor);
             auto const version = mptAlice.getMPTokenVersion(bob);
 
-            Buffer const proof = mptAlice.getConvertBackProof(
+            auto const proof = mptAlice.getConvertBackProof(
                 bob,
                 amt,
                 makeContextHash(env, mptAlice, alice, bob, carol, version),
@@ -5673,6 +6082,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                         encryptedSpendingBalance, "Missing encrypted spending balance"),
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(proof.has_value()))
+                return;
 
             mptAlice.convertBack({
                 .account = bob,
@@ -5762,7 +6173,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         uint256 const contextHashA =
             getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version);
 
-        Buffer const proofA = mptAlice.getConvertBackProof(
+        auto const proofA = mptAlice.getConvertBackProof(
             bob,
             amtA,
             contextHashA,
@@ -5772,6 +6183,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .encryptedAmt = encryptedSpendingBalance,
                 .blindingFactor = pcBlindingFactor,
             });
+        if (!BEAST_EXPECT(proofA.has_value()))
+            return;
 
         // Construct Transaction B with Amount m2 = 20 and attach Proof pi
         uint64_t const amtB = 20;
@@ -5843,7 +6256,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         uint256 const oldContextHash =
             getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV);
 
-        Buffer const oldProof = mptAlice.getConvertBackProof(
+        auto const oldProof = mptAlice.getConvertBackProof(
             bob,
             amt,
             oldContextHash,
@@ -5853,6 +6266,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .encryptedAmt = encryptedSpendingBalanceV,
                 .blindingFactor = pcBlindingFactor,
             });
+        if (!BEAST_EXPECT(oldProof.has_value()))
+            return;
 
         // Submit and verify failure
         mptAlice.convertBack({
@@ -5915,7 +6330,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         uint256 const contextHash =
             getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion);
 
-        Buffer const proof = mptAlice.getConvertBackProof(
+        auto const proof = mptAlice.getConvertBackProof(
             bob,
             amt,
             contextHash,
@@ -5925,6 +6340,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .encryptedAmt = spendingBalEnc,
                 .blindingFactor = pcBf,
             });
+        if (!BEAST_EXPECT(proof.has_value()))
+            return;
 
         // Submit transaction with Divergent Ciphertexts
         // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10.
@@ -6058,7 +6475,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         uint256 const contextHash =
             getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion);
 
-        Buffer const proof = mptAlice.getConvertBackProof(
+        auto const proof = mptAlice.getConvertBackProof(
             bob,
             1,
             contextHash,
@@ -6068,6 +6485,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 .encryptedAmt = underflowedCt,
                 .blindingFactor = pcBf,
             });
+        if (!BEAST_EXPECT(proof.has_value()))
+            return;
 
         mptAlice.convertBack({
             .account = bob,
@@ -6712,6 +7131,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                 mptAlice.confidentialClaw(
                     {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee});
             });
+
+            // Check fee for the mirror update transaction.
+            Account const newIssuerKey("newIssuerKey");
+            mptAlice.generateKeyPair(newIssuerKey);
+            mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(newIssuerKey)});
+            checkFee(alice, [&]() {
+                mptAlice.mirrorUpdate(
+                    {.account = alice,
+                     .holder = bob,
+                     .issuerEncryptedAmount = getTrivialCiphertext(),
+                     .fee = expectedFee});
+            });
         }
 
         // test insufficient fee for confidential transactions
@@ -6743,6 +7174,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                  .amt = 1,
                  .fee = baseFee,
                  .err = telINSUF_FEE_P});
+            mptAlice.mirrorUpdate(
+                {.account = alice,
+                 .holder = bob,
+                 .issuerEncryptedAmount = getTrivialCiphertext(),
+                 .fee = baseFee,
+                 .err = telINSUF_FEE_P});
         }
 
         // test excessive fee for confidential transactions
@@ -7330,7 +7767,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             uint256 const convertBackCtxHash =
                 getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version);
 
-            Buffer const convertBackProof = mptAlice.getConvertBackProof(
+            auto const convertBackProof = mptAlice.getConvertBackProof(
                 bob,
                 sendAmount,
                 convertBackCtxHash,
@@ -7340,14 +7777,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
                     .encryptedAmt = encryptedSpending,
                     .blindingFactor = pcBlindingFactor,
                 });
+            if (!BEAST_EXPECT(convertBackProof.has_value()))
+                return;
 
             // Resize the convertBack proof to match the expected send proof
             // size so it passes preflight's size check and reaches the actual
             // ZK verification in doApply.
             auto const expectedSendSize = kEcSendProofLength;
             Buffer resizedProof(expectedSendSize);
-            auto const copyLen = std::min(convertBackProof.size(), expectedSendSize);
-            std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen);
+            Buffer const& convertBackProofRef =
+                requireOptionalRef(convertBackProof, "Missing proof");
+            auto const copyLen = std::min(convertBackProofRef.size(), expectedSendSize);
+            std::memcpy(resizedProof.data(), convertBackProofRef.data(), copyLen);
             // Zero-pad the rest (if convertBack proof is shorter)
             if (copyLen < expectedSendSize)
                 std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen);
@@ -8143,6 +8584,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         testConvertBackWithAuditor(features);
         testConvertBackPedersenProof(features);
         testConvertBackBulletproof(features);
+        testConvertBackOverdraftBulletproof(features);
 
         // Homomorphic operation tests
         testSendHomomorphicOverflow(features);
@@ -8177,6 +8619,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         testSendInvalidProofContextBinding(features);
         testSendForgedEqualityProof(features);
         testSendForgedRangeProof(features);
+        testSendOverdraftBulletproof(features);
         testSendNegativeValueMalleability(features);
         testSendFiatShamirBinding(features);
         testSendProofComponentReuse(features);
diff --git a/src/test/app/Credentials_test.cpp b/src/test/app/Credentials_test.cpp
index 1f6ec012c8..ff3489884e 100644
--- a/src/test/app/Credentials_test.cpp
+++ b/src/test/app/Credentials_test.cpp
@@ -14,6 +14,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -24,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -425,7 +427,6 @@ struct Credentials_test : public beast::unit_test::Suite
         Account const subject{"subject"};
 
         {
-            using namespace jtx;
             Env env{*this, features};
 
             env.fund(XRP(5000), subject, issuer);
@@ -566,10 +567,27 @@ struct Credentials_test : public beast::unit_test::Suite
                 // End test
                 env.close();
             }
+
+            {
+                testcase("Credentials fail, subject is a pseudo-account.");
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = subject, .asset = xrpIssue()});
+                env(tx);
+                env.close();
+
+                auto const sleVault = env.le(keylet);
+                if (!BEAST_EXPECT(sleVault))
+                    return;
+                Account const vaultPseudo{"vault", sleVault->at(sfAccount)};
+                auto const expectedResult =
+                    features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS);
+
+                env(credentials::create(vaultPseudo, issuer, credType), expectedResult);
+                env.close();
+            }
         }
 
         {
-            using namespace jtx;
             Env env{*this, features};
 
             env.fund(XRP(5000), issuer);
@@ -583,7 +601,6 @@ struct Credentials_test : public beast::unit_test::Suite
         }
 
         {
-            using namespace jtx;
             Env env{*this, features};
 
             auto const reserve = drops(env.current()->fees().reserve);
@@ -1157,6 +1174,7 @@ struct Credentials_test : public beast::unit_test::Suite
         testCredentialsDelete(all);
         testCreateFailed(all);
         testCreateFailed(all - fixDirectoryLimit);
+        testCreateFailed(all - fixCleanup3_3_0);
         testAcceptFailed(all);
         testDeleteFailed(all);
         testFeatureFailed(all - featureCredentials);
diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp
index 257ed33619..5f57cfa21b 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 
 
@@ -236,7 +237,7 @@ class Delegate_test : public beast::unit_test::Suite
             env(delegate::set(gw, Account("unknown"), {"Payment"}), Ter(tecNO_TARGET));
         }
 
-        // Delegating to a pseudo-account is not allowed, should return tecNO_PERMISSION
+        // Delegating to a pseudo-account is not allowed, should return tecPSEUDO_ACCOUNT
         {
             Vault const vault{env};
             auto [tx, keylet] = vault.create({.owner = gw, .asset = xrpIssue()});
@@ -246,7 +247,7 @@ class Delegate_test : public beast::unit_test::Suite
             auto const sleVault = env.le(keylet);
             BEAST_EXPECT(sleVault);
             Account const vaultPseudo{"vault", sleVault->at(sfAccount)};
-            env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecNO_PERMISSION));
+            env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecPSEUDO_ACCOUNT));
         }
 
         // non-delegable transaction
@@ -1143,6 +1144,88 @@ class Delegate_test : public beast::unit_test::Suite
             env.require(Balance(gw, aliceUSD(-20)));
         }
 
+        // PaymentBurn must not exceed the balance the account holds. Redeeming past
+        // zero makes the payment engine issue the account's own IOUs, which is a mint.
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            auto const gwUSD = gw["USD"];
+            auto const aliceUSD = alice["USD"];
+
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(gwUSD(200), alice);
+            env.close();
+
+            env(pay(gw, alice, gwUSD(50)));
+            env.close();
+            env.require(Balance(alice, gwUSD(50)));
+
+            // gw accepts alice-issued USD, so the engine has issuing liquidity
+            // available once the trustline reaches zero.
+            env(trust(gw, aliceUSD(200)));
+            env.close();
+
+            env(delegate::set(alice, bob, {"PaymentBurn"}));
+            env.close();
+
+            if (!features[fixCleanup3_4_0])
+            {
+                // Pre-fixCleanup3_4_0: the balance direction alone authorizes the payment, so it
+                // redeems alice's 50 and then mints 50 alice-issued USD.
+                env(pay(alice, gw, gwUSD(100)), delegate::As(bob));
+                env.require(Balance(alice, gwUSD(-50)));
+                env.require(Balance(gw, aliceUSD(50)));
+            }
+            else
+            {
+                // Post-fixCleanup3_4_0: Rejected because it exceeds what alice holds.
+                env(pay(alice, gw, gwUSD(100)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(alice, gwUSD(50)));
+                env.require(Balance(gw, aliceUSD(-50)));
+
+                // Allowed because it is less than what alice holds.
+                env(pay(alice, gw, gwUSD(20)), delegate::As(bob));
+                env.require(Balance(alice, gwUSD(30)));
+                env.close();
+
+                // Exactly what alice holds: allowed, and settles at zero.
+                env(pay(alice, gw, gwUSD(30)), delegate::As(bob));
+                env.require(Balance(alice, gwUSD(0)));
+                env.close();
+
+                // Nothing left to burn: rejected.
+                env(pay(alice, gw, gwUSD(1)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
+                env.require(Balance(gw, aliceUSD(0)));
+            }
+        }
+
+        // A delegate holding both PaymentMint and PaymentBurn may cross zero.
+        {
+            Env env(*this, features);
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const gw{"gateway"};
+            auto const gwUSD = gw["USD"];
+            auto const aliceUSD = alice["USD"];
+
+            env.fund(XRP(10000), alice, bob, gw);
+            env.trust(gwUSD(200), alice);
+            env.close();
+
+            env(pay(gw, alice, gwUSD(50)));
+            env(trust(gw, aliceUSD(200)));
+            env.close();
+
+            env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint"}));
+            env.close();
+
+            env(pay(alice, gw, gwUSD(100)), delegate::As(bob));
+            env.require(Balance(alice, gwUSD(-50)));
+            env.require(Balance(gw, aliceUSD(50)));
+        }
+
         // Test invalid fields or flags not allowed in granular permission template
         {
             Env env(*this, features);
@@ -2167,11 +2250,12 @@ class Delegate_test : public beast::unit_test::Suite
             env(delegate::set(alice, bob, {"MPTokenIssuanceLock"}));
             env.close();
 
-            // Field is not permitted, permitted fields for delegation is defined in
-            // permissions.macro.
+            // tfMPTSetCanLock is a valid MPTokenIssuanceSet flag but is not
+            // covered by the MPTokenIssuanceLock granular permission, so a
+            // delegate holding only that permission cannot set it.
             mpt.set(
                 {.account = alice,
-                 .mutableFlags = 2,
+                 .flags = tfMPTSetCanLock,
                  .delegate = bob,
                  .err = terNO_DELEGATE_PERMISSION});
 
@@ -2717,19 +2801,24 @@ class Delegate_test : public beast::unit_test::Suite
 
         std::size_t delegableCount = 0;
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, txDelegable, ...) \
-    if (txDelegable == xrpl::Delegable)                 \
-    {                                                   \
-        delegableCount++;                               \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                 \
+    if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \
+    {                                                                                \
+        delegableCount++;                                                            \
     }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
         // ====================================================================
         // IMPORTANT NOTICE:
@@ -2823,15 +2912,15 @@ class Delegate_test : public beast::unit_test::Suite
                 auto [createTx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()});
                 env(createTx);
 
-                env(loanBroker::set(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
-                env(loanBroker::del(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
-                env(loanBroker::coverDeposit(alice, keylet.key, XRP(1)),
+                env(loan_broker::set(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
+                env(loan_broker::del(alice, keylet.key), delegate::As(bob), Ter(temINVALID));
+                env(loan_broker::coverDeposit(alice, keylet.key, XRP(1)),
                     delegate::As(bob),
                     Ter(temINVALID));
-                env(loanBroker::coverWithdraw(alice, keylet.key, XRP(1)),
+                env(loan_broker::coverWithdraw(alice, keylet.key, XRP(1)),
                     delegate::As(bob),
                     Ter(temINVALID));
-                env(loanBroker::coverClawback(alice), delegate::As(bob), Ter(temINVALID));
+                env(loan_broker::coverClawback(alice), delegate::As(bob), Ter(temINVALID));
 
                 env(loan::set(alice, keylet.key, Number(100)), delegate::As(bob), Ter(temINVALID));
                 env(loan::manage(alice, keylet.key, 0), delegate::As(bob), Ter(temINVALID));
@@ -2909,6 +2998,7 @@ class Delegate_test : public beast::unit_test::Suite
         testAccountDelete();
         testDelegateTransaction();
         testPaymentGranular(all);
+        testPaymentGranular(all - fixCleanup3_4_0);
         testTrustSetGranular();
         testAccountSetGranular();
         testMPTokenIssuanceSetGranular();
diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp
index c75bdeaf3a..c987e603be 100644
--- a/src/test/app/DepositAuth_test.cpp
+++ b/src/test/app/DepositAuth_test.cpp
@@ -21,6 +21,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -28,6 +29,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -444,7 +446,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite
     }
 
     void
-    testInvalid()
+    testInvalid(FeatureBitset features)
     {
         testcase("Invalid");
 
@@ -453,7 +455,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite
         Account const becky{"becky"};
         Account const carol{"carol"};
 
-        Env env(*this);
+        Env env(*this, features);
 
         // Tell env about alice, becky and carol since they are not yet funded.
         env.memoize(alice);
@@ -559,6 +561,25 @@ struct DepositPreauth_test : public beast::unit_test::Suite
         env.close();
         env.require(Owners(alice, 0));
         env.require(Owners(becky, 0));
+
+        {
+            // alice attempts to authorize a pseudo-account.
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = becky, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            if (!BEAST_EXPECT(sleVault))
+                return;
+            Account const vaultPseudo{"vault", sleVault->at(sfAccount)};
+
+            auto const expectedResult =
+                features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS);
+            env(deposit::auth(alice, vaultPseudo), expectedResult);
+            env.close();
+            env.require(Owners(alice, features[fixCleanup3_3_0] ? 0 : 1));
+        }
     }
 
     void
@@ -913,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()
     {
@@ -1419,11 +1480,13 @@ struct DepositPreauth_test : public beast::unit_test::Suite
     run() override
     {
         testEnable();
-        testInvalid();
         auto const supported{jtx::testableAmendments()};
+        testInvalid(supported);
+        testInvalid(supported - fixCleanup3_3_0);
         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 4015f5ddc8..3a2bc14183 100644
--- a/src/test/app/EscrowToken_test.cpp
+++ b/src/test/app/EscrowToken_test.cpp
@@ -30,6 +30,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -943,13 +944,106 @@ struct EscrowToken_test : public beast::unit_test::Suite
 
             if (env.current()->rules().enabled(fixCleanup3_2_0))
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), seq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))));
                 BEAST_EXPECT(env.current()->exists(trustLineKey));
                 BEAST_EXPECT(env.balance(alice, usd) == usd(1'000));
             }
         }
     }
 
+    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)
     {
@@ -1072,7 +1166,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const aa = env.le(keylet::escrow(alice.id(), aseq));
+            auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(aa);
             {
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
@@ -1096,7 +1190,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const bb = env.le(keylet::escrow(bob.id(), bseq));
+            auto const bb = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bb);
 
             {
@@ -1118,7 +1212,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -1144,7 +1238,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bob, bob, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -1188,10 +1282,10 @@ struct EscrowToken_test : public beast::unit_test::Suite
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
 
-            auto const ab = env.le(keylet::escrow(alice.id(), aseq));
+            auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(ab);
 
-            auto const bc = env.le(keylet::escrow(bob.id(), bseq));
+            auto const bc = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bc);
 
             {
@@ -1229,8 +1323,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1);
@@ -1263,8 +1357,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bob, bob, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1);
@@ -1320,7 +1414,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
                 Ter(tecNO_PERMISSION));
             env.close(5s);
 
-            auto const ag = env.le(keylet::escrow(alice.id(), aseq));
+            auto const ag = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(ag);
 
             {
@@ -1343,7 +1437,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1);
@@ -2702,7 +2796,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             auto const seq1 = env.seq(alice);
             env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
                 Sandbox sb(&view, TapNone);
-                auto sleNew = std::make_shared(keylet::escrow(alice, seq1));
+                auto sleNew =
+                    std::make_shared(keylet::escrow(alice, SeqProxy::rawSequence(seq1)));
                 MPTIssue const mpt{MPTIssue{makeMptID(1, AccountID(0x4985601))}};
                 STAmount const amt(mpt, 10);
                 sleNew->setAccountID(sfDestination, bob);
@@ -2929,7 +3024,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             auto const seq1 = env.seq(alice);
             env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
                 Sandbox sb(&view, TapNone);
-                auto sleNew = std::make_shared(keylet::escrow(alice, seq1));
+                auto sleNew =
+                    std::make_shared(keylet::escrow(alice, SeqProxy::rawSequence(seq1)));
                 MPTIssue const mpt{MPTIssue{makeMptID(1, AccountID(0x4985601))}};
                 STAmount const amt(mpt, 10);
                 sleNew->setAccountID(sfDestination, bob);
@@ -3280,7 +3376,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const aa = env.le(keylet::escrow(alice.id(), aseq));
+            auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(aa);
             {
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
@@ -3304,7 +3400,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const bb = env.le(keylet::escrow(bob.id(), bseq));
+            auto const bb = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bb);
 
             {
@@ -3318,7 +3414,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -3338,7 +3434,7 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bob, bob, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -3379,10 +3475,10 @@ struct EscrowToken_test : public beast::unit_test::Suite
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
 
-            auto const ab = env.le(keylet::escrow(alice.id(), aseq));
+            auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(ab);
 
-            auto const bc = env.le(keylet::escrow(bob.id(), bseq));
+            auto const bc = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bc);
 
             {
@@ -3411,8 +3507,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1);
@@ -3436,8 +3532,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bob, bob, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1);
@@ -3680,6 +3776,252 @@ struct EscrowToken_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testMPTSplitEscrowTransferFee(FeatureBitset features)
+    {
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        bool const withCleanup340 = features[fixCleanup3_4_0];
+        testcase(
+            std::string("MPT Split Escrow Transfer Fee ") +
+            (withCleanup340 ? "with Cleanup340" : "without Cleanup340"));
+
+        Env env{*this, features};
+        auto const baseFee = env.current()->fees().base;
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gw");
+        env.fund(XRP(1'000), alice, bob, gw);
+        env.close();
+
+        MPTTester const mpt({
+            .env = env,
+            .issuer = gw,
+            .holders = {alice, bob},
+            .transferFee = 1'000,
+            .flags = tfMPTCanEscrow | tfMPTCanTransfer,
+        });
+        env(pay(gw, alice, mpt(10'000)));
+        env.close();
+
+        static constexpr int escrowCount = 10;
+        static constexpr int splitAmount = 10;
+        static constexpr int totalLocked = escrowCount * splitAmount;
+        std::array seqs{};
+        for (auto& seq : seqs)
+        {
+            seq = env.seq(alice);
+            env(escrow::create(alice, bob, mpt(splitAmount)),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150));
+            env.close();
+        }
+
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked));
+        BEAST_EXPECT(env.balance(bob, mpt) == mpt(0));
+        BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000));
+        BEAST_EXPECT(mptEscrowed(env, alice, mpt) == totalLocked);
+        BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == totalLocked);
+
+        for (auto const seq : seqs)
+        {
+            env(escrow::finish(bob, alice, seq),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFulfillment(escrow::kFb1),
+                Fee(baseFee * 150));
+            env.close();
+        }
+
+        auto const feeBurned = withCleanup340 ? escrowCount : 0;
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked));
+        BEAST_EXPECT(env.balance(bob, mpt) == mpt(totalLocked - feeBurned));
+        BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000 + feeBurned));
+        BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0);
+        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)
     {
@@ -3978,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);
@@ -3998,6 +4341,10 @@ public:
             testMPTWithFeats(feats);
             testMPTWithFeats(feats - fixTokenEscrowV1);
         }
+        testMPTSplitEscrowTransferFee(all - fixCleanup3_4_0);
+        testMPTSplitEscrowTransferFee(all);
+        testIOUCancelReserveRecycle(all - fixCleanup3_4_0);
+        testIOUCancelReserveRecycle(all);
     }
 };
 
diff --git a/src/test/app/Escrow_test.cpp b/src/test/app/Escrow_test.cpp
index 5623bc4443..8a0d651004 100644
--- a/src/test/app/Escrow_test.cpp
+++ b/src/test/app/Escrow_test.cpp
@@ -20,6 +20,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -230,7 +231,7 @@ struct Escrow_test : public beast::unit_test::Suite
             Stag(1),
             Dtag(2));
 
-        auto const sle = env.le(keylet::escrow(alice.id(), seq));
+        auto const sle = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)));
         BEAST_EXPECT(sle);
         BEAST_EXPECT((*sle)[sfSourceTag] == 1);
         BEAST_EXPECT((*sle)[sfDestinationTag] == 2);
@@ -773,7 +774,8 @@ struct Escrow_test : public beast::unit_test::Suite
                 Fee(150 * baseFee));
 
             // SLE removed on finish
-            BEAST_EXPECT(!env.le(keylet::escrow(Account("alice").id(), seq)));
+            BEAST_EXPECT(
+                !env.le(keylet::escrow(Account("alice").id(), SeqProxy::rawSequence(seq))));
             BEAST_EXPECT((*env.le("alice"))[sfOwnerCount] == 0);
             env.require(Balance("carol", XRP(6000)));
             env(escrow::cancel("bob", "alice", seq), Ter(tecNO_TARGET));
@@ -795,7 +797,8 @@ struct Escrow_test : public beast::unit_test::Suite
             env(escrow::cancel("bob", "alice", seq));
             env.require(Balance("alice", XRP(5000) - drops(baseFee)));
             // SLE removed on cancel
-            BEAST_EXPECT(!env.le(keylet::escrow(Account("alice").id(), seq)));
+            BEAST_EXPECT(
+                !env.le(keylet::escrow(Account("alice").id(), SeqProxy::rawSequence(seq))));
         }
         {
             Env env(*this, features);
@@ -1117,7 +1120,7 @@ struct Escrow_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const aa = env.le(keylet::escrow(alice.id(), aseq));
+            auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(aa);
 
             {
@@ -1134,7 +1137,7 @@ struct Escrow_test : public beast::unit_test::Suite
             BEAST_EXPECT(
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
-            auto const bb = env.le(keylet::escrow(bruce.id(), bseq));
+            auto const bb = env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bb);
 
             {
@@ -1148,7 +1151,7 @@ struct Escrow_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -1168,7 +1171,7 @@ struct Escrow_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bruce, bruce, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq))));
                 BEAST_EXPECT(
                     (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
 
@@ -1198,10 +1201,10 @@ struct Escrow_test : public beast::unit_test::Suite
                 (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS));
             env.close(5s);
 
-            auto const ab = env.le(keylet::escrow(alice.id(), aseq));
+            auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)));
             BEAST_EXPECT(ab);
 
-            auto const bc = env.le(keylet::escrow(bruce.id(), bseq));
+            auto const bc = env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq)));
             BEAST_EXPECT(bc);
 
             {
@@ -1230,8 +1233,8 @@ struct Escrow_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::finish(alice, alice, aseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(env.le(keylet::escrow(bruce.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 0);
@@ -1255,8 +1258,8 @@ struct Escrow_test : public beast::unit_test::Suite
             env.close(5s);
             env(escrow::cancel(bruce, bruce, bseq));
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq)));
-                BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), bseq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))));
+                BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq))));
 
                 xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id()));
                 BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 0);
diff --git a/src/test/app/FixNFTokenPageLinks_test.cpp b/src/test/app/FixNFTokenPageLinks_test.cpp
index 7b13fc060b..d73ab9b6c7 100644
--- a/src/test/app/FixNFTokenPageLinks_test.cpp
+++ b/src/test/app/FixNFTokenPageLinks_test.cpp
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -139,7 +140,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
             env.fund(XRP(1000), alice);
 
             auto const linkFixFee = drops(env.current()->fees().increment);
-            env(ledgerStateFix::nftPageLinks(alice, alice), Fee(linkFixFee), Ter(temDISABLED));
+            env(ledger_state_fix::nftPageLinks(alice, alice), Fee(linkFixFee), Ter(temDISABLED));
         }
 
         Env env{*this, testableAmendments()};
@@ -151,38 +152,38 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         {
             // Fail preflight1.  Can't combine AccountTxnID and ticket.
-            json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
+            json::Value tx = ledger_state_fix::nftPageLinks(alice, alice);
             tx[sfAccountTxnID.jsonName] =
                 "00000000000000000000000000000000"
                 "00000000000000000000000000000000";
             env(tx, ticket::Use(ticketSeq), Ter(temINVALID));
         }
         // Fee too low.
-        env(ledgerStateFix::nftPageLinks(alice, alice), Ter(telINSUF_FEE_P));
+        env(ledger_state_fix::nftPageLinks(alice, alice), Ter(telINSUF_FEE_P));
 
         // Invalid flags.
         auto const linkFixFee = drops(env.current()->fees().increment);
-        env(ledgerStateFix::nftPageLinks(alice, alice),
+        env(ledger_state_fix::nftPageLinks(alice, alice),
             Fee(linkFixFee),
             Txflags(tfPassive),
             Ter(temINVALID_FLAG));
 
         {
-            // ledgerStateFix::nftPageLinks requires an Owner field.
-            json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
+            // ledger_state_fix::nftPageLinks requires an Owner field.
+            json::Value tx = ledger_state_fix::nftPageLinks(alice, alice);
             tx.removeMember(sfOwner.jsonName);
             env(tx, Fee(linkFixFee), Ter(temINVALID));
         }
         {
             // NFTokenPageLink fixes require sfOwner and reject fields that
             // belong to other LedgerStateFix types.
-            json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
+            json::Value tx = ledger_state_fix::nftPageLinks(alice, alice);
             tx[sfBookDirectory.jsonName] = to_string(uint256{1});
             env(tx, Fee(linkFixFee), Ter(temINVALID));
         }
         {
             // Invalid LedgerFixType codes.
-            json::Value tx = ledgerStateFix::nftPageLinks(alice, alice);
+            json::Value tx = ledger_state_fix::nftPageLinks(alice, alice);
             tx[sfLedgerFixType.jsonName] = 0;
             env(tx, Fee(linkFixFee), Ter(tefINVALID_LEDGER_FIX_TYPE));
 
@@ -193,7 +194,9 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // Preclaim
         Account const carol("carol");
         env.memoize(carol);
-        env(ledgerStateFix::nftPageLinks(alice, carol), Fee(linkFixFee), Ter(tecOBJECT_NOT_FOUND));
+        env(ledger_state_fix::nftPageLinks(alice, carol),
+            Fee(linkFixFee),
+            Ter(tecOBJECT_NOT_FOUND));
     }
 
     void
@@ -214,13 +217,17 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
 
         // Owner has no pages to fix.
         auto const linkFixFee = drops(env.current()->fees().increment);
-        env(ledgerStateFix::nftPageLinks(alice, alice), Fee(linkFixFee), Ter(tecFAILED_PROCESSING));
+        env(ledger_state_fix::nftPageLinks(alice, alice),
+            Fee(linkFixFee),
+            Ter(tecFAILED_PROCESSING));
 
         // Alice has only one page.
         env(token::mint(alice), Txflags(tfTransferable));
         env.close();
 
-        env(ledgerStateFix::nftPageLinks(alice, alice), Fee(linkFixFee), Ter(tecFAILED_PROCESSING));
+        env(ledger_state_fix::nftPageLinks(alice, alice),
+            Fee(linkFixFee),
+            Ter(tecFAILED_PROCESSING));
 
         // Alice has at least three pages.
         for (std::uint32_t i = 0; i < 64; ++i)
@@ -229,7 +236,9 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
             env.close();
         }
 
-        env(ledgerStateFix::nftPageLinks(alice, alice), Fee(linkFixFee), Ter(tecFAILED_PROCESSING));
+        env(ledger_state_fix::nftPageLinks(alice, alice),
+            Fee(linkFixFee),
+            Ter(tecFAILED_PROCESSING));
     }
 
     void
@@ -367,7 +376,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         dariaNFTs.reserve(32);
         for (int i = 0; i < 32; ++i)
         {
-            uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key;
             env(token::createOffer(carol, carolNFTs.back(), XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -401,7 +411,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         // back from daria.
         for (uint256 const& nft : dariaNFTs)
         {
-            uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key;
             env(token::createOffer(carol, nft, drops(1)), token::Owner(daria));
             env.close();
 
@@ -439,7 +450,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         //**********************************************************************
         // Verify that the LedgerStateFix transaction is not enabled.
         auto const linkFixFee = drops(env.current()->fees().increment);
-        env(ledgerStateFix::nftPageLinks(daria, alice), Fee(linkFixFee), Ter(temDISABLED));
+        env(ledger_state_fix::nftPageLinks(daria, alice), Fee(linkFixFee), Ter(temDISABLED));
 
         // Wait 15 ledgers so the LedgerStateFix transaction is no longer
         // retried.
@@ -475,7 +486,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         env(noop(daria));
 
         // daria fixes the links in alice's NFToken directory.
-        env(ledgerStateFix::nftPageLinks(daria, alice), Fee(linkFixFee));
+        env(ledger_state_fix::nftPageLinks(daria, alice), Fee(linkFixFee));
         env.close();
 
         // alice's last page should now be present and include no links.
@@ -516,7 +527,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         }
 
         // daria fixes the links in bob's NFToken directory.
-        env(ledgerStateFix::nftPageLinks(daria, bob), Fee(linkFixFee));
+        env(ledger_state_fix::nftPageLinks(daria, bob), Fee(linkFixFee));
         env.close();
 
         // bob's last page should now be present and include a previous
@@ -574,7 +585,7 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite
         }
 
         // carol fixes the links in their own NFToken directory.
-        env(ledgerStateFix::nftPageLinks(carol, carol), Fee(linkFixFee));
+        env(ledger_state_fix::nftPageLinks(carol, carol), Fee(linkFixFee));
         env.close();
 
         {
diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp
index 302e55a2cc..0f88814d4f 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -26,11 +26,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -408,7 +411,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
                 env(pay(gw, alice, usd(1'000)));
                 env(pay(gw, bob, eur(1'000)));
 
-                Keylet const bobUsdOffer = keylet::offer(bob, env.seq(bob));
+                Keylet const bobUsdOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob)));
                 env(offer(bob, usd(10), drops(2)), Txflags(tfPassive));
                 env(offer(bob, drops(1), eur(1'000)), Txflags(tfPassive));
 
@@ -636,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)
     {
@@ -741,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)
     {
@@ -2111,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)
     {
@@ -2120,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);
@@ -2128,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/Flow_test.cpp b/src/test/app/Flow_test.cpp
index 8d5162394e..5f12d54aec 100644
--- a/src/test/app/Flow_test.cpp
+++ b/src/test/app/Flow_test.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -547,7 +548,7 @@ struct Flow_test : public beast::unit_test::Suite
             env(pay(gw, alice, usd(1000)));
             env(pay(gw, bob, eur(1000)));
 
-            Keylet const bobUsdOffer = keylet::offer(bob, env.seq(bob));
+            Keylet const bobUsdOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob)));
             env(offer(bob, usd(1), drops(2)), Txflags(tfPassive));
             env(offer(bob, drops(1), eur(1000)), Txflags(tfPassive));
 
diff --git a/src/test/app/Freeze_test.cpp b/src/test/app/Freeze_test.cpp
index 786f5b4680..79087dacc3 100644
--- a/src/test/app/Freeze_test.cpp
+++ b/src/test/app/Freeze_test.cpp
@@ -23,6 +23,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -1788,7 +1789,7 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(a2, 0), Txflags(tfTransferable));
             env.close();
 
-            auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+            auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
             env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2));
             env.close();
 
@@ -1874,10 +1875,11 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(a2, 0), Txflags(tfTransferable));
             env.close();
 
-            uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
+            uint256 const sellIdx =
+                keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key;
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
             env.close();
-            auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+            auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
             env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
             env.close();
 
@@ -1900,13 +1902,15 @@ class Freeze_test : public beast::unit_test::Suite
             env(token::mint(minter, 0), token::XferFee(1u), Txflags(tfTransferable));
             env.close();
 
-            uint256 const minterSellIdx = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellIdx =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, drops(1)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(a2, minterSellIdx));
             env.close();
 
-            uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
+            uint256 const sellIdx =
+                keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key;
             env(token::createOffer(a2, nftID, usd(100)), Txflags(tfSellNFToken));
             env.close();
             env(trust(g1, minter["USD"](1000), tfSetFreeze | tfSetDeepFreeze));
@@ -1946,7 +1950,7 @@ class Freeze_test : public beast::unit_test::Suite
     static uint256
     getCheckIndex(AccountID const& account, std::uint32_t uSequence)
     {
-        return keylet::check(account, uSequence).key;
+        return keylet::check(account, SeqProxy::rawSequence(uSequence)).key;
     }
 
     static uint256
@@ -1960,7 +1964,8 @@ class Freeze_test : public beast::unit_test::Suite
         env(token::mint(account, 0), Txflags(tfTransferable));
         env.close();
 
-        uint256 const sellOfferIndex = keylet::nftokenOffer(account, env.seq(account)).key;
+        uint256 const sellOfferIndex =
+            keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key;
         env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken));
         env.close();
 
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 eaf1f2704c..0000000000
--- a/src/test/app/Invariants_test.cpp
+++ /dev/null
@@ -1,6206 +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 
-
-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, (*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(), (*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(), (*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(), (*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, (*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, (*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, (*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, (*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, (*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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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 loanBroker;
-
-        auto const loanBrokerKeylet = keylet::loanBroker(a.id(), 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(loanBroker::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(), 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(), 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(), 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(), 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(), 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(), ac.view().seq());
-                    auto sleVault = ac.view().peek(keylet);
-                    if (!sleVault)
-                        return false;
-                    ac.view().erase(sleVault);
-                }
-                {
-                    auto const keylet = keylet::vault(a2.id(), 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(), 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(), 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(), 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(), 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(), 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(), 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 be positive",
-             "assets available must not be greater than assets outstanding",
-             "assets outstanding must be positive"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), 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(), 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(), 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(), 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(), 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(), 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);
-
-        doInvariantCheck(
-            {"set assets outstanding must not exceed assets maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), 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 be positive"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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 be positive",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), (*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(), (*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(), 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(), 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 2947b3a3ce..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 
@@ -17,9 +18,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
+#include 
+
 namespace xrpl::test {
 
 class LPTokenTransfer_test : public jtx::AMMTest
@@ -310,7 +314,7 @@ class LPTokenTransfer_test : public jtx::AMMTest
 
         // carol_ can always create a check with lptoken that has frozen
         // token
-        uint256 const carolChkId{keylet::check(carol_, env.seq(carol_)).key};
+        uint256 const carolChkId{keylet::check(carol_, SeqProxy::rawSequence(env.seq(carol_))).key};
         env(check::create(carol_, bob_, STAmount{lpIssue, 10}));
         env.close();
 
@@ -327,7 +331,7 @@ class LPTokenTransfer_test : public jtx::AMMTest
         env.close();
 
         // bob_ creates a check
-        uint256 const bobChkId{keylet::check(bob_, env.seq(bob_)).key};
+        uint256 const bobChkId{keylet::check(bob_, SeqProxy::rawSequence(env.seq(bob_))).key};
         env(check::create(bob_, carol_, STAmount{lpIssue, 10}));
         env.close();
 
@@ -359,7 +363,8 @@ class LPTokenTransfer_test : public jtx::AMMTest
         env.close();
 
         // bob_ creates a sell offer for lptoken
-        uint256 const sellOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key;
+        uint256 const sellOfferIndex =
+            keylet::nftokenOffer(bob_, SeqProxy::rawSequence(env.seq(bob_))).key;
         env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), Txflags(tfSellNFToken));
         env.close();
 
@@ -420,7 +425,8 @@ class LPTokenTransfer_test : public jtx::AMMTest
             env.close();
 
             // bob_ creates a buy offer with lptoken despite bob_'s USD is frozen
-            uint256 const buyOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key;
+            uint256 const buyOfferIndex =
+                keylet::nftokenOffer(bob_, SeqProxy::rawSequence(env.seq(bob_))).key;
             env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), token::Owner(carol_));
             env.close();
 
@@ -430,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
@@ -444,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/LedgerNodeHelpers_test.cpp b/src/test/app/LedgerNodeHelpers_test.cpp
new file mode 100644
index 0000000000..76c3e3cc52
--- /dev/null
+++ b/src/test/app/LedgerNodeHelpers_test.cpp
@@ -0,0 +1,261 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+
+#include 
+#include 
+
+namespace xrpl::tests {
+
+class LedgerNodeHelpers_test : public beast::unit_test::Suite
+{
+    static boost::intrusive_ptr
+    makeTestItem(std::uint32_t seed)
+    {
+        Serializer s;
+        s.add32(seed);
+        s.add32(seed + 1);
+        s.add32(seed + 2);
+        return makeShamapitem(s.getSHA512Half(), s.slice());
+    }
+
+    static std::string
+    serializeNode(SHAMapTreeNodePtr const& node)
+    {
+        Serializer s;
+        node->serializeForWire(s);
+        auto const slice = s.slice();
+        return std::string(slice.begin(), slice.end());
+    }
+
+    void
+    testGetTreeNode()
+    {
+        testcase("getTreeNode");
+
+        // Valid: inner node. It must have at least one child for `serializeNode` to work.
+        {
+            auto const innerNode = intr_ptr::makeShared(1);
+            auto const childNode = intr_ptr::makeShared(1);
+            innerNode->setChild(0, childNode);
+            auto const innerData = serializeNode(innerNode);
+            auto const result = getTreeNode(innerData);
+            BEAST_EXPECT(result && result->isInner());
+        }
+
+        // Valid: leaf node.
+        {
+            auto const leafItem = makeTestItem(12345);
+            auto const leafNode = intr_ptr::makeShared(leafItem, 1);
+            auto const leafData = serializeNode(leafNode);
+            auto const result = getTreeNode(leafData);
+            BEAST_EXPECT(result && result->isLeaf());
+        }
+
+        // Invalid: empty data.
+        {
+            auto const result = getTreeNode("");
+            BEAST_EXPECT(!result);
+        }
+
+        // Invalid: garbage data.
+        {
+            auto const result = getTreeNode("invalid");
+            BEAST_EXPECT(!result);
+        }
+
+        // Invalid: truncated data.
+        {
+            auto const leafItem = makeTestItem(54321);
+            auto const leafNode = intr_ptr::makeShared(leafItem, 1);
+            // Truncate the data to trigger an exception in SHAMapTreeNode::makeAccountState when
+            // the data is used to deserialize the node.
+            uint256 const tag;
+            auto const leafData = serializeNode(leafNode).substr(0, tag.kBytes - 1);
+            auto const result = getTreeNode(leafData);
+            BEAST_EXPECT(!result);
+        }
+    }
+
+    void
+    testGetSHAMapNodeID()
+    {
+        testcase("getSHAMapNodeID");
+
+        {
+            // Tests using inner nodes at various depths.
+            auto const innerNode = intr_ptr::makeShared(1);
+            auto const childNode = intr_ptr::makeShared(1);
+            innerNode->setChild(0, childNode);
+            auto const innerData = serializeNode(innerNode);
+
+            // Valid: legacy `nodeid` field at arbitrary depth.
+            {
+                auto const innerDepth = 3;
+                auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{});
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(innerData);
+                ledgerNode.set_nodeid(innerID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *innerNode);
+                BEAST_EXPECT(result == innerID);
+            }
+
+            // Valid: new `id` field at minimum depth.
+            {
+                auto const innerDepth = 0;
+                auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{});
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(innerData);
+                ledgerNode.set_id(innerID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *innerNode);
+                BEAST_EXPECT(result == innerID);
+            }
+
+            // Invalid: new `depth` field should not be used for inner nodes.
+            {
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(innerData);
+                ledgerNode.set_depth(10);
+                auto const result = getSHAMapNodeID(ledgerNode, *innerNode);
+                BEAST_EXPECT(!result);
+            }
+
+            // Invalid: both legacy `nodeid` and new `id` fields set for an inner node.
+            {
+                auto const innerDepth = 9;
+                auto const innerID = SHAMapNodeID::createID(innerDepth, uint256{});
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(innerData);
+                ledgerNode.set_nodeid(innerID.getRawString());
+                ledgerNode.set_id(innerID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *innerNode);
+                BEAST_EXPECT(!result);
+            }
+        }
+
+        {
+            // Tests using leaf nodes at various depths.
+            auto const leafItem = makeTestItem(12345);
+            auto const leafNode = intr_ptr::makeShared(leafItem, 1);
+            auto const leafData = serializeNode(leafNode);
+            auto const leafKey = leafItem->key();
+
+            // Valid: legacy `nodeid` field at arbitrary depth.
+            {
+                auto const kLeafDepth = 5;
+                auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(leafData);
+                ledgerNode.set_nodeid(leafID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(result == leafID);
+            }
+
+            // Invalid: new `id` field should not be used for leaf nodes.
+            {
+                auto const kLeafDepth = 5;
+                auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(leafData);
+                ledgerNode.set_id(leafID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(!result);
+            }
+
+            // Valid: new `depth` field at minimum depth.
+            {
+                auto const kLeafDepth = 0;
+                auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(leafData);
+                ledgerNode.set_depth(kLeafDepth);
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(result == leafID);
+            }
+
+            // Valid: new `depth` field at arbitrary depth between minimum and maximum.
+            {
+                auto const kLeafDepth = 10;
+                auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(leafData);
+                ledgerNode.set_depth(kLeafDepth);
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(result == leafID);
+            }
+
+            // Valid: new `depth` field at maximum depth.
+            // Note that we do not test a depth greater than the maximum depth, because the proto
+            // message is assumed to have been validated by the time the getSHAMapNodeID function is
+            // called.
+            {
+                auto const kLeafDepth = SHAMap::kLeafDepth;
+                auto const leafID = SHAMapNodeID::createID(kLeafDepth, leafKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(leafData);
+                ledgerNode.set_depth(kLeafDepth);
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(result == leafID);
+            }
+
+            // Invalid: legacy `nodeid` field where the node ID is inconsistent with the key.
+            {
+                auto const otherItem = makeTestItem(54321);
+                auto const otherNode =
+                    intr_ptr::makeShared(otherItem, 1);
+                auto const otherData = serializeNode(otherNode);
+                auto const otherKey = otherItem->key();
+                auto const otherDepth = 1;
+                auto const otherID = SHAMapNodeID::createID(otherDepth, otherKey);
+
+                protocol::TMLedgerNode ledgerNode;
+                ledgerNode.set_nodedata(otherData);
+                ledgerNode.set_nodeid(otherID.getRawString());
+                auto const result = getSHAMapNodeID(ledgerNode, *leafNode);
+                BEAST_EXPECT(!result);
+            }
+        }
+
+        // Invalid: no field set.
+        {
+            auto const innerNode = intr_ptr::makeShared(1);
+            protocol::TMLedgerNode ledgerNode;
+            ledgerNode.set_nodedata("test_data");
+            auto const result = getSHAMapNodeID(ledgerNode, *innerNode);
+            BEAST_EXPECT(!result);
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testGetTreeNode();
+        testGetSHAMapNodeID();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LedgerNodeHelpers, app, xrpl);
+
+}  // namespace xrpl::tests
diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp
index 4cc83608d6..bb0c790e37 100644
--- a/src/test/app/LedgerReplay_test.cpp
+++ b/src/test/app/LedgerReplay_test.cpp
@@ -1,5 +1,6 @@
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -21,28 +22,21 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 
 #include 
 #include 
-#include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
-#include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -53,11 +47,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -266,141 +263,38 @@ enum class PeerFeature {
  * Simulate a network peer.
  * Depending on the configured PeerFeature,
  * it either supports the ProtocolFeature::LedgerReplay or not
+ *
+ * `PeerStub` supplies the rest of the `Peer` interface as no-ops.
  */
-class TestPeer : public Peer
+class TestPeer : public PeerStub
 {
 public:
-    TestPeer(bool enableLedgerReplay)
-        : ledgerReplayEnabled_(enableLedgerReplay)
-        , nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey()))
+    // Arbitrary but fixed: the replay code only compares ids.
+    explicit TestPeer(bool enableLedgerReplay)
+        : PeerStub(1234), ledgerReplayEnabled_(enableLedgerReplay)
     {
     }
 
-    void
-    send(std::shared_ptr const& m) override
-    {
-    }
-    [[nodiscard]] beast::IP::Endpoint
-    getRemoteAddress() const override
-    {
-        return {};
-    }
-    void
-    charge(Resource::Charge const& fee, std::string const& context = {}) override
-    {
-    }
-    [[nodiscard]] id_t
-    id() const override
-    {
-        return 1234;
-    }
-    [[nodiscard]] bool
-    cluster() const override
-    {
-        return false;
-    }
-    [[nodiscard]] bool
-    isHighLatency() const override
-    {
-        return false;
-    }
-    [[nodiscard]] int
-    getScore(bool) const override
-    {
-        return 0;
-    }
-    [[nodiscard]] PublicKey const&
-    getNodePublic() const override
-    {
-        return nodePublicKey_;
-    }
-    json::Value
-    json() override
-    {
-        return {};
-    }
     [[nodiscard]] bool
     supportsFeature(ProtocolFeature f) const override
     {
         return f == ProtocolFeature::LedgerReplay && ledgerReplayEnabled_;
     }
-    [[nodiscard]] std::optional
-    publisherListSequence(PublicKey const&) const override
-    {
-        return {};
-    }
-    void
-    setPublisherListSequence(PublicKey const&, std::size_t const) override
-    {
-    }
-    [[nodiscard]] uint256 const&
-    getClosedLedgerHash() const override
-    {
-        static uint256 const kHash{};
-        return kHash;
-    }
+
+    // The replay code only asks peers that already have the ledger.
     [[nodiscard]] bool
-    hasLedger(uint256 const& hash, std::uint32_t seq) const override
+    hasLedger(uint256 const&, std::uint32_t) const override
     {
         return true;
     }
-    void
-    ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override
-    {
-    }
-    [[nodiscard]] bool
-    hasTxSet(uint256 const& hash) const override
-    {
-        return false;
-    }
-    void
-    cycleStatus() override
-    {
-    }
-    bool
-    hasRange(std::uint32_t uMin, std::uint32_t uMax) override
-    {
-        return false;
-    }
-    [[nodiscard]] bool
-    compressionEnabled() const override
-    {
-        return false;
-    }
-    void
-    sendTxQueue() override
-    {
-    }
-    void
-    addTxQueue(uint256 const&) override
-    {
-    }
-    void
-    removeTxQueue(uint256 const&) override
-    {
-    }
-    [[nodiscard]] bool
-    txReduceRelayEnabled() const override
-    {
-        return false;
-    }
 
-    [[nodiscard]] std::string const&
-    fingerprint() const override
-    {
-        return fingerprint_;
-    }
-
-    // NOLINTBEGIN(readability-identifier-naming)
-    std::string fingerprint_;
+private:
     bool ledgerReplayEnabled_;
-    PublicKey nodePublicKey_;
-    // NOLINTEND(readability-identifier-naming)
 };
 
 enum class PeerSetBehavior {
     Good,
-    Drop50,
+    DropAlternate,
     DropAll,
     DropSkipListReply,
     DropLedgerDeltaReply,
@@ -443,17 +337,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)
@@ -498,6 +388,7 @@ struct TestPeerSet : public PeerSet
     LedgerReplayMsgHandler& remote;
     std::shared_ptr dummyPeer;
     PeerSetBehavior behavior;
+    std::atomic sendCount{0};
 };
 
 /**
@@ -958,7 +849,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             auto reply = std::make_shared(
                 server.msgHandler.processProofPathRequest(request));
             BEAST_EXPECT(reply->has_error());
-            BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply));
+            BEAST_EXPECT(
+                server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::BadData);
         }
         {
             // request, wrong hash
@@ -982,7 +874,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             auto reply = std::make_shared(
                 server.msgHandler.processProofPathRequest(request));
             BEAST_EXPECT(!reply->has_error());
-            BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply));
+            BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok);
 
             {
                 // bad reply: invalid hash/key sizes
@@ -990,37 +882,49 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
                     // reply with undersized ledgerhash (31 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string(31, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with oversized ledgerhash (33 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string(33, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with empty ledgerhash
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string());
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with undersized key (31 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_key(std::string(31, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with oversized key (33 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_key(std::string(33, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with empty key
                     auto bad = std::make_shared(*reply);
                     bad->set_key(std::string());
-                    BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processProofPathResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
             }
 
@@ -1030,13 +934,18 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
                 std::string r(reply->ledgerheader());
                 r.back()--;
                 reply->set_ledgerheader(r);
-                BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processProofPathResponse(reply) ==
+                    ReplayMsgStatus::Malformed);
                 r.back()++;
                 reply->set_ledgerheader(r);
-                BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok);
                 // bad proof path
                 reply->mutable_path()->RemoveLast();
-                BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processProofPathResponse(reply) ==
+                    ReplayMsgStatus::Malformed);
             }
         }
     }
@@ -1054,14 +963,16 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             auto reply = std::make_shared(
                 server.msgHandler.processReplayDeltaRequest(request));
             BEAST_EXPECT(reply->has_error());
-            BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply));
+            BEAST_EXPECT(
+                server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData);
             // request, wrong hash
             uint256 hash(1234567);
             request->set_ledgerhash(hash.data(), hash.size());
             reply = std::make_shared(
                 server.msgHandler.processReplayDeltaRequest(request));
             BEAST_EXPECT(reply->has_error());
-            BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply));
+            BEAST_EXPECT(
+                server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData);
         }
 
         {
@@ -1071,7 +982,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             auto reply = std::make_shared(
                 server.msgHandler.processReplayDeltaRequest(request));
             BEAST_EXPECT(!reply->has_error());
-            BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply));
+            BEAST_EXPECT(
+                server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok);
 
             {
                 // bad reply: invalid hash sizes
@@ -1079,19 +991,25 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
                     // reply with undersized ledgerhash (31 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string(31, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processReplayDeltaResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with oversized ledgerhash (33 bytes)
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string(33, '\x01'));
-                    BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processReplayDeltaResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
                 {
                     // reply with empty ledgerhash
                     auto bad = std::make_shared(*reply);
                     bad->set_ledgerhash(std::string());
-                    BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad));
+                    BEAST_EXPECT(
+                        server.msgHandler.processReplayDeltaResponse(bad) ==
+                        ReplayMsgStatus::Malformed);
                 }
             }
 
@@ -1101,17 +1019,77 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
                 std::string r(reply->ledgerheader());
                 r.back()--;
                 reply->set_ledgerheader(r);
-                BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processReplayDeltaResponse(reply) ==
+                    ReplayMsgStatus::Malformed);
                 r.back()++;
                 reply->set_ledgerheader(r);
-                BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok);
                 // bad txns
                 reply->mutable_transaction()->RemoveLast();
-                BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply));
+                BEAST_EXPECT(
+                    server.msgHandler.processReplayDeltaResponse(reply) ==
+                    ReplayMsgStatus::Malformed);
             }
         }
     }
 
+    void
+    testTruncatedHeader()
+    {
+        testcase("TruncatedLedgerHeader");
+        LedgerServer server(*this, {.initLedgers = 1});
+        auto const l = server.ledgerMaster.getClosedLedger();
+
+        auto runNoThrow = [this](auto fn, char const* what) {
+            try
+            {
+                BEAST_EXPECT(fn() == ReplayMsgStatus::Malformed);
+            }
+            catch (std::exception const& e)
+            {
+                fail(
+                    std::format("processor threw on truncated header ({}): {}", what, e.what()),
+                    __FILE__,
+                    __LINE__);
+            }
+            catch (...)
+            {
+                fail(
+                    std::format("processor threw unknown exception ({}) on truncated header", what),
+                    __FILE__,
+                    __LINE__);
+            }
+        };
+
+        {
+            auto request = std::make_shared();
+            request->set_ledgerhash(l->header().hash.data(), l->header().hash.size());
+            auto reply = std::make_shared(
+                server.msgHandler.processReplayDeltaRequest(request));
+            BEAST_EXPECT(!reply->has_error());
+
+            reply->set_ledgerheader(std::string(1, '\x00'));
+            runNoThrow(
+                [&] { return server.msgHandler.processReplayDeltaResponse(reply); }, "ReplayDelta");
+        }
+
+        {
+            auto request = std::make_shared();
+            request->set_ledgerhash(l->header().hash.data(), l->header().hash.size());
+            request->set_type(protocol::TMLedgerMapType::lmACCOUNT_STATE);
+            request->set_key(keylet::skip().key.data(), keylet::skip().key.size());
+            auto reply = std::make_shared(
+                server.msgHandler.processProofPathRequest(request));
+            BEAST_EXPECT(!reply->has_error());
+
+            reply->set_ledgerheader(std::string(1, '\x00'));
+            runNoThrow(
+                [&] { return server.msgHandler.processProofPathResponse(reply); }, "ProofPath");
+        }
+    }
+
     void
     testTaskParameter()
     {
@@ -1206,7 +1184,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             if (serverResult != expecting)
                 return false;
 
-            beast::IP::Address const addr = boost::asio::ip::make_address("172.1.1.100");
+            beast::ip::Address const addr = boost::asio::ip::make_address("172.1.1.100");
             jtx::Env serverEnv(*this);
             serverEnv.app().config().ledgerReplay = server;
             auto httpResp = xrpl::makeResponse(
@@ -1308,7 +1286,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:
@@ -1514,6 +1492,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
     {
         testProofPath();
         testReplayDelta();
+        testTruncatedHeader();
         testTaskParameter();
         testConfig();
         testHandshake();
@@ -1523,7 +1502,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/Loan_test.cpp b/src/test/app/Loan_test.cpp
deleted file mode 100644
index 231a3b405a..0000000000
--- a/src/test/app/Loan_test.cpp
+++ /dev/null
@@ -1,8767 +0,0 @@
-#include 
-//
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class Loan_test : public beast::unit_test::Suite
-{
-protected:
-    // Ensure that all the features needed for Lending Protocol are included,
-    // even if they are set to unsupported.
-
-    FeatureBitset const all_{jtx::testableAmendments()};
-    std::string const iouCurrency_{"IOU"};
-
-    void
-    testDisabled()
-    {
-        testcase("Disabled");
-        // Lending Protocol depends on Single Asset Vault (SAV). Test
-        // combinations of the two amendments.
-        // Single Asset Vault depends on MPTokensV1, but don't test every combo
-        // of that.
-        using namespace jtx;
-        auto failAll = [this](FeatureBitset features) {
-            Env env(*this, features);
-
-            Account const alice{"alice"};
-            Account const bob{"bob"};
-            env.fund(XRP(10000), alice, bob);
-
-            auto const keylet = keylet::loanBroker(alice, env.seq(alice));
-
-            using namespace std::chrono_literals;
-            using namespace loan;
-
-            // counter party signature is optional on LoanSet. Confirm that by
-            // sending transaction without one.
-            auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED));
-            env(setTx);
-
-            // All loan transactions are disabled.
-            // 1. LoanSet
-            setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED));
-            env(setTx);
-            // Actual sequence will be based off the loan broker, but we
-            // obviously don't have one of those if the amendment is disabled
-            auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice));
-            // Other Loan transactions are disabled, too.
-            // 2. LoanDelete
-            env(del(alice, loanKeylet.key), Ter(temDISABLED));
-            // 3. LoanManage
-            env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED));
-            // 4. LoanPay
-            env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED));
-        };
-        failAll(all_ - featureMPTokensV1);
-        failAll(all_ - featureSingleAssetVault - featureLendingProtocol);
-        failAll(all_ - featureSingleAssetVault);
-        failAll(all_ - featureLendingProtocol);
-    }
-
-    struct BrokerParameters
-    {
-        Number vaultDeposit = 1'000'000;
-        Number debtMax = 25'000;
-        TenthBips32 coverRateMin = percentageToTenthBips(10);
-        int coverDeposit = 1000;
-        TenthBips16 managementFeeRate{100};
-        TenthBips32 coverRateLiquidation = percentageToTenthBips(25);
-        std::string data = {};  // NOLINT(readability-redundant-member-init)
-        std::uint32_t flags = 0;
-        // 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)
-
-        [[nodiscard]] Number
-        maxCoveredLoanValue(Number const& currentDebt) const
-        {
-            NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
-            auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value();
-
-            return debtLimit - currentDebt;
-        }
-
-        static BrokerParameters const&
-        defaults()
-        {
-            static BrokerParameters const kResult{};
-            return kResult;
-        }
-
-        // TODO: create an operator() which returns a transaction similar to
-        // LoanParameters
-    };
-
-    struct BrokerInfo
-    {
-        jtx::PrettyAsset asset;
-        uint256 brokerID;
-        uint256 vaultID;
-        BrokerParameters params;
-        BrokerInfo(
-            jtx::PrettyAsset const& asset,
-            Keylet const& brokerKeylet,
-            Keylet const& vaultKeylet,
-            BrokerParameters p)
-            : asset(asset)
-            , brokerID(brokerKeylet.key)
-            , vaultID(vaultKeylet.key)
-            , params(std::move(p))
-        {
-        }
-
-        [[nodiscard]] Keylet
-        brokerKeylet() const
-        {
-            return keylet::loanBroker(brokerID);
-        }
-        [[nodiscard]] Keylet
-        vaultKeylet() const
-        {
-            return keylet::vault(vaultID);
-        }
-
-        [[nodiscard]] int
-        vaultScale(jtx::Env const& env) const
-        {
-            using namespace jtx;
-
-            auto const vaultSle = env.le(keylet::vault(vaultID));
-            return getAssetsTotalScale(vaultSle);
-        }
-    };
-
-    struct LoanParameters
-    {
-        // The account submitting the transaction. May be borrower or broker.
-        jtx::Account account;
-        // The counterparty. Should be the other of borrower or broker.
-        jtx::Account counter;
-        // Whether the counterparty is specified in the `counterparty` field, or
-        // only signs.
-        bool counterpartyExplicit = true;
-        Number principalRequest;
-        // NOLINTBEGIN(readability-redundant-member-init)
-        std::optional setFee = std::nullopt;
-        std::optional originationFee = std::nullopt;
-        std::optional serviceFee = std::nullopt;
-        std::optional lateFee = std::nullopt;
-        std::optional closeFee = std::nullopt;
-        std::optional overFee = std::nullopt;
-        std::optional interest = std::nullopt;
-        std::optional lateInterest = std::nullopt;
-        std::optional closeInterest = std::nullopt;
-        std::optional overpaymentInterest = std::nullopt;
-        std::optional payTotal = std::nullopt;
-        std::optional payInterval = std::nullopt;
-        std::optional gracePd = std::nullopt;
-        std::optional flags = std::nullopt;
-        // NOLINTEND(readability-redundant-member-init)
-
-        template 
-        jtx::JTx
-        operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const
-        {
-            using namespace jtx;
-            using namespace jtx::loan;
-
-            JTx jt{loan::set(
-                account,
-                broker.brokerID,
-                broker.asset(principalRequest).number(),
-                flags.value_or(0))};
-
-            Sig(sfCounterpartySignature, counter)(env, jt);
-
-            Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt);
-
-            if (counterpartyExplicit)
-                kCounterparty(counter)(env, jt);
-            if (originationFee)
-                kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt);
-            if (serviceFee)
-                kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt);
-            if (lateFee)
-                kLatePaymentFee(broker.asset(*lateFee).number())(env, jt);
-            if (closeFee)
-                kClosePaymentFee(broker.asset(*closeFee).number())(env, jt);
-            if (overFee)
-                kOverpaymentFee (*overFee)(env, jt);
-            if (interest)
-                kInterestRate (*interest)(env, jt);
-            if (lateInterest)
-                kLateInterestRate (*lateInterest)(env, jt);
-            if (closeInterest)
-                kCloseInterestRate (*closeInterest)(env, jt);
-            if (overpaymentInterest)
-                kOverpaymentInterestRate (*overpaymentInterest)(env, jt);
-            if (payTotal)
-                kPaymentTotal (*payTotal)(env, jt);
-            if (payInterval)
-                kPaymentInterval (*payInterval)(env, jt);
-            if (gracePd)
-                kGracePeriod (*gracePd)(env, jt);
-
-            return env.jt(jt, fN...);
-        }
-    };
-
-    struct PaymentParameters
-    {
-        Number overpaymentFactor = Number{1};
-        std::optional overpaymentExtra = std::nullopt;
-        std::uint32_t flags = 0;
-        bool showStepBalances = false;
-        bool validateBalances = true;
-
-        static PaymentParameters const&
-        defaults()
-        {
-            static PaymentParameters const kResult{};
-            return kResult;
-        }
-    };
-
-    struct LoanState
-    {
-        std::uint32_t previousPaymentDate = 0;
-        NetClock::time_point startDate;
-        std::uint32_t nextPaymentDate = 0;
-        std::uint32_t paymentRemaining = 0;
-        std::int32_t const loanScale = 0;
-        Number totalValue = 0;
-        Number principalOutstanding = 0;
-        Number managementFeeOutstanding = 0;
-        Number periodicPayment = 0;
-        std::uint32_t flags = 0;
-        std::uint32_t const paymentInterval = 0;
-        TenthBips32 const interestRate{};
-    };
-
-    /**
-     * Helper class to compare the expected state of a loan and loan broker
-     * against the data in the ledger.
-     */
-    struct VerifyLoanStatus
-    {
-    public:
-        jtx::Env const& env;
-        BrokerInfo const& broker;
-        jtx::Account const& pseudoAccount;
-        Keylet const& loanKeylet;
-
-        VerifyLoanStatus(
-            jtx::Env const& env,
-            BrokerInfo const& broker,
-            jtx::Account const& pseudo,
-            Keylet const& keylet)
-            : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet)
-        {
-        }
-
-        /**
-         * Checks the expected broker state against the ledger
-         */
-        void
-        checkBroker(
-            Number const& principalOutstanding,
-            Number const& interestOwed,
-            TenthBips32 interestRate,
-            std::uint32_t paymentInterval,
-            std::uint32_t paymentsRemaining,
-            std::uint32_t ownerCount) const
-        {
-            using namespace jtx;
-            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-                env.test.BEAST_EXPECT(brokerSle))
-            {
-                TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
-                auto const brokerDebt = brokerSle->at(sfDebtTotal);
-                auto const expectedDebt = principalOutstanding + interestOwed;
-                env.test.BEAST_EXPECT(brokerDebt == expectedDebt);
-                env.test.BEAST_EXPECT(
-                    env.balance(pseudoAccount, broker.asset).number() ==
-                    brokerSle->at(sfCoverAvailable));
-                env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount);
-
-                if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
-                    env.test.BEAST_EXPECT(vaultSle))
-                {
-                    Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)};
-                    env.test.BEAST_EXPECT(
-                        vaultSle->at(sfAssetsAvailable) ==
-                        env.balance(vaultPseudo, broker.asset).number());
-                    if (ownerCount == 0)
-                    {
-                        // The Vault must be perfectly balanced if there
-                        // are no loans outstanding
-                        auto const total = vaultSle->at(sfAssetsTotal);
-                        auto const available = vaultSle->at(sfAssetsAvailable);
-                        env.test.BEAST_EXPECT(total == available);
-                        env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
-                    }
-                }
-            }
-        }
-
-        void
-        checkPayment(
-            std::int32_t loanScale,
-            jtx::Account const& account,
-            jtx::PrettyAmount const& balanceBefore,
-            STAmount const& expectedPayment,
-            jtx::PrettyAmount const& adjustment) const
-        {
-            auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent());
-
-            STAmount const balanceChangeAmount{
-                broker.asset,
-                roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)};
-            {
-                auto const difference = roundToScale(
-                    env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount),
-                    borrowerScale);
-                env.test.expect(
-                    roundToScale(difference, loanScale) >= beast::kZero,
-                    "Balance before: " + to_string(balanceBefore.value()) +
-                        ", expected change: " + to_string(balanceChangeAmount) +
-                        ", difference (balance after - expected): " + to_string(difference),
-                    __FILE__,
-                    __LINE__);
-            }
-        }
-
-        /**
-         * Checks both the loan and broker expect states against the ledger
-         */
-        void
-        operator()(
-            std::uint32_t previousPaymentDate,
-            std::uint32_t nextPaymentDate,
-            std::uint32_t paymentRemaining,
-            Number const& loanScale,
-            Number const& totalValue,
-            Number const& principalOutstanding,
-            Number const& managementFeeOutstanding,
-            Number const& periodicPayment,
-            std::uint32_t flags) const
-        {
-            using namespace jtx;
-            if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
-            {
-                env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate);
-                env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining);
-                env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate);
-                env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale);
-                env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue);
-                env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding);
-                env.test.BEAST_EXPECT(
-                    loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding);
-                env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment);
-                env.test.BEAST_EXPECT(loan->at(sfFlags) == flags);
-
-                auto const ls = constructLoanState(loan);
-
-                auto const interestRate = TenthBips32{loan->at(sfInterestRate)};
-                auto const paymentInterval = loan->at(sfPaymentInterval);
-                checkBroker(
-                    principalOutstanding,
-                    ls.interestDue,
-                    interestRate,
-                    paymentInterval,
-                    paymentRemaining,
-                    1);
-
-                if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-                    env.test.BEAST_EXPECT(brokerSle))
-                {
-                    if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
-                        env.test.BEAST_EXPECT(vaultSle))
-                    {
-                        if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u))
-                        {
-                            env.test.BEAST_EXPECT(
-                                vaultSle->at(sfLossUnrealized) ==
-                                totalValue - managementFeeOutstanding);
-                        }
-                        else
-                        {
-                            env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
-                        }
-                    }
-                }
-            }
-        }
-
-        /**
-         * Checks both the loan and broker expect states against the ledger
-         */
-        void
-        operator()(LoanState const& state) const
-        {
-            operator()(
-                state.previousPaymentDate,
-                state.nextPaymentDate,
-                state.paymentRemaining,
-                state.loanScale,
-                state.totalValue,
-                state.principalOutstanding,
-                state.managementFeeOutstanding,
-                state.periodicPayment,
-                state.flags);
-        };
-    };
-
-    BrokerInfo
-    createVaultAndBroker(
-        jtx::Env& env,
-        jtx::PrettyAsset const& asset,
-        jtx::Account const& lender,
-        BrokerParameters const& params = BrokerParameters::defaults())
-    {
-        using namespace jtx;
-
-        Vault const vault{env};
-
-        auto const deposit = asset(params.vaultDeposit);
-        auto const debtMaximumValue = asset(params.debtMax).value();
-        auto const coverDepositValue = asset(params.coverDeposit).value();
-
-        auto const coverRateMinValue = params.coverRateMin;
-
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
-        if (params.vaultScale)
-            tx[sfScale] = *params.vaultScale;
-        env(tx);
-        env.close();
-        BEAST_EXPECT(env.le(vaultKeylet));
-
-        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
-        env.close();
-        if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault))
-        {
-            BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value());
-        }
-
-        auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender));
-
-        using namespace loanBroker;
-        env(set(lender, vaultKeylet.key, params.flags),
-            kData(params.data),
-            kManagementFeeRate(params.managementFeeRate),
-            kDebtMaximum(debtMaximumValue),
-            kCoverRateMinimum(coverRateMinValue),
-            kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation)));
-
-        if (coverDepositValue != beast::kZero)
-            env(coverDeposit(lender, keylet.key, coverDepositValue));
-
-        env.close();
-
-        return {asset, keylet, vaultKeylet, params};
-    }
-
-    /**
-     * Get the state without checking anything
-     */
-    LoanState
-    getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet)
-    {
-        using d = NetClock::duration;
-        using tp = NetClock::time_point;
-
-        // Lookup the current loan state
-        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
-        {
-            return LoanState{
-                .previousPaymentDate = loan->at(sfPreviousPaymentDueDate),
-                .startDate = tp{d{loan->at(sfStartDate)}},
-                .nextPaymentDate = loan->at(sfNextPaymentDueDate),
-                .paymentRemaining = loan->at(sfPaymentRemaining),
-                .loanScale = loan->at(sfLoanScale),
-                .totalValue = loan->at(sfTotalValueOutstanding),
-                .principalOutstanding = loan->at(sfPrincipalOutstanding),
-                .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding),
-                .periodicPayment = loan->at(sfPeriodicPayment),
-                .flags = loan->at(sfFlags),
-                .paymentInterval = loan->at(sfPaymentInterval),
-                .interestRate = TenthBips32{loan->at(sfInterestRate)},
-            };
-        }
-        return LoanState{};
-    }
-
-    /**
-     * Get the state and check the values against the parameters used in
-     * `lifecycle`
-     */
-    LoanState
-    getCurrentState(
-        jtx::Env const& env,
-        BrokerInfo const& broker,
-        Keylet const& loanKeylet,
-        VerifyLoanStatus const& verifyLoanStatus)
-    {
-        using namespace std::chrono_literals;
-        using d = NetClock::duration;
-        using tp = NetClock::time_point;
-
-        auto const state = getCurrentState(env, broker, loanKeylet);
-        BEAST_EXPECT(state.previousPaymentDate == 0);
-        BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s);
-        BEAST_EXPECT(state.paymentRemaining == 12);
-        BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value());
-        BEAST_EXPECT(
-            state.loanScale >=
-            (broker.asset.integral()
-                 ? 0
-                 : std::max(broker.vaultScale(env), state.principalOutstanding.exponent())));
-        BEAST_EXPECT(state.paymentInterval == 600);
-        {
-            NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-            BEAST_EXPECT(
-                state.totalValue ==
-                roundToAsset(
-                    broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale));
-        }
-        BEAST_EXPECT(
-            state.managementFeeOutstanding ==
-            computeManagementFee(
-                broker.asset,
-                state.totalValue - state.principalOutstanding,
-                broker.params.managementFeeRate,
-                state.loanScale));
-
-        verifyLoanStatus(state);
-
-        return state;
-    }
-
-    bool
-    canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state)
-    {
-        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
-                BEAST_EXPECT(vaultSle))
-            {
-                // log << vaultSle->getJson() << std::endl;
-                auto const assetsUnavailable =
-                    vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable);
-                auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue -
-                    state.managementFeeOutstanding;
-
-                if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable))
-                {
-                    return false;
-                }
-            }
-        }
-        return true;
-    }
-
-    enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
-
-    // Specify the accounts as params to allow other accounts to be used
-    jtx::PrettyAsset
-    createAsset(
-        jtx::Env& env,
-        AssetType assetType,
-        BrokerParameters const& brokerParams,
-        jtx::Account const& issuer,
-        jtx::Account const& lender,
-        jtx::Account const& borrower)
-    {
-        using namespace jtx;
-
-        switch (assetType)
-        {
-            case AssetType::XRP:
-                // TODO: remove the factor, and set up loans in drops
-                return PrettyAsset{xrpIssue(), 1'000'000};
-
-            case AssetType::IOU: {
-                PrettyAsset const asset{issuer[iouCurrency_]};
-
-                auto const limit =
-                    asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit));
-                if (lender != issuer)
-                    env(trust(lender, limit));
-                if (borrower != issuer)
-                    env(trust(borrower, limit));
-
-                return asset;
-            }
-
-            case AssetType::MPT: {
-                // Enough to cover initial fees
-                if (!env.le(keylet::account(issuer)))
-                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer);
-                if (!env.le(keylet::account(lender)))
-                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender));
-                if (!env.le(keylet::account(borrower)))
-                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower));
-
-                MPTTester mptt{env, issuer, kMptInitNoFund};
-                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-                // Scale the MPT asset so interest is interesting
-                PrettyAsset const asset{mptt.issuanceID(), 10'000};
-                // Need to do the authorization here because mptt isn't
-                // accessible outside
-                if (lender != issuer)
-                    mptt.authorize({.account = lender});
-                if (borrower != issuer)
-                    mptt.authorize({.account = borrower});
-
-                env.close();
-
-                return asset;
-            }
-
-            default:
-                throw std::runtime_error("Unknown asset type");
-        }
-    }
-
-    void
-    describeLoan(
-        jtx::Env& env,
-        BrokerParameters const& brokerParams,
-        LoanParameters const& loanParams,
-        AssetType assetType,
-        jtx::Account const& issuer,
-        jtx::Account const& lender,
-        jtx::Account const& borrower)
-    {
-        using namespace jtx;
-
-        auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
-        auto const principal = asset(loanParams.principalRequest).number();
-        auto const interest = loanParams.interest.value_or(TenthBips32{});
-        auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval);
-        auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal);
-        auto const feeRate = brokerParams.managementFeeRate;
-        auto const props = computeLoanProperties(
-            env.current()->rules(),
-            asset,
-            principal,
-            interest,
-            interval,
-            total,
-            feeRate,
-            asset(brokerParams.vaultDeposit).number().exponent());
-        log << "Loan properties:\n"
-            << "\tPrincipal: " << principal << std::endl
-            << "\tInterest rate: " << interest << std::endl
-            << "\tPayment interval: " << interval << std::endl
-            << "\tManagement Fee Rate: " << feeRate << std::endl
-            << "\tTotal Payments: " << total << std::endl
-            << "\tPeriodic Payment: " << props.periodicPayment << std::endl
-            << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl
-            << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl
-            << "\tLoan Scale: " << props.loanScale << std::endl
-            << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl;
-
-        // checkGuards returns a TER, so success is 0
-        BEAST_EXPECT(!checkLoanGuards(
-            asset,
-            asset(loanParams.principalRequest).number(),
-            loanParams.interest.value_or(TenthBips32{}) != beast::kZero,
-            loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal),
-            props,
-            env.journal));
-    }
-
-    std::optional>
-    createLoan(
-        jtx::Env& env,
-        AssetType assetType,
-        BrokerParameters const& brokerParams,
-        LoanParameters const& loanParams,
-        jtx::Account const& issuer,
-        jtx::Account const& lender,
-        jtx::Account const& borrower)
-    {
-        using namespace jtx;
-
-        // Enough to cover initial fees
-        env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer);
-        if (lender != issuer)
-            env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender));
-        if (borrower != issuer && borrower != lender)
-            env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower));
-
-        describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower);
-
-        // Make the asset
-        auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
-
-        env.close();
-        if (asset.native() || lender != issuer)
-        {
-            env(
-                pay((asset.native() ? env.master : issuer),
-                    lender,
-                    asset(brokerParams.vaultDeposit + brokerParams.coverDeposit)));
-        }
-        // Fund the borrower later once we know the total loan
-        // size
-
-        BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
-
-        auto const pseudoAcctOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return std::nullopt;
-            auto const brokerPseudo = brokerSle->at(sfAccount);
-            return Account("Broker pseudo-account", brokerPseudo);
-        }();
-        if (!pseudoAcctOpt)
-            return std::nullopt;
-        Account const& pseudoAcct = *pseudoAcctOpt;
-
-        auto const loanKeyletOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return std::nullopt;
-
-            // Broker has no loans
-            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
-
-            // The loan keylet is based on the LoanSequence of the
-            // _LOAN_BROKER_ object.
-            auto const loanSequence = brokerSle->at(sfLoanSequence);
-            return keylet::loan(broker.brokerID, loanSequence);
-        }();
-        if (!loanKeyletOpt)
-            return std::nullopt;
-        Keylet const& loanKeylet = *loanKeyletOpt;
-
-        env(loanParams(env, broker));
-
-        env.close();
-
-        return std::make_tuple(broker, loanKeylet, pseudoAcct);
-    }
-
-    static void
-    topUpBorrower(
-        jtx::Env& env,
-        BrokerInfo const& broker,
-        jtx::Account const& issuer,
-        jtx::Account const& borrower,
-        LoanState const& state,
-        std::optional const& servFee)
-    {
-        using namespace jtx;
-
-        STAmount const serviceFee = broker.asset(servFee.value_or(0));
-
-        // Ensure the borrower has enough funds to make the payments
-        // (including tx fees, if necessary)
-        auto const borrowerBalance = env.balance(borrower, broker.asset);
-
-        auto const baseFee = env.current()->fees().base;
-
-        // Add extra for transaction fees and reserves, if appropriate, or a
-        // tiny amount for the extra paid in each transaction
-        auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) +
-            (broker.asset.native() ? Number(
-                                         baseFee * state.paymentRemaining +
-                                         accountReserve(*env.current(), borrower.id(), env.journal))
-                                   : broker.asset(15).number());
-
-        auto const shortage = totalNeeded - borrowerBalance.number();
-
-        if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower))
-        {
-            env(
-                pay((broker.asset.native() ? env.master : issuer),
-                    borrower,
-                    STAmount{broker.asset, shortage}));
-        }
-    }
-
-    void
-    makeLoanPayments(
-        jtx::Env& env,
-        BrokerInfo const& broker,
-        LoanParameters const& loanParams,
-        Keylet const& loanKeylet,
-        VerifyLoanStatus const& verifyLoanStatus,
-        jtx::Account const& issuer,
-        jtx::Account const& lender,
-        jtx::Account const& borrower,
-        PaymentParameters const& paymentParams = PaymentParameters::defaults())
-    {
-        // Make all the individual payments
-        using namespace jtx;
-        using namespace jtx::loan;
-        using namespace std::chrono_literals;
-        using d = NetClock::duration;
-
-        bool const showStepBalances = paymentParams.showStepBalances;
-
-        auto const currencyLabel = getCurrencyLabel(broker.asset);
-
-        auto const baseFee = env.current()->fees().base;
-
-        env.close();
-        auto state = getCurrentState(env, broker, loanKeylet);
-
-        verifyLoanStatus(state);
-
-        STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0));
-
-        topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
-
-        // Periodic payment amount will consist of
-        // 1. principal outstanding (1000)
-        // 2. interest interest rate (at 12%)
-        // 3. payment interval (600s)
-        // 4. loan service fee (2)
-        // Calculate these values without the helper functions
-        // to verify they're working correctly The numbers in
-        // the below BEAST_EXPECTs may not hold across assets.
-        auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
-        STAmount const roundedPeriodicPayment{
-            broker.asset,
-            roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
-
-        if (!showStepBalances)
-        {
-            log << currencyLabel << " Payment components: "
-                << "Payments remaining, "
-                << "rawInterest, rawPrincipal, "
-                   "rawMFee, "
-                << "trackedValueDelta, trackedPrincipalDelta, "
-                   "trackedInterestDelta, trackedMgmtFeeDelta, special"
-                << std::endl;
-        }
-
-        // Include the service fee
-        STAmount const totalDue = roundToScale(
-            roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward);
-
-        auto currentRoundedState = constructLoanState(
-            state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
-        {
-            auto const raw = computeTheoreticalLoanState(
-                env.current()->rules(),
-                state.periodicPayment,
-                periodicRate,
-                state.paymentRemaining,
-                broker.params.managementFeeRate);
-
-            if (showStepBalances)
-            {
-                log << currencyLabel << " Starting loan balances: "
-                    << "\n\tTotal value: " << currentRoundedState.valueOutstanding
-                    << "\n\tPrincipal: " << currentRoundedState.principalOutstanding
-                    << "\n\tInterest: " << currentRoundedState.interestDue
-                    << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue
-                    << "\n\tPayments remaining " << state.paymentRemaining << std::endl;
-            }
-            else
-            {
-                log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", "
-                    << raw.interestDue << ", " << raw.principalOutstanding << ", "
-                    << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", "
-                    << currentRoundedState.principalOutstanding << ", "
-                    << currentRoundedState.interestDue << ", "
-                    << currentRoundedState.managementFeeDue << std::endl;
-            }
-        }
-
-        // Try to pay a little extra to show that it's _not_
-        // taken
-        auto const extraAmount = paymentParams.overpaymentExtra
-            ? broker.asset(*paymentParams.overpaymentExtra).value()
-            : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20});
-
-        STAmount const transactionAmount =
-            STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount;
-
-        auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number();
-        auto const initialState = state;
-        xrpl::detail::PaymentComponents totalPaid{
-            .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0};
-        Number totalInterestPaid = 0;
-        Number totalFeesPaid = 0;
-        std::size_t totalPaymentsMade = 0;
-
-        xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
-            env.current()->rules(),
-            state.periodicPayment,
-            periodicRate,
-            state.paymentRemaining,
-            broker.params.managementFeeRate);
-
-        auto validateBorrowerBalance = [&]() {
-            if (borrower == issuer || !paymentParams.validateBalances)
-                return;
-            auto const totalSpent =
-                (totalPaid.trackedValueDelta + totalFeesPaid +
-                 (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero));
-            BEAST_EXPECT(
-                env.balance(borrower, broker.asset).number() ==
-                borrowerInitialBalance - totalSpent);
-        };
-
-        auto const defaultRound = broker.asset.integral() ? 3 : 0;
-        auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) {
-            auto const p = places.value_or(defaultRound);
-            if (p == 0)
-                return n;
-            auto const factor = Number{1, p};
-            return (n * factor).truncate() / factor;
-        };
-        while (state.paymentRemaining > 0)
-        {
-            validateBorrowerBalance();
-            // Compute the expected principal amount
-            auto const paymentComponents = xrpl::detail::computePaymentComponents(
-                env.current()->rules(),
-                broker.asset.raw(),
-                state.loanScale,
-                state.totalValue,
-                state.principalOutstanding,
-                state.managementFeeOutstanding,
-                state.periodicPayment,
-                periodicRate,
-                state.paymentRemaining,
-                broker.params.managementFeeRate);
-
-            BEAST_EXPECT(
-                paymentComponents.trackedValueDelta <= roundedPeriodicPayment ||
-                (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final &&
-                 paymentComponents.trackedValueDelta >= roundedPeriodicPayment));
-            BEAST_EXPECT(
-                paymentComponents.trackedValueDelta ==
-                paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() +
-                    paymentComponents.trackedManagementFeeDelta);
-
-            xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
-                env.current()->rules(),
-                state.periodicPayment,
-                periodicRate,
-                state.paymentRemaining - 1,
-                broker.params.managementFeeRate);
-            xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
-            BEAST_EXPECT(
-                deltas.total() == deltas.principal + deltas.interest + deltas.managementFee);
-            BEAST_EXPECT(
-                paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                deltas.total() == state.periodicPayment ||
-                (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14);
-
-            if (!showStepBalances)
-            {
-                log << currencyLabel << " Payment components: " << state.paymentRemaining << ", "
-
-                    << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee
-                    << ", " << paymentComponents.trackedValueDelta << ", "
-                    << paymentComponents.trackedPrincipalDelta << ", "
-                    << paymentComponents.trackedInterestPart() << ", "
-                    << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* {
-                    if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final)
-                        return "final";
-                    if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra)
-                        return "extra";
-                    return "none";
-                }() << std::endl;
-            }
-
-            auto const totalDueAmount =
-                STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee};
-
-            if (paymentParams.validateBalances)
-            {
-                // Due to the rounding algorithms to keep the interest and
-                // principal in sync with "true" values, the computed amount
-                // may be a little less than the rounded fixed payment
-                // amount. For integral types, the difference should be < 3
-                // (1 unit for each of the interest and management fee). For
-                // IOUs, the difference should be dust.
-                Number const diff = totalDue - totalDueAmount;
-                BEAST_EXPECT(
-                    paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                    diff == beast::kZero ||
-                    (diff > beast::kZero &&
-                     ((broker.asset.integral() && (static_cast(diff) < 3)) ||
-                      (state.loanScale - diff.exponent() > 13))));
-
-                BEAST_EXPECT(
-                    paymentComponents.trackedPrincipalDelta >= beast::kZero &&
-                    paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
-                BEAST_EXPECT(
-                    paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final ||
-                    paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
-            }
-
-            auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
-
-            // Make the payment
-            env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags));
-
-            env.close(d{state.paymentInterval / 2});
-
-            if (paymentParams.validateBalances)
-            {
-                // Need to account for fees if the loan is in XRP
-                PrettyAmount adjustment = broker.asset(0);
-                if (broker.asset.native())
-                {
-                    adjustment = env.current()->fees().base;
-                }
-
-                // Check the result
-                verifyLoanStatus.checkPayment(
-                    state.loanScale,
-                    borrower,
-                    borrowerBalanceBeforePayment,
-                    totalDueAmount,
-                    adjustment);
-            }
-
-            if (showStepBalances)
-            {
-                auto const loanSle = env.le(loanKeylet);
-                if (!BEAST_EXPECT(loanSle))
-                {
-                    // No reason for this not to exist
-                    return;
-                }
-                auto const current = constructLoanState(loanSle);
-                auto const errors = nextTrueState - current;
-                log << currencyLabel << " Loan balances: "
-                    << "\n\tAmount taken: " << paymentComponents.trackedValueDelta
-                    << "\n\tTotal value: " << current.valueOutstanding
-                    << " (true: " << truncate(nextTrueState.valueOutstanding)
-                    << ", error: " << truncate(errors.total())
-                    << ")\n\tPrincipal: " << current.principalOutstanding
-                    << " (true: " << truncate(nextTrueState.principalOutstanding)
-                    << ", error: " << truncate(errors.principal)
-                    << ")\n\tInterest: " << current.interestDue
-                    << " (true: " << truncate(nextTrueState.interestDue)
-                    << ", error: " << truncate(errors.interest)
-                    << ")\n\tMgmt fee: " << current.managementFeeDue
-                    << " (true: " << truncate(nextTrueState.managementFeeDue)
-                    << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining "
-                    << loanSle->at(sfPaymentRemaining) << std::endl;
-
-                currentRoundedState = current;
-            }
-
-            --state.paymentRemaining;
-            state.previousPaymentDate = state.nextPaymentDate;
-            if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final)
-            {
-                state.paymentRemaining = 0;
-                state.nextPaymentDate = 0;
-            }
-            else
-            {
-                state.nextPaymentDate += state.paymentInterval;
-            }
-            state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
-            state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
-            state.totalValue -= paymentComponents.trackedValueDelta;
-
-            if (paymentParams.validateBalances)
-                verifyLoanStatus(state);
-
-            totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
-            totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
-            totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta;
-            totalInterestPaid += paymentComponents.trackedInterestPart();
-            totalFeesPaid += serviceFee;
-            ++totalPaymentsMade;
-
-            currentTrueState = nextTrueState;
-        }
-        validateBorrowerBalance();
-
-        // Loan is paid off
-        BEAST_EXPECT(state.paymentRemaining == 0);
-        BEAST_EXPECT(state.principalOutstanding == 0);
-
-        auto const initialInterestDue = initialState.totalValue -
-            (initialState.principalOutstanding + initialState.managementFeeOutstanding);
-        if (paymentParams.validateBalances)
-        {
-            // Make sure all the payments add up
-            BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
-            BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
-            BEAST_EXPECT(
-                totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
-            // This is almost a tautology given the previous checks, but
-            // check it anyway for completeness.
-            BEAST_EXPECT(totalInterestPaid == initialInterestDue);
-            BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
-        }
-
-        if (showStepBalances)
-        {
-            auto const loanSle = env.le(loanKeylet);
-            if (!BEAST_EXPECT(loanSle))
-            {
-                // No reason for this not to exist
-                return;
-            }
-            log << currencyLabel << " Total amounts paid: "
-                << "\n\tTotal value: " << totalPaid.trackedValueDelta
-                << " (initial: " << truncate(initialState.totalValue)
-                << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta)
-                << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta
-                << " (initial: " << truncate(initialState.principalOutstanding) << ", error: "
-                << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta)
-                << ")\n\tInterest: " << totalInterestPaid
-                << " (initial: " << truncate(initialInterestDue)
-                << ", error: " << truncate(initialInterestDue - totalInterestPaid)
-                << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta
-                << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: "
-                << truncate(
-                       initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta)
-                << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl;
-        }
-    }
-
-    void
-    runLoan(
-        AssetType assetType,
-        BrokerParameters const& brokerParams,
-        LoanParameters const& loanParams,
-        FeatureBitset features)
-    {
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        Env env(*this, features);
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-        auto pseudoAcct = std::get(*loanResult);
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
-
-        makeLoanPayments(
-            env,
-            broker,
-            loanParams,
-            loanKeylet,
-            verifyLoanStatus,
-            issuer,
-            lender,
-            borrower,
-            PaymentParameters{.showStepBalances = true});
-    }
-
-    /**
-     * Runs through the complete lifecycle of a loan
-     *
-     * 1. Create a loan.
-     * 2. Test a bunch of transaction failure conditions.
-     * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done
-     *    depends on the callback. e.g. Default, Early payoff, make all the
-     * normal payments, etc.
-     * 4. Delete the loan. The loan will alternate between being deleted by the
-     *    lender and the borrower.
-     */
-    void
-    lifecycle(
-        std::string const& caseLabel,
-        char const* label,
-        jtx::Env& env,
-        Number const& loanAmount,
-        int interestExponent,
-        jtx::Account const& lender,
-        jtx::Account const& borrower,
-        jtx::Account const& evan,
-        BrokerInfo const& broker,
-        jtx::Account const& pseudoAcct,
-        std::uint32_t flags,
-        // The end of life callback is expected to take the loan to 0 payments
-        // remaining, one way or another
-        std::function
-            toEndOfLife)
-    {
-        auto const [keylet, loanSequence] = [&]() {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-            {
-                // will be invalid
-                return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0));
-            }
-
-            // Broker has no loans
-            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
-
-            // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_
-            // object.
-            auto const loanSequence = brokerSle->at(sfLoanSequence);
-            return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence);
-        }();
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
-
-        // No loans yet
-        verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0);
-
-        if (!BEAST_EXPECT(loanSequence != 0))
-            return;
-
-        testcase << caseLabel << " " << label;
-
-        using namespace jtx;
-        using namespace loan;
-        using namespace std::chrono_literals;
-
-        auto applyExponent = [interestExponent, this](TenthBips32 value) mutable {
-            BEAST_EXPECT(value > TenthBips32(0));
-            while (interestExponent > 0)
-            {
-                auto const oldValue = value;
-                value *= 10;
-                --interestExponent;
-                BEAST_EXPECT(value / 10 == oldValue);
-            }
-            while (interestExponent < 0)
-            {
-                auto const oldValue = value;
-                value /= 10;
-                ++interestExponent;
-                BEAST_EXPECT(value * 10 == oldValue);
-            }
-            return value;
-        };
-
-        auto const borrowerOwnerCount = env.ownerCount(borrower);
-
-        auto const loanSetFee = env.current()->fees().base * 2;
-        LoanParameters const loanParams{
-            .account = borrower,
-            .counter = lender,
-            .counterpartyExplicit = false,
-            .principalRequest = loanAmount,
-            .setFee = loanSetFee,
-            .originationFee = 1,
-            .serviceFee = 2,
-            .lateFee = 3,
-            .closeFee = 4,
-            .overFee = applyExponent(percentageToTenthBips(5) / 10),
-            .interest = applyExponent(percentageToTenthBips(12)),
-            // 2.4%
-            .lateInterest = applyExponent(percentageToTenthBips(24) / 10),
-            .closeInterest = applyExponent(percentageToTenthBips(36) / 10),
-            .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10),
-            .payTotal = 12,
-            .payInterval = 600,
-            .gracePd = 60,
-            .flags = flags,
-        };
-        Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value();
-        auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value();
-        auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value();
-        auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value();
-        auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value();
-
-        auto const borrowerStartbalance = env.balance(borrower, broker.asset);
-
-        auto createJtx = loanParams(env, broker);
-        // Successfully create a Loan
-        env(createJtx);
-
-        env.close();
-
-        auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count();
-
-        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1);
-        }
-
-        {
-            // Need to account for fees if the loan is in XRP
-            PrettyAmount adjustment = broker.asset(0);
-            if (broker.asset.native())
-            {
-                adjustment = 2 * env.current()->fees().base;
-            }
-
-            BEAST_EXPECT(
-                env.balance(borrower, broker.asset).value() ==
-                borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount -
-                    adjustment.value());
-        }
-
-        auto const loanFlags =
-            createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0);
-
-        if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
-        {
-            // log << "loan after create: " << to_string(loan->getJson())
-            //     << std::endl;
-            BEAST_EXPECT(
-                loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment));
-            BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence);
-            BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
-            BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID);
-            BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount);
-            BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount);
-            BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount);
-            BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount);
-            BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee);
-            BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest);
-            BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest);
-            BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest);
-            BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest);
-            BEAST_EXPECT(loan->at(sfStartDate) == startDate);
-            BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval);
-            BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd);
-            BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0);
-            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval);
-            BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal);
-            BEAST_EXPECT(
-                loan->at(sfLoanScale) >=
-                (broker.asset.integral()
-                     ? 0
-                     : std::max(broker.vaultScale(env), principalRequestAmount.exponent())));
-            BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount);
-        }
-
-        auto state = getCurrentState(env, broker, keylet, verifyLoanStatus);
-
-        auto const loanProperties = computeLoanProperties(
-            env.current()->rules(),
-            broker.asset.raw(),
-            state.principalOutstanding,
-            state.interestRate,
-            state.paymentInterval,
-            state.paymentRemaining,
-            broker.params.managementFeeRate,
-            state.loanScale);
-
-        verifyLoanStatus(
-            0,
-            startDate + *loanParams.payInterval,
-            *loanParams.payTotal,
-            state.loanScale,
-            loanProperties.loanState.valueOutstanding,
-            principalRequestAmount,
-            loanProperties.loanState.managementFeeDue,
-            loanProperties.periodicPayment,
-            loanFlags | 0);
-
-        // Manage the loan
-        // no-op
-        env(manage(lender, keylet.key, 0));
-        {
-            // no flags
-            auto jt = manage(lender, keylet.key, 0);
-            jt.removeMember(sfFlags.getName());
-            env(jt);
-        }
-        // Only the lender can manage
-        env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION));
-        // unknown flags
-        env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG));
-        // combinations of flags are not allowed
-        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG));
-        env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG));
-        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG));
-        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault),
-            Ter(temINVALID_FLAG));
-        // invalid loan ID
-        env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY));
-        // Loan is unimpaired, can't unimpair it again
-        env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
-        // Loan is unimpaired, it can go into default, but only after it's past
-        // due
-        env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON));
-
-        // Check the vault
-        bool const canImpair = canImpairLoan(env, broker, state);
-        // Impair the loan, if possible
-        env(manage(lender, keylet.key, tfLoanImpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-        // Unimpair the loan
-        env(manage(lender, keylet.key, tfLoanUnimpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
-
-        auto const nextDueDate = startDate + *loanParams.payInterval;
-
-        env.close();
-
-        verifyLoanStatus(
-            0,
-            nextDueDate,
-            *loanParams.payTotal,
-            loanProperties.loanScale,
-            loanProperties.loanState.valueOutstanding,
-            principalRequestAmount,
-            loanProperties.loanState.managementFeeDue,
-            loanProperties.periodicPayment,
-            loanFlags | 0);
-
-        // Can't delete the loan yet. It has payments remaining.
-        env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS));
-
-        if (BEAST_EXPECT(toEndOfLife))
-            toEndOfLife(keylet, verifyLoanStatus);
-        env.close();
-
-        // Verify the loan is at EOL
-        if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
-        {
-            BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0);
-            BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0);
-        }
-        auto const borrowerStartingBalance = env.balance(borrower, broker.asset);
-
-        // Try to delete the loan broker with an active loan
-        env(loanBroker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS));
-        // Ensure the above tx doesn't get ordered after the LoanDelete and
-        // delete our broker!
-        env.close();
-
-        // Test failure cases
-        env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG));
-        env(del(evan, keylet.key), Ter(tecNO_PERMISSION));
-        env(del(lender, broker.brokerID), Ter(tecNO_ENTRY));
-
-        // Delete the loan
-        // Either the borrower or the lender can delete the loan. Alternate
-        // between who does it across tests.
-        static unsigned kDeleteCounter = 0;
-        auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower;
-        env(del(deleter, keylet.key));
-        env.close();
-
-        PrettyAmount adjustment = broker.asset(0);
-        if (deleter == borrower)
-        {
-            // Need to account for fees if the loan is in XRP
-            if (broker.asset.native())
-            {
-                adjustment = env.current()->fees().base;
-            }
-        }
-
-        // No loans left
-        verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0);
-
-        BEAST_EXPECT(
-            env.balance(borrower, broker.asset).value() ==
-            borrowerStartingBalance.value() - adjustment);
-        BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount);
-
-        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
-        }
-    }
-
-    static std::string
-    getCurrencyLabel(Asset const& asset)
-    {
-        if (asset.native())
-            return "XRP";
-        if (asset.holds())
-            return "IOU";
-        if (asset.holds())
-            return "MPT";
-        return "Unknown";
-    }
-
-    /**
-     * Wrapper to run a series of lifecycle tests for a given asset and loan
-     * amount
-     *
-     * Will be used in the future to vary the loan parameters. For now, it is
-     * only called once.
-     *
-     * Tests a bunch of LoanSet failure conditions before lifecycle.
-     */
-    template 
-    void
-    testCaseWrapper(
-        jtx::Env& env,
-        jtx::MPTTester& mptt,
-        std::array const& assets,
-        BrokerInfo const& broker,
-        Number const& loanAmount,
-        int interestExponent)
-    {
-        using namespace jtx;
-        using namespace Lending;
-
-        auto const& asset = broker.asset.raw();
-        auto const currencyLabel = getCurrencyLabel(asset);
-        auto const caseLabel = [&]() {
-            std::stringstream ss;
-            ss << "Lifecycle: " << loanAmount << " " << currencyLabel
-               << " Scale interest to: " << interestExponent << " ";
-            return ss.str();
-        }();
-        testcase << caseLabel;
-
-        using namespace loan;
-        using namespace std::chrono_literals;
-        using d = NetClock::duration;
-        using tp = NetClock::time_point;
-
-        Account const issuer{"issuer"};
-        // For simplicity, lender will be the sole actor for the vault &
-        // brokers.
-        Account const lender{"lender"};
-        // Borrower only wants to borrow
-        Account const borrower{"borrower"};
-        // Evan will attempt to be naughty
-        Account const evan{"evan"};
-        // Do not fund alice
-        Account const alice{"alice"};
-
-        Number const principalRequest = broker.asset(loanAmount).value();
-        Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0);
-        BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10);
-        Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value();
-        Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value();
-        Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value();
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-
-        auto const pseudoAcct = [&]() {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return Account{lender};
-            auto const brokerPseudo = brokerSle->at(sfAccount);
-            return Account("Broker pseudo-account", brokerPseudo);
-        }();
-
-        auto const baseFee = env.current()->fees().base;
-
-        auto badKeylet = keylet::vault(lender.id(), env.seq(lender));
-        // Try some failure cases
-        // flags are checked first
-        env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask),
-            Sig(sfCounterpartySignature, lender),
-            loanSetFee,
-            Ter(temINVALID_FLAG));
-
-        // field length validation
-        // sfData: good length, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kData(std::string(kMaxDataPayloadLength, 'X')),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfData: too long
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kData(std::string(kMaxDataPayloadLength + 1, 'Y')),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // field range validation
-        // sfOverpaymentFee: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kOverpaymentFee(kMaxOverpaymentFee),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfOverpaymentFee: too big
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kOverpaymentFee(kMaxOverpaymentFee + 1),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfInterestRate: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kInterestRate(kMaxInterestRate),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kInterestRate(TenthBips32(0)),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfInterestRate: too big
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(kMaxInterestRate + 1),
-            loanSetFee,
-            Ter(temINVALID));
-        // sfInterestRate: too small
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(TenthBips32(-1)),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfLateInterestRate: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kLateInterestRate(kMaxLateInterestRate),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kLateInterestRate(TenthBips32(0)),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfLateInterestRate: too big
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kLateInterestRate(kMaxLateInterestRate + 1),
-            loanSetFee,
-            Ter(temINVALID));
-        // sfLateInterestRate: too small
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kLateInterestRate(TenthBips32(-1)),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfCloseInterestRate: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kCloseInterestRate(kMaxCloseInterestRate),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kCloseInterestRate(TenthBips32(0)),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfCloseInterestRate: too big
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kCloseInterestRate(kMaxCloseInterestRate + 1),
-            loanSetFee,
-            Ter(temINVALID));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kCloseInterestRate(TenthBips32(-1)),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfOverpaymentInterestRate: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kOverpaymentInterestRate(kMaxOverpaymentInterestRate),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kOverpaymentInterestRate(TenthBips32(0)),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfOverpaymentInterestRate: too big
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1),
-            loanSetFee,
-            Ter(temINVALID));
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kOverpaymentInterestRate(TenthBips32(-1)),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfPaymentTotal: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kPaymentTotal(LoanSet::kMinPaymentTotal),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfPaymentTotal: too small (there is no max)
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kPaymentTotal(LoanSet::kMinPaymentTotal - 1),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfPaymentInterval: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kPaymentInterval(LoanSet::kMinPaymentInterval),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfPaymentInterval: too small (there is no max)
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kPaymentInterval(LoanSet::kMinPaymentInterval - 1),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // sfGracePeriod: good value, bad account
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, borrower),
-            kPaymentInterval(LoanSet::kMinPaymentInterval * 2),
-            kGracePeriod(LoanSet::kMinPaymentInterval * 2),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // sfGracePeriod: larger than paymentInterval
-        env(set(evan, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            kPaymentInterval(LoanSet::kMinPaymentInterval * 2),
-            kGracePeriod(LoanSet::kMinPaymentInterval * 3),
-            loanSetFee,
-            Ter(temINVALID));
-
-        // insufficient fee - single sign
-        env(set(borrower, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, lender),
-            Ter(telINSUF_FEE_P));
-        // insufficient fee - multisign
-        env(signers(lender, 2, {{evan, 1}, {borrower, 1}}));
-        env(signers(borrower, 2, {{evan, 1}, {lender, 1}}));
-        env(set(borrower, broker.brokerID, principalRequest),
-            kCounterparty(lender),
-            Msig(evan, lender),
-            Msig(sfCounterpartySignature, evan, borrower),
-            Fee(env.current()->fees().base * 5 - 1),
-            Ter(telINSUF_FEE_P));
-        // Bad multisign signatures for borrower (Account)
-        env(set(borrower, broker.brokerID, principalRequest),
-            kCounterparty(lender),
-            Msig(alice, issuer),
-            Msig(sfCounterpartySignature, evan, borrower),
-            Fee(env.current()->fees().base * 5),
-            Ter(tefBAD_SIGNATURE));
-        // Bad multisign signatures for issuer (Counterparty)
-        env(set(borrower, broker.brokerID, principalRequest),
-            kCounterparty(lender),
-            Msig(evan, lender),
-            Msig(sfCounterpartySignature, alice, issuer),
-            Fee(env.current()->fees().base * 5 - 1),
-            Ter(tefBAD_SIGNATURE));
-        env(signers(lender, kNone));
-        env(signers(borrower, kNone));
-        // multisign sufficient fee, but no signers set up
-        env(set(borrower, broker.brokerID, principalRequest),
-            kCounterparty(lender),
-            Msig(evan, lender),
-            Msig(sfCounterpartySignature, evan, borrower),
-            Fee(env.current()->fees().base * 5),
-            Ter(tefNOT_MULTI_SIGNING));
-        // not the broker owner, no counterparty, not signed by broker
-        // owner
-        env(set(borrower, broker.brokerID, principalRequest),
-            Sig(sfCounterpartySignature, evan),
-            loanSetFee,
-            Ter(tefBAD_AUTH));
-        // not the broker owner, counterparty is borrower
-        env(set(evan, broker.brokerID, principalRequest),
-            kCounterparty(borrower),
-            Sig(sfCounterpartySignature, borrower),
-            loanSetFee,
-            Ter(tecNO_PERMISSION));
-        // not a LoanBroker object, no counterparty
-        env(set(lender, badKeylet.key, principalRequest),
-            Sig(sfCounterpartySignature, evan),
-            loanSetFee,
-            Ter(temBAD_SIGNER));
-        // not a LoanBroker object, counterparty is valid
-        env(set(lender, badKeylet.key, principalRequest),
-            kCounterparty(borrower),
-            Sig(sfCounterpartySignature, borrower),
-            loanSetFee,
-            Ter(tecNO_ENTRY));
-        // borrower doesn't exist
-        env(set(lender, broker.brokerID, principalRequest),
-            kCounterparty(alice),
-            Sig(sfCounterpartySignature, alice),
-            loanSetFee,
-            Ter(terNO_ACCOUNT));
-
-        // Request more funds than the vault has available
-        env(set(evan, broker.brokerID, totalVaultRequest + 1),
-            Sig(sfCounterpartySignature, lender),
-            loanSetFee,
-            Ter(tecINSUFFICIENT_FUNDS));
-
-        // Request more funds than the broker's first-loss capital can
-        // cover.
-        env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1),
-            Sig(sfCounterpartySignature, lender),
-            loanSetFee,
-            Ter(tecINSUFFICIENT_FUNDS));
-
-        // Frozen trust line / locked MPT issuance
-        // XRP can not be frozen, but run through the loop anyway to test
-        // the tecLIMIT_EXCEEDED case
-        {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return;
-
-            auto const vaultPseudo = [&]() {
-                auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
-                if (!BEAST_EXPECT(vaultSle))
-                {
-                    // This will be wrong, but the test has failed anyway.
-                    return Account{lender};
-                }
-                auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount));
-                return vaultPseudo;
-            }();
-
-            auto const [freeze, deepfreeze, unfreeze, expectedResult] =
-                [&]() -> std::tuple<
-                          std::function,
-                          std::function,
-                          std::function,
-                          TER> {
-                // Freeze / lock the asset
-                std::function const empty;
-                if (broker.asset.native())
-                {
-                    // XRP can't be frozen
-                    return std::make_tuple(empty, empty, empty, tesSUCCESS);
-                }
-                if (broker.asset.holds())
-                {
-                    auto freeze = [&](Account const& holder) {
-                        env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze));
-                    };
-                    auto deepfreeze = [&](Account const& holder) {
-                        env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
-                    };
-                    auto unfreeze = [&](Account const& holder) {
-                        env(trust(
-                            issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze));
-                    };
-                    return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN);
-                }
-
-                auto freeze = [&](Account const& holder) {
-                    mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock});
-                };
-                auto unfreeze = [&](Account const& holder) {
-                    mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock});
-                };
-                return std::make_tuple(freeze, empty, unfreeze, tecLOCKED);
-            }();
-
-            // Try freezing the accounts that can't be frozen
-            if (freeze)
-            {
-                for (auto const& account : {vaultPseudo, evan})
-                {
-                    // Freeze the account
-                    freeze(account);
-
-                    // Try to create a loan with a frozen line
-                    env(set(evan, broker.brokerID, debtMaximumRequest),
-                        Sig(sfCounterpartySignature, lender),
-                        loanSetFee,
-                        Ter(expectedResult));
-
-                    // Unfreeze the account
-                    BEAST_EXPECT(unfreeze);
-                    unfreeze(account);
-
-                    // Ensure the line is unfrozen with a request that is fine
-                    // except too it requests more principal than the broker can
-                    // carry
-                    env(set(evan, broker.brokerID, debtMaximumRequest + 1),
-                        Sig(sfCounterpartySignature, lender),
-                        loanSetFee,
-                        Ter(tecLIMIT_EXCEEDED));
-                }
-            }
-
-            // Deep freeze the borrower, which prevents them from receiving
-            // funds
-            if (deepfreeze)
-            {
-                // Make sure evan has a trust line that so the issuer can
-                // freeze it. (Don't need to do this for the borrower,
-                // because LoanSet will create a line to the borrower
-                // automatically.)
-                env(trust(evan, issuer[iouCurrency_](100'000)));
-
-                for (auto const& account : {// these accounts can't be frozen, which deep freeze
-                                            // implies
-                                            vaultPseudo,
-                                            evan,
-                                            // these accounts can't be deep frozen
-                                            lender})
-                {
-                    // Freeze evan
-                    deepfreeze(account);
-
-                    // Try to create a loan with a deep frozen line
-                    env(set(evan, broker.brokerID, debtMaximumRequest),
-                        Sig(sfCounterpartySignature, lender),
-                        loanSetFee,
-                        Ter(expectedResult));
-
-                    // Unfreeze evan
-                    BEAST_EXPECT(unfreeze);
-                    unfreeze(account);
-
-                    // Ensure the line is unfrozen with a request that is fine
-                    // except too it requests more principal than the broker can
-                    // carry
-                    env(set(evan, broker.brokerID, debtMaximumRequest + 1),
-                        Sig(sfCounterpartySignature, lender),
-                        loanSetFee,
-                        Ter(tecLIMIT_EXCEEDED));
-                }
-            }
-        }
-
-        // Finally! Create a loan
-
-        auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) {
-            if (auto const brokerSle = env.le(keylet::loanBroker(brokerID));
-                BEAST_EXPECT(brokerSle))
-            {
-                auto const available = brokerSle->at(sfCoverAvailable);
-                BEAST_EXPECT(available == expected);
-                return available;
-            }
-            return Number{};
-        };
-        auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) {
-            if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-                BEAST_EXPECT(brokerSle))
-            {
-                BEAST_EXPECT(
-                    state.loanScale >=
-                    (broker.asset.integral()
-                         ? 0
-                         : std::max(
-                               broker.vaultScale(env), state.principalOutstanding.exponent())));
-                NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-                auto const defaultAmount = roundToAsset(
-                    broker.asset,
-                    std::min(
-                        tenthBipsOfValue(
-                            tenthBipsOfValue(
-                                brokerSle->at(sfDebtTotal), broker.params.coverRateMin),
-                            broker.params.coverRateLiquidation),
-                        state.totalValue - state.managementFeeOutstanding),
-                    state.loanScale);
-                return std::make_pair(defaultAmount, brokerSle->at(sfOwner));
-            }
-            return std::make_pair(Number{}, AccountID{});
-        };
-        auto replenishCover = [&env, &coverAvailable](
-                                  BrokerInfo const& broker,
-                                  AccountID const& brokerAcct,
-                                  Number const& startingCoverAvailable,
-                                  Number const& amountToBeCovered) {
-            coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered);
-            env(loanBroker::coverDeposit(
-                brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered}));
-            coverAvailable(broker.brokerID, startingCoverAvailable);
-            env.close();
-        };
-
-        auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) {
-            return [&, impair, baseFlag](
-                       Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
-                // toEndOfLife
-                //
-                // Default the loan
-
-                // Initialize values with the current state
-                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
-                BEAST_EXPECT(state.flags == baseFlag);
-
-                auto const& broker = verifyLoanStatus.broker;
-                auto const startingCoverAvailable = coverAvailable(
-                    broker.brokerID, broker.asset(broker.params.coverDeposit).number());
-
-                if (impair)
-                {
-                    // Check the vault
-                    bool const canImpair = canImpairLoan(env, broker, state);
-                    // Impair the loan, if possible
-                    env(manage(lender, loanKeylet.key, tfLoanImpair),
-                        canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-
-                    if (canImpair)
-                    {
-                        state.flags |= tfLoanImpair;
-                        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));
-                    }
-                    verifyLoanStatus(state);
-                }
-
-                auto const nextDueDate = tp{d{state.nextPaymentDate}};
-
-                // Can't default the loan yet. The grace period hasn't
-                // expired
-                env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
-
-                // Let some time pass so that the loan can be
-                // defaulted
-                env.close(nextDueDate + 60s);
-
-                auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker);
-
-                // Default the loan
-                env(manage(lender, loanKeylet.key, tfLoanDefault));
-                env.close();
-
-                // The LoanBroker just lost some of it's first-loss capital.
-                // Replenish it.
-                replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered);
-
-                state.flags |= tfLoanDefault;
-                state.paymentRemaining = 0;
-                state.totalValue = 0;
-                state.principalOutstanding = 0;
-                state.managementFeeOutstanding = 0;
-                state.nextPaymentDate = 0;
-                verifyLoanStatus(state);
-
-                // Once a loan is defaulted, it can't be managed
-                env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
-                env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
-                // Can't make a payment on it either
-                env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED));
-            };
-        };
-
-        auto singlePayment = [&](Keylet const& loanKeylet,
-                                 VerifyLoanStatus const& verifyLoanStatus,
-                                 LoanState& state,
-                                 STAmount const& payoffAmount,
-                                 std::uint32_t numPayments,
-                                 std::uint32_t baseFlag,
-                                 std::uint32_t txFlags) {
-            // toEndOfLife
-            //
-            verifyLoanStatus(state);
-
-            // Send some bogus pay transactions
-            env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags),
-                Ter(temINVALID));
-            // broker.asset(80) is less than a single payment, but all these
-            // checks fail before that matters
-            env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT));
-            env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY));
-            env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION));
-
-            // TODO: Write a general "isFlag" function? See STObject::isFlag.
-            // Maybe add a static overloaded member?
-            if (!(state.flags & lsfLoanOverpayment))
-            {
-                // If the loan does not allow overpayments, send a payment that
-                // tries to make an overpayment. Do not include `txFlags`, so we
-                // don't end up duplicating the next test transaction.
-                //
-                // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag:
-                // with fix on → preflight passes, apply returns tecNO_PERMISSION;
-                // with fix off → preflight rejects the flag, returns temINVALID_FLAG.
-                bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3);
-                STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}};
-                XRPAmount const overpayFee{
-                    baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)};
-                env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
-                    Fee(overpayFee),
-                    Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG}));
-
-                if (hasFix313)
-                {
-                    env.disableFeature(fixCleanup3_1_3);
-                    env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
-                        Fee(overpayFee),
-                        Ter(temINVALID_FLAG));
-                    env.enableFeature(fixCleanup3_1_3);
-                }
-            }
-            // Try to send a payment marked as multiple mutually exclusive
-            // payment types. Do not include `txFlags`, so we don't duplicate
-            // the prior test transaction.
-            env(pay(borrower,
-                    loanKeylet.key,
-                    broker.asset(state.periodicPayment * 2),
-                    tfLoanLatePayment | tfLoanFullPayment),
-                Ter(temINVALID_FLAG));
-            env(pay(borrower,
-                    loanKeylet.key,
-                    broker.asset(state.periodicPayment * 2),
-                    tfLoanLatePayment | tfLoanOverpayment),
-                Ter(temINVALID_FLAG));
-            env(pay(borrower,
-                    loanKeylet.key,
-                    broker.asset(state.periodicPayment * 2),
-                    tfLoanOverpayment | tfLoanFullPayment),
-                Ter(temINVALID_FLAG));
-            env(pay(borrower,
-                    loanKeylet.key,
-                    broker.asset(state.periodicPayment * 2),
-                    tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment),
-                Ter(temINVALID_FLAG));
-
-            {
-                auto const otherAsset =
-                    broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0];
-                env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET));
-            }
-
-            // Amount doesn't cover a single payment
-            env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags),
-                Ter(tecINSUFFICIENT_PAYMENT));
-
-            // Get the balance after these failed transactions take
-            // fees
-            auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
-
-            BEAST_EXPECT(payoffAmount > state.principalOutstanding);
-            // Try to pay a little extra to show that it's _not_
-            // taken
-            auto const transactionAmount = payoffAmount + broker.asset(10);
-
-            // Send a transaction that tries to pay more than the borrowers's
-            // balance
-            XRPAmount const badFee{
-                baseFee *
-                (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment /
-                     kLoanPaymentsPerFeeIncrement +
-                 1)};
-            env(pay(borrower,
-                    loanKeylet.key,
-                    STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2},
-                    txFlags),
-                Fee(badFee),
-                Ter(tecINSUFFICIENT_FUNDS));
-
-            XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)};
-            env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee));
-
-            env.close();
-
-            // log << env.meta()->getJson() << std::endl;
-
-            // Need to account for fees if the loan is in XRP
-            PrettyAmount adjustment = broker.asset(0);
-            if (broker.asset.native())
-            {
-                adjustment = badFee + goodFee;
-            }
-
-            state.paymentRemaining = 0;
-            state.principalOutstanding = 0;
-            state.totalValue = 0;
-            state.managementFeeOutstanding = 0;
-            state.previousPaymentDate =
-                state.nextPaymentDate + (state.paymentInterval * (numPayments - 1));
-            state.nextPaymentDate = 0;
-            verifyLoanStatus(state);
-
-            verifyLoanStatus.checkPayment(
-                state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment);
-
-            // Can't impair or default a paid off loan
-            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
-            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
-        };
-
-        auto fullPayment = [&](std::uint32_t baseFlag) {
-            return [&, baseFlag](
-                       Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
-                // toEndOfLife
-                //
-                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
-                env.close(state.startDate + 20s);
-                auto const loanAge = (env.now() - state.startDate).count();
-                BEAST_EXPECT(loanAge == 30);
-
-                // Full payoff amount will consist of
-                // 1. principal outstanding (1000)
-                // 2. accrued interest (at 12%)
-                // 3. prepayment penalty (closeInterest at 3.6%)
-                // 4. close payment fee (4)
-                // Calculate these values without the helper functions
-                // to verify they're working correctly The numbers in
-                // the below BEAST_EXPECTs may not hold across assets.
-                Number const interval = state.paymentInterval;
-                auto const periodicRate = interval * Number(12, -2) / kSecondsInYear;
-                BEAST_EXPECT(
-                    periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{}));
-                STAmount const principalOutstanding{broker.asset, state.principalOutstanding};
-                STAmount const accruedInterest{
-                    broker.asset, state.principalOutstanding * periodicRate * loanAge / interval};
-                BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19)));
-                STAmount const prepaymentPenalty{
-                    broker.asset, state.principalOutstanding * Number(36, -3)};
-                BEAST_EXPECT(prepaymentPenalty == broker.asset(36));
-                STAmount const closePaymentFee = broker.asset(4);
-                auto const payoffAmount = roundToScale(
-                    principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee,
-                    state.loanScale);
-                BEAST_EXPECT(
-                    payoffAmount ==
-                    roundToAsset(
-                        broker.asset,
-                        broker.asset(Number(1040000114155251, -12)).number(),
-                        state.loanScale));
-
-                // The terms of this loan actually make the early payoff
-                // more expensive than just making payments
-                BEAST_EXPECT(
-                    payoffAmount >
-                    state.paymentRemaining * (state.periodicPayment + broker.asset(2).value()));
-
-                singlePayment(
-                    loanKeylet,
-                    verifyLoanStatus,
-                    state,
-                    payoffAmount,
-                    1,
-                    baseFlag,
-                    tfLoanFullPayment);
-            };
-        };
-
-        auto combineAllPayments = [&](std::uint32_t baseFlag) {
-            return
-                [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
-                    // toEndOfLife
-                    //
-
-                    auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
-                    env.close();
-
-                    BEAST_EXPECT(
-                        STAmount(broker.asset, state.periodicPayment) ==
-                        broker.asset(Number(8333457002039338267, -17)));
-
-                    // Make all the payments in one transaction
-                    // service fee is 2
-                    auto const startingPayments = state.paymentRemaining;
-                    STAmount const payoffAmount = [&]() {
-                        NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-                        auto const rawPayoff =
-                            startingPayments * (state.periodicPayment + broker.asset(2).value());
-                        STAmount payoffAmount{broker.asset, rawPayoff};
-                        BEAST_EXPECTS(
-                            payoffAmount == broker.asset(Number(1024014840244721, -12)),
-                            to_string(payoffAmount));
-                        BEAST_EXPECT(payoffAmount > state.principalOutstanding);
-
-                        payoffAmount = roundToScale(payoffAmount, state.loanScale);
-
-                        return payoffAmount;
-                    }();
-
-                    auto const totalPayoffValue =
-                        state.totalValue + startingPayments * broker.asset(2).value();
-                    STAmount const totalPayoffAmount{broker.asset, totalPayoffValue};
-
-                    BEAST_EXPECTS(
-                        totalPayoffAmount == payoffAmount,
-                        "Payoff amount: " + to_string(payoffAmount) +
-                            ". Total Value: " + to_string(totalPayoffAmount));
-
-                    singlePayment(
-                        loanKeylet,
-                        verifyLoanStatus,
-                        state,
-                        payoffAmount,
-                        state.paymentRemaining,
-                        baseFlag,
-                        0);
-                };
-        };
-
-        // There are a lot of fields that can be set on a loan, but most
-        // of them only affect the "math" when a payment is made. The
-        // only one that really affects behavior is the
-        // `tfLoanOverpayment` flag.
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Impair and Default",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            defaultImmediately(lsfLoanOverpayment));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Impair and Default",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            0,
-            defaultImmediately(0));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Default without Impair",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            defaultImmediately(lsfLoanOverpayment, false));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Default without Impair",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            0,
-            defaultImmediately(0, false));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Pay off immediately",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            0,
-            fullPayment(0));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Pay off immediately",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            fullPayment(lsfLoanOverpayment));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Combine all payments",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            0,
-            combineAllPayments(0));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Combine all payments",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            combineAllPayments(lsfLoanOverpayment));
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Make payments",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            0,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
-                // toEndOfLife
-                //
-                // Draw and make multiple payments
-                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
-                BEAST_EXPECT(state.flags == 0);
-                env.close();
-
-                verifyLoanStatus(state);
-
-                env.close(state.startDate + 20s);
-                auto const loanAge = (env.now() - state.startDate).count();
-                BEAST_EXPECT(loanAge == 30);
-
-                // Periodic payment amount will consist of
-                // 1. principal outstanding (1000)
-                // 2. interest interest rate (at 12%)
-                // 3. payment interval (600s)
-                // 4. loan service fee (2)
-                // Calculate these values without the helper functions
-                // to verify they're working correctly The numbers in
-                // the below BEAST_EXPECTs may not hold across assets.
-                Number const interval = state.paymentInterval;
-                auto const periodicRate = interval * Number(12, -2) / kSecondsInYear;
-                BEAST_EXPECT(
-                    periodicRate == Number(2283105022831050228, -24, Number::Normalized{}));
-                STAmount const roundedPeriodicPayment{
-                    broker.asset,
-                    roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
-
-                testcase << currencyLabel << " Payment components: "
-                         << "Payments remaining, rawInterest, rawPrincipal, "
-                            "rawMFee, trackedValueDelta, trackedPrincipalDelta, "
-                            "trackedInterestDelta, trackedMgmtFeeDelta, special";
-
-                auto const serviceFee = broker.asset(2);
-
-                BEAST_EXPECT(
-                    roundedPeriodicPayment ==
-                    roundToScale(
-                        broker.asset(
-                            Number(8333457002039338267, -17), Number::RoundingMode::Upward),
-                        state.loanScale,
-                        Number::RoundingMode::Upward));
-                // 83334570.01162141
-                // Include the service fee
-                STAmount const totalDue = roundToScale(
-                    roundedPeriodicPayment + serviceFee,
-                    state.loanScale,
-                    Number::RoundingMode::Upward);
-                // Only check the first payment since the rounding
-                // may drift as payments are made
-                BEAST_EXPECT(
-                    totalDue ==
-                    roundToScale(
-                        broker.asset(
-                            Number(8533457002039338267, -17), Number::RoundingMode::Upward),
-                        state.loanScale,
-                        Number::RoundingMode::Upward));
-
-                {
-                    auto const raw = computeTheoreticalLoanState(
-                        env.current()->rules(),
-                        state.periodicPayment,
-                        periodicRate,
-                        state.paymentRemaining,
-                        broker.params.managementFeeRate);
-                    auto const rounded = constructLoanState(
-                        state.totalValue,
-                        state.principalOutstanding,
-                        state.managementFeeOutstanding);
-                    testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining
-                             << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", "
-                             << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", "
-                             << rounded.principalOutstanding << ", " << rounded.interestDue << ", "
-                             << rounded.managementFeeDue;
-                }
-
-                // Try to pay a little extra to show that it's _not_
-                // taken
-                STAmount const transactionAmount =
-                    STAmount{broker.asset, totalDue} + broker.asset(10);
-                // Only check the first payment since the rounding
-                // may drift as payments are made
-                BEAST_EXPECT(
-                    transactionAmount ==
-                    roundToScale(
-                        broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward),
-                        state.loanScale,
-                        Number::RoundingMode::Upward));
-
-                auto const initialState = state;
-                xrpl::detail::PaymentComponents totalPaid{
-                    .trackedValueDelta = 0,
-                    .trackedPrincipalDelta = 0,
-                    .trackedManagementFeeDelta = 0};
-                Number totalInterestPaid = 0;
-                std::size_t totalPaymentsMade = 0;
-
-                xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
-                    env.current()->rules(),
-                    state.periodicPayment,
-                    periodicRate,
-                    state.paymentRemaining,
-                    broker.params.managementFeeRate);
-
-                while (state.paymentRemaining > 0)
-                {
-                    // Compute the expected principal amount
-                    auto const paymentComponents = xrpl::detail::computePaymentComponents(
-                        env.current()->rules(),
-                        broker.asset.raw(),
-                        state.loanScale,
-                        state.totalValue,
-                        state.principalOutstanding,
-                        state.managementFeeOutstanding,
-                        state.periodicPayment,
-                        periodicRate,
-                        state.paymentRemaining,
-                        broker.params.managementFeeRate);
-
-                    BEAST_EXPECTS(
-                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                            paymentComponents.trackedValueDelta <= roundedPeriodicPayment,
-                        "Delta: " + to_string(paymentComponents.trackedValueDelta) +
-                            ", periodic payment: " + to_string(roundedPeriodicPayment));
-
-                    xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
-                        env.current()->rules(),
-                        state.periodicPayment,
-                        periodicRate,
-                        state.paymentRemaining - 1,
-                        broker.params.managementFeeRate);
-                    xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
-
-                    testcase << currencyLabel << " Payment components: " << state.paymentRemaining
-                             << ", " << deltas.interest << ", " << deltas.principal << ", "
-                             << deltas.managementFee << ", " << paymentComponents.trackedValueDelta
-                             << ", " << paymentComponents.trackedPrincipalDelta << ", "
-                             << paymentComponents.trackedInterestPart() << ", "
-                             << paymentComponents.trackedManagementFeeDelta << ", "
-                             << [&]() -> char const* {
-                        if (paymentComponents.specialCase ==
-                            ::xrpl::detail::PaymentSpecialCase::Final)
-                            return "final";
-                        if (paymentComponents.specialCase ==
-                            ::xrpl::detail::PaymentSpecialCase::Extra)
-                            return "extra";
-                        return "none";
-                    }();
-
-                    auto const totalDueAmount = STAmount{
-                        broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()};
-
-                    // Due to the rounding algorithms to keep the interest and
-                    // principal in sync with "true" values, the computed amount
-                    // may be a little less than the rounded fixed payment
-                    // amount. For integral types, the difference should be < 3
-                    // (1 unit for each of the interest and management fee). For
-                    // IOUs, the difference should be after the 8th digit.
-                    Number const diff = totalDue - totalDueAmount;
-                    BEAST_EXPECT(
-                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                        diff == beast::kZero ||
-                        (diff > beast::kZero &&
-                         ((broker.asset.integral() && (static_cast(diff) < 3)) ||
-                          (state.loanScale - diff.exponent() > 13))));
-
-                    BEAST_EXPECT(
-                        paymentComponents.trackedValueDelta ==
-                        paymentComponents.trackedPrincipalDelta +
-                            paymentComponents.trackedInterestPart() +
-                            paymentComponents.trackedManagementFeeDelta);
-                    BEAST_EXPECT(
-                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                        paymentComponents.trackedValueDelta <= roundedPeriodicPayment);
-
-                    BEAST_EXPECT(
-                        state.paymentRemaining < 12 ||
-                        roundToAsset(
-                            broker.asset,
-                            deltas.principal,
-                            state.loanScale,
-                            Number::RoundingMode::Upward) ==
-                            roundToScale(
-                                broker.asset(
-                                    Number(8333228691531218890, -17), Number::RoundingMode::Upward),
-                                state.loanScale,
-                                Number::RoundingMode::Upward));
-                    BEAST_EXPECT(
-                        paymentComponents.trackedPrincipalDelta >= beast::kZero &&
-                        paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
-                    BEAST_EXPECT(
-                        paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final ||
-                        paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
-                    BEAST_EXPECT(
-                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
-                        (state.periodicPayment.exponent() -
-                         (deltas.principal + deltas.interest + deltas.managementFee -
-                          state.periodicPayment)
-                             .exponent()) > 14);
-
-                    auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
-
-                    if (canImpairLoan(env, broker, state))
-                    {
-                        // Making a payment will unimpair the loan
-                        env(manage(lender, loanKeylet.key, tfLoanImpair));
-                    }
-
-                    env.close();
-
-                    // Make the payment
-                    env(pay(borrower, loanKeylet.key, transactionAmount));
-
-                    env.close();
-
-                    // Need to account for fees if the loan is in XRP
-                    PrettyAmount adjustment = broker.asset(0);
-                    if (broker.asset.native())
-                    {
-                        adjustment = env.current()->fees().base;
-                    }
-
-                    // Check the result
-                    verifyLoanStatus.checkPayment(
-                        state.loanScale,
-                        borrower,
-                        borrowerBalanceBeforePayment,
-                        totalDueAmount,
-                        adjustment);
-
-                    --state.paymentRemaining;
-                    state.previousPaymentDate = state.nextPaymentDate;
-                    if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final)
-                    {
-                        state.paymentRemaining = 0;
-                        state.nextPaymentDate = 0;
-                    }
-                    else
-                    {
-                        state.nextPaymentDate += state.paymentInterval;
-                    }
-                    state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
-                    state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
-                    state.totalValue -= paymentComponents.trackedValueDelta;
-
-                    verifyLoanStatus(state);
-
-                    totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
-                    totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
-                    totalPaid.trackedManagementFeeDelta +=
-                        paymentComponents.trackedManagementFeeDelta;
-                    totalInterestPaid += paymentComponents.trackedInterestPart();
-                    ++totalPaymentsMade;
-
-                    currentTrueState = nextTrueState;
-                }
-
-                // Loan is paid off
-                BEAST_EXPECT(state.paymentRemaining == 0);
-                BEAST_EXPECT(state.principalOutstanding == 0);
-
-                // Make sure all the payments add up
-                BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
-                BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
-                BEAST_EXPECT(
-                    totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
-                // This is almost a tautology given the previous checks, but
-                // check it anyway for completeness.
-                BEAST_EXPECT(
-                    totalInterestPaid ==
-                    initialState.totalValue -
-                        (initialState.principalOutstanding +
-                         initialState.managementFeeOutstanding));
-                BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
-
-                // Can't impair or default a paid off loan
-                env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
-                env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
-            });
-
-#if LOAN_TODO
-        // TODO
-
-        /*
-        LoanPay fails with tecINVARIANT_FAILED  error when loan_broker(also
-        borrower) tries to do the payment. Here's the scenario: Create a XRP
-        loan with loan broker as borrower, loan origination fee and loan service
-        fee. Loan broker makes the first payment with periodic payment and loan
-        service fee.
-        */
-
-        auto time = [&](std::string label, std::function timed) {
-            if (!BEAST_EXPECT(timed))
-                return;
-
-            using clock_type = std::chrono::steady_clock;
-            using duration_type = std::chrono::milliseconds;
-
-            auto const start = clock_type::now();
-            timed();
-            auto const duration =
-                std::chrono::duration_cast(clock_type::now() - start);
-
-            log << label << " took " << duration.count() << "ms" << std::endl;
-
-            return duration;
-        };
-
-        lifecycle(
-            caseLabel,
-            "timing",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
-                // Estimate optimal values for kLoanPaymentsPerFeeIncrement and
-                // kLoanMaximumPaymentsPerTransaction.
-                using namespace loan;
-
-                auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet);
-                auto const serviceFee = broker.asset(2).value();
-
-                STAmount const totalDue{
-                    broker.asset,
-                    roundPeriodicPayment(
-                        broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
-
-                // Make a single payment
-                time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); });
-                env.close();
-
-                // Make all but the final payment
-                auto const numPayments = (state.paymentRemaining - 2);
-                STAmount const bigPayment{broker.asset, totalDue * numPayments};
-                XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)};
-                time("ten payments", [&]() {
-                    env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee));
-                });
-                env.close();
-
-                time("final payment", [&]() {
-                    // Make the final payment
-                    env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1}));
-                });
-                env.close();
-            });
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Explicit overpayment",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment prohibited - Late payment",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Late payment",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
-
-        lifecycle(
-            caseLabel,
-            "Loan overpayment allowed - Late payment and overpayment",
-            env,
-            loanAmount,
-            interestExponent,
-            lender,
-            borrower,
-            evan,
-            broker,
-            pseudoAcct,
-            tfLoanOverpayment,
-            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
-
-#endif
-    }
-
-    void
-    testLoanSet(FeatureBitset features)
-    {
-        using namespace jtx;
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        struct CaseArgs
-        {
-            bool requireAuth = false;
-            bool authorizeBorrower = false;
-            int initialXRP = 1'000'000;
-        };
-
-        auto const testCase = [&, this](
-                                  std::function mptTest,
-                                  std::function iouTest,
-                                  CaseArgs args = {}) {
-            Env env(*this, features);
-            env.fund(XRP(args.initialXRP), issuer, lender, borrower);
-            env.close();
-            if (args.requireAuth)
-            {
-                env(fset(issuer, asfRequireAuth));
-                env.close();
-            }
-
-            // We need two different asset types, MPT and IOU. Prepare MPT
-            // first
-            MPTTester mptt{env, issuer, kMptInitNoFund};
-
-            auto const kNone = LedgerSpecificFlags(0);
-            mptt.create(
-                {.flags = tfMPTCanTransfer | tfMPTCanLock |
-                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
-            env.close();
-            PrettyAsset const mptAsset = mptt.issuanceID();
-            mptt.authorize({.account = lender});
-            mptt.authorize({.account = borrower});
-            env.close();
-            if (args.requireAuth)
-            {
-                mptt.authorize({.account = issuer, .holder = lender});
-                if (args.authorizeBorrower)
-                    mptt.authorize({.account = issuer, .holder = borrower});
-                env.close();
-            }
-
-            env(pay(issuer, lender, mptAsset(10'000'000)));
-            env.close();
-
-            // Prepare IOU
-            PrettyAsset const iouAsset = issuer[iouCurrency_];
-            env(trust(lender, iouAsset(10'000'000)));
-            env(trust(borrower, iouAsset(10'000'000)));
-            env.close();
-            if (args.requireAuth)
-            {
-                env(trust(issuer, iouAsset(0), lender, tfSetfAuth));
-                env(pay(issuer, lender, iouAsset(10'000'000)));
-                if (args.authorizeBorrower)
-                {
-                    env(trust(issuer, iouAsset(0), borrower, tfSetfAuth));
-                    env(pay(issuer, borrower, iouAsset(10'000)));
-                }
-            }
-            else
-            {
-                env(pay(issuer, lender, iouAsset(10'000'000)));
-                env(pay(issuer, borrower, iouAsset(10'000)));
-            }
-            env.close();
-
-            // Create vaults and loan brokers
-            std::array const assets{mptAsset, iouAsset};
-            std::vector brokers;
-            brokers.reserve(assets.size());
-            for (auto const& asset : assets)
-            {
-                brokers.emplace_back(createVaultAndBroker(env, asset, lender));
-            }
-
-            if (mptTest)
-                mptTest(env, brokers[0], mptt);
-            if (iouTest)
-                iouTest(env, brokers[1]);
-        };
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("MPT issuer is borrower, issuer submits");
-                env(set(issuer, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-
-                testcase("MPT issuer is borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(issuer),
-                    Sig(sfCounterpartySignature, issuer),
-                    Fee(env.current()->fees().base * 5));
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("IOU issuer is borrower, issuer submits");
-                env(set(issuer, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-
-                testcase("IOU issuer is borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(issuer),
-                    Sig(sfCounterpartySignature, issuer),
-                    Fee(env.current()->fees().base * 5));
-            },
-            CaseArgs{.requireAuth = true});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("MPT unauthorized borrower, borrower submits");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-
-                testcase("MPT unauthorized borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Sig(sfCounterpartySignature, borrower),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("IOU unauthorized borrower, borrower submits");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-
-                testcase("IOU unauthorized borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Sig(sfCounterpartySignature, borrower),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-            },
-            CaseArgs{.requireAuth = true});
-
-        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, BrokerInfo const& broker, MPTTester& mptt) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "MPT authorized borrower, borrower submits, borrower has "
-                    "no reserve");
-                mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize});
-                env.close();
-
-                auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower);
-                auto const sleMPT1 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT1 == nullptr);
-
-                // Burn some XRP
-                env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
-                env.close();
-
-                // Cannot create loan, not enough reserve to create MPToken
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecINSUFFICIENT_RESERVE});
-                env.close();
-
-                // Can create loan now, will implicitly create MPToken
-                env(pay(issuer, borrower, XRP(incReserve)));
-                env.close();
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-                env.close();
-
-                auto const sleMPT2 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT2 != nullptr);
-            },
-            {},
-            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
-
-        testCase(
-            {},
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "IOU authorized borrower, borrower submits, borrower has "
-                    "no reserve");
-                // Remove trust line from borrower to issuer
-                env.trust(broker.asset(0), borrower);
-                env.close();
-
-                env(pay(borrower, issuer, broker.asset(10'000)));
-                env.close();
-                auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get());
-                auto const sleLine1 = env.le(trustline);
-                BEAST_EXPECT(sleLine1 == nullptr);
-
-                // Burn some XRP
-                env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
-                env.close();
-
-                // Cannot create loan, not enough reserve to create trust line
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_LINE_INSUF_RESERVE});
-                env.close();
-
-                // Can create loan now, will implicitly create trust line
-                env(pay(issuer, borrower, XRP(incReserve)));
-                env.close();
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-                env.close();
-
-                auto const sleLine2 = env.le(trustline);
-                BEAST_EXPECT(sleLine2 != nullptr);
-            },
-            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "MPT authorized borrower, borrower submits, lender has "
-                    "no reserve");
-                auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
-                auto const sleMPT1 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT1 != nullptr);
-
-                env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
-                env.close();
-
-                mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
-                env.close();
-
-                auto const sleMPT2 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT2 == nullptr);
-
-                // Burn some XRP
-                env(noop(lender), Fee(XRP(incReserve)));
-                env.close();
-
-                // Cannot create loan, not enough reserve to create MPToken
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kLoanOriginationFee(broker.asset(1).value()),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecINSUFFICIENT_RESERVE});
-                env.close();
-
-                // Can create loan now, will implicitly create MPToken
-                env(pay(issuer, lender, XRP(incReserve)));
-                env.close();
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kLoanOriginationFee(broker.asset(1).value()),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-                env.close();
-
-                auto const sleMPT3 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT3 != nullptr);
-            },
-            {},
-            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
-
-        testCase(
-            {},
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "IOU authorized borrower, borrower submits, lender has no "
-                    "reserve");
-                // Remove trust line from lender to issuer
-                env.trust(broker.asset(0), lender);
-                env.close();
-
-                auto const trustline = keylet::trustLine(lender, broker.asset.raw().get());
-                auto const sleLine1 = env.le(trustline);
-                BEAST_EXPECT(sleLine1 != nullptr);
-
-                env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value()))));
-                env.close();
-                auto const sleLine2 = env.le(trustline);
-                BEAST_EXPECT(sleLine2 == nullptr);
-
-                // Burn some XRP
-                env(noop(lender), Fee(XRP(incReserve)));
-                env.close();
-
-                // Cannot create loan, not enough reserve to create trust line
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kLoanOriginationFee(broker.asset(1).value()),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_LINE_INSUF_RESERVE});
-                env.close();
-
-                // Can create loan now, will implicitly create trust line
-                env(pay(issuer, lender, XRP(incReserve)));
-                env.close();
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kLoanOriginationFee(broker.asset(1).value()),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-                env.close();
-
-                auto const sleLine3 = env.le(trustline);
-                BEAST_EXPECT(sleLine3 != nullptr);
-            },
-            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("MPT authorized borrower, unauthorized lender");
-                auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
-                auto const sleMPT1 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT1 != nullptr);
-
-                env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
-                env.close();
-
-                mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
-                env.close();
-
-                auto const sleMPT2 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT2 == nullptr);
-
-                // Cannot create loan, lender not authorized to receive fee
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kLoanOriginationFee(broker.asset(1).value()),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-                env.close();
-
-                // Cannot create loan, even without an origination fee
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter{tecNO_AUTH});
-                env.close();
-
-                // No MPToken for lender - no authorization and no payment
-                auto const sleMPT3 = env.le(mptoken);
-                BEAST_EXPECT(sleMPT3 == nullptr);
-            },
-            {},
-            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("MPT authorized borrower, borrower submits");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("IOU authorized borrower, borrower submits");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5));
-            },
-            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("MPT authorized borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Sig(sfCounterpartySignature, borrower),
-                    Fee(env.current()->fees().base * 5));
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase("IOU authorized borrower, lender submits");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Sig(sfCounterpartySignature, borrower),
-                    Fee(env.current()->fees().base * 5));
-            },
-            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
-
-        jtx::Account const alice{"alice"};
-        jtx::Account const bella{"bella"};
-        auto const msigSetup = [&](Env& env, Account const& account) {
-            json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}});
-            env(tx1);
-            env.close();
-        };
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                msigSetup(env, lender);
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "MPT authorized borrower, borrower submits, lender "
-                    "multisign");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Msig(sfCounterpartySignature, alice, bella),
-                    Fee(env.current()->fees().base * 5));
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                msigSetup(env, lender);
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "IOU authorized borrower, borrower submits, lender "
-                    "multisign");
-                env(set(borrower, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    Msig(sfCounterpartySignature, alice, bella),
-                    Fee(env.current()->fees().base * 5));
-            },
-            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                msigSetup(env, borrower);
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "MPT authorized borrower, lender submits, borrower "
-                    "multisign");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Msig(sfCounterpartySignature, alice, bella),
-                    Fee(env.current()->fees().base * 5));
-            },
-            [&, this](Env& env, BrokerInfo const& broker) {
-                using namespace loan;
-                msigSetup(env, borrower);
-                Number const principalRequest = broker.asset(1'000).value();
-
-                testcase(
-                    "IOU authorized borrower, lender submits, borrower "
-                    "multisign");
-                env(set(lender, broker.brokerID, principalRequest),
-                    kCounterparty(borrower),
-                    Msig(sfCounterpartySignature, alice, bella),
-                    Fee(env.current()->fees().base * 5));
-            },
-            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-                Vault const vault{env};
-                auto tx = vault.set({.owner = lender, .id = broker.vaultID});
-                tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit;
-                env(tx);
-                env.close();
-
-                testcase("Vault at maximum value");
-                env(set(issuer, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    kInterestRate(TenthBips32(10'000)),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    Ter(tecLIMIT_EXCEEDED));
-            },
-            nullptr);
-
-        testCase(
-            [&, this](Env& env, BrokerInfo const& broker, auto&) {
-                using namespace loan;
-                Number const principalRequest = broker.asset(1'000).value();
-                Vault const vault{env};
-                auto tx = vault.set({.owner = lender, .id = broker.vaultID});
-                tx[sfAssetsMaximum] =
-                    BrokerParameters::defaults().vaultDeposit + broker.asset(1).number();
-                env(tx);
-                env.close();
-
-                testcase("Vault maximum value exceeded");
-                env(set(issuer, broker.brokerID, principalRequest),
-                    kCounterparty(lender),
-                    kInterestRate(TenthBips32(100'000)),
-                    Sig(sfCounterpartySignature, lender),
-                    Fee(env.current()->fees().base * 5),
-                    kPaymentTotal(2),
-                    kPaymentInterval(3600 * 24),
-                    Ter(tecLIMIT_EXCEEDED));
-            },
-            nullptr);
-    }
-
-    void
-    testLifecycle(FeatureBitset features)
-    {
-        testcase("Lifecycle");
-        using namespace jtx;
-
-        // Create 3 loan brokers: one for XRP, one for an IOU, and one for
-        // an MPT. That'll require three corresponding SAVs.
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        // For simplicity, lender will be the sole actor for the vault &
-        // brokers.
-        Account const lender{"lender"};
-        // Borrower only wants to borrow
-        Account const borrower{"borrower"};
-        // Evan will attempt to be naughty
-        Account const evan{"evan"};
-        // Do not fund alice
-        Account const alice{"alice"};
-
-        // Fund the accounts and trust lines with the same amount so that
-        // tests can use the same values regardless of the asset.
-        env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan));
-        env.close();
-
-        // Create assets
-        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        env(trust(lender, iouAsset(10'000'000)));
-        env(trust(borrower, iouAsset(10'000'000)));
-        env(trust(evan, iouAsset(10'000'000)));
-        env(pay(issuer, evan, iouAsset(1'000'000)));
-        env(pay(issuer, lender, iouAsset(10'000'000)));
-        // Fund the borrower with enough to cover interest and fees
-        env(pay(issuer, borrower, iouAsset(10'000)));
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-        // Scale the MPT asset a little bit so we can get some interest
-        PrettyAsset const mptAsset{mptt.issuanceID(), 100};
-        mptt.authorize({.account = lender});
-        mptt.authorize({.account = borrower});
-        mptt.authorize({.account = evan});
-        env(pay(issuer, lender, mptAsset(10'000'000)));
-        env(pay(issuer, evan, mptAsset(1'000'000)));
-        // Fund the borrower with enough to cover interest and fees
-        env(pay(issuer, borrower, mptAsset(10'000)));
-        env.close();
-
-        std::array const assets{iouAsset, xrpAsset, mptAsset};
-
-        // Create vaults and loan brokers
-        std::vector brokers;
-        brokers.reserve(assets.size());
-        for (auto const& asset : assets)
-        {
-            brokers.emplace_back(createVaultAndBroker(
-                env, asset, lender, BrokerParameters{.data = "spam spam spam spam"}));
-        }
-
-        // Create and update Loans
-        for (auto const& broker : brokers)
-        {
-            for (int amountExponent = 3; amountExponent >= 3; --amountExponent)
-            {
-                Number const loanAmount{1, amountExponent};
-                for (int interestExponent = 0; interestExponent >= 0; --interestExponent)
-                {
-                    testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent);
-                }
-            }
-
-            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-                BEAST_EXPECT(brokerSle))
-            {
-                BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
-                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
-
-                auto const coverAvailable = brokerSle->at(sfCoverAvailable);
-                env(loanBroker::coverWithdraw(
-                    lender, broker.brokerID, STAmount(broker.asset, coverAvailable)));
-                env.close();
-
-                brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-                BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0);
-            }
-            // Verify we can delete the loan broker
-            env(loanBroker::del(lender, broker.brokerID));
-            env.close();
-        }
-    }
-
-    void
-    testSelfLoan(FeatureBitset features)
-    {
-        testcase << "Self Loan";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        // Create 3 loan brokers: one for XRP, one for an IOU, and one for
-        // an MPT. That'll require three corresponding SAVs.
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        // For simplicity, lender will be the sole actor for the vault &
-        // brokers.
-        Account const lender{"lender"};
-
-        // Fund the accounts and trust lines with the same amount so that
-        // tests can use the same values regardless of the asset.
-        env.fund(XRP(100'000'000), issuer, noripple(lender));
-        env.close();
-
-        // Use an XRP asset for simplicity
-        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-        // Create vaults and loan brokers
-        BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        // The LoanSet json can be created without a counterparty signature,
-        // but it will not pass preflight
-        auto createJson = env.json(
-            set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee));
-        env(createJson, Ter(temBAD_SIGNER));
-
-        // Adding an empty counterparty signature object also fails, but
-        // at the RPC level.
-        createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object));
-        env(createJson, Ter(telENV_RPC_FAILED));
-
-        if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx))
-        {
-            Serializer s;
-            jt.stx->add(s);
-            auto const jr = env.rpc("submit", strHex(s.slice()));
-
-            BEAST_EXPECT(jr.isMember(jss::result));
-            auto const jResult = jr[jss::result];
-            BEAST_EXPECT(jResult[jss::error] == "invalidTransaction");
-            BEAST_EXPECT(
-                jResult[jss::error_exception] ==
-                "fails local checks: Transaction has bad signature.");
-        }
-
-        // Copy the transaction signature into the counterparty signature.
-        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];
-
-        // The duplicated signature works
-        createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson));
-        env(createJson);
-
-        env.close();
-
-        auto const startDate = env.current()->header().parentCloseTime;
-
-        // Loan is successfully created
-        {
-            auto const res = env.rpc("account_objects", lender.human());
-            auto const objects = res[jss::result][jss::account_objects];
-
-            std::map types;
-            BEAST_EXPECT(objects.size() == 4);
-            for (auto const& object : objects)
-            {
-                ++types[object[sfLedgerEntryType].asString()];
-            }
-            BEAST_EXPECT(types.size() == 4);
-            for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"})
-            {
-                BEAST_EXPECT(types[type] == 1);
-            }
-        }
-        auto const loanID = [&]() {
-            json::Value params(json::ValueType::Object);
-            params[jss::account] = lender.human();
-            params[jss::type] = "Loan";
-            auto const res = env.rpc("json", "account_objects", to_string(params));
-            auto const objects = res[jss::result][jss::account_objects];
-
-            BEAST_EXPECT(objects.size() == 1);
-
-            auto const loan = objects[0u];
-            BEAST_EXPECT(loan[sfBorrower] == lender.human());
-            // soeDEFAULT fields are not returned if they're in the default
-            // state
-            BEAST_EXPECT(!loan.isMember(sfCloseInterestRate));
-            BEAST_EXPECT(!loan.isMember(sfClosePaymentFee));
-            BEAST_EXPECT(loan[sfFlags] == 0);
-            BEAST_EXPECT(loan[sfGracePeriod] == 60);
-            BEAST_EXPECT(!loan.isMember(sfInterestRate));
-            BEAST_EXPECT(!loan.isMember(sfLateInterestRate));
-            BEAST_EXPECT(!loan.isMember(sfLatePaymentFee));
-            BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID));
-            BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee));
-            BEAST_EXPECT(loan[sfLoanSequence] == 1);
-            BEAST_EXPECT(!loan.isMember(sfLoanServiceFee));
-            BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60);
-            BEAST_EXPECT(!loan.isMember(sfOverpaymentFee));
-            BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate));
-            BEAST_EXPECT(loan[sfPaymentInterval] == 60);
-            BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000");
-            BEAST_EXPECT(loan[sfPaymentRemaining] == 1);
-            BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate));
-            BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000");
-            BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000");
-            BEAST_EXPECT(!loan.isMember(sfLoanScale));
-            BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count());
-
-            return loan["index"].asString();
-        }();
-        auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})};
-
-        env.close(startDate);
-
-        // Make a payment
-        env(pay(lender, loanKeylet.key, broker.asset(1000)));
-    }
-
-    void
-    testBatchBypassCounterparty(FeatureBitset features)
-    {
-        // From FIND-001
-        testcase << "Batch Bypass Counterparty";
-
-        bool const lendingBatchEnabled = !std::ranges::any_of(
-            Batch::kDisabledTxTypes,
-            [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; });
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, features);
-
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        BrokerParameters const brokerParams;
-        env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower);
-        env.close();
-
-        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0);
-
-        json::Value randomData{json::ValueType::Object};
-        randomData[jss::SigningPubKey] = json::StaticString{"2600"};
-        json::Value sigObject{json::ValueType::Object};
-        sigObject[jss::SigningPubKey] = strHex(lender.pk().slice());
-        Serializer ss;
-        ss.add32(HashPrefix::TxSign);
-        parse(randomData).addWithoutSigningFields(ss);
-        auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice());
-        sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()});
-
-        forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject;
-
-        // ? Fails because the lender hasn't signed the tx
-        env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED));
-
-        auto const seq = env.seq(borrower);
-        auto const batchFee = batch::calcBatchFee(env, 1, 2);
-        // ! Should fail because the lender hasn't signed the tx
-        env(batch::outer(borrower, seq, batchFee, tfAllOrNothing),
-            batch::Inner(forgedLoanSet, seq + 1),
-            batch::Inner(pay(borrower, lender, XRP(1)), seq + 2),
-            Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH));
-        env.close();
-
-        // ? Check that the loan was NOT created
-        {
-            json::Value params(json::ValueType::Object);
-            params[jss::account] = borrower.human();
-            params[jss::type] = "Loan";
-            auto const res = env.rpc("json", "account_objects", to_string(params));
-            auto const objects = res[jss::result][jss::account_objects];
-            BEAST_EXPECT(objects.size() == 0);
-        }
-    }
-
-    void
-    testWrongMaxDebtBehavior(FeatureBitset features)
-    {
-        // From FIND-003
-        testcase << "Wrong Max Debt Behavior";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-
-        BrokerParameters const brokerParams{.debtMax = 0};
-        env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender));
-        env.close();
-
-        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
-
-        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0);
-        }
-
-        using namespace loan;
-
-        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));
-        env(createJson);
-
-        env.close();
-    }
-
-    void
-    testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features)
-    {
-        // From FIND-012
-        testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
-                    "valid rate";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        BrokerParameters const brokerParams;
-        env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{640562, -5};
-
-        Number const serviceFee{2462611968};
-        std::uint32_t const numPayments{4294967295 / 800};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            kLoanServiceFee(serviceFee),
-            kPaymentTotal(numPayments),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["CloseInterestRate"] = 55374;
-        createJson["ClosePaymentFee"] = "3825205248";
-        createJson["LatePaymentFee"] = "237";
-        createJson["LoanOriginationFee"] = "0";
-        createJson["OverpaymentFee"] = 35167;
-        createJson["OverpaymentInterestRate"] = 1360;
-        createJson["PaymentInterval"] = 727;
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        // Fails in preclaim because principal requested can't be
-        // represented as XRP
-        env(createJson, Ter(tecPRECISION_LOSS));
-        env.close();
-
-        BEAST_EXPECT(!env.le(keylet));
-
-        Number const actualPrincipal{6};
-
-        createJson[sfPrincipalRequested] = actualPrincipal;
-        createJson.removeMember(sfSequence.jsonName);
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        // Fails in doApply because the payment is too small to be
-        // represented as XRP.
-        env(createJson, Ter(tecPRECISION_LOSS));
-        env.close();
-    }
-
-    void
-    testRPC(FeatureBitset features)
-    {
-        // This will expand as more test cases are added. Some functionality
-        // is tested in other test functions.
-        testcase("RPC");
-
-        using namespace jtx;
-
-        Env env(*this, features);
-
-        auto lowerFee = [&]() {
-            // Run the local fee back down.
-            while (env.app().getFeeTrack().lowerLocalFee())
-                ;
-        };
-
-        auto const baseFee = env.current()->fees().base;
-
-        Account const alice{"alice"};
-        std::string const borrowerPass = "borrower";
-        Account const borrower{borrowerPass, KeyType::Ed25519};
-        auto const lenderPass = "lender";
-        Account const lender{lenderPass, KeyType::Ed25519};
-
-        env.fund(XRP(1'000'000), alice, lender, borrower);
-        env.close();
-        env(noop(lender));
-        env(noop(lender));
-        env(noop(lender));
-        env(noop(lender));
-        env(noop(lender));
-        env.close();
-
-        {
-            testcase("RPC AccountSet");
-            json::Value txJson{json::ValueType::Object};
-            txJson[sfTransactionType] = "AccountSet";
-            txJson[sfAccount] = borrower.human();
-
-            auto const signParams = [&]() {
-                json::Value signParams{json::ValueType::Object};
-                signParams[jss::passphrase] = borrowerPass;
-                signParams[jss::key_type] = "ed25519";
-                signParams[jss::tx_json] = txJson;
-                return signParams;
-            }();
-            auto const jSign = env.rpc("json", "sign", to_string(signParams));
-            BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json));
-            auto txSignResult = jSign[jss::result][jss::tx_json];
-            auto txSignBlob = jSign[jss::result][jss::tx_blob].asString();
-            txSignResult.removeMember(jss::hash);
-
-            auto const jtx = env.jt(txJson, Sig(borrower));
-            BEAST_EXPECT(txSignResult == jtx.jv);
-
-            lowerFee();
-            auto const jSubmit = env.rpc("submit", txSignBlob);
-            BEAST_EXPECT(
-                jSubmit.isMember(jss::result) &&
-                jSubmit[jss::result].isMember(jss::engine_result) &&
-                jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS");
-
-            lowerFee();
-            env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ));
-        }
-
-        {
-            testcase("RPC LoanSet - illegal signature_target");
-
-            json::Value txJson{json::ValueType::Object};
-            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");
-        }
-        {
-            testcase("RPC LoanSet - sign and submit borrower initiated");
-            // 1. Borrower creates the transaction
-            json::Value txJson{json::ValueType::Object};
-            txJson[sfTransactionType] = "LoanSet";
-            txJson[sfAccount] = borrower.human();
-            txJson[sfCounterparty] = lender.human();
-            txJson[sfLoanBrokerID] =
-                "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
-                "F83F"
-                "5C";
-            txJson[sfPrincipalRequested] = "100000000";
-            txJson[sfPaymentTotal] = 10000;
-            txJson[sfPaymentInterval] = 3600;
-            txJson[sfGracePeriod] = 300;
-            txJson[sfFlags] = 65536;  // tfLoanOverpayment
-            txJson[sfFee] = to_string(24 * baseFee / 10);
-
-            // 2. Borrower signs the transaction
-            auto const borrowerSignParams = [&]() {
-                json::Value params{json::ValueType::Object};
-                params[jss::passphrase] = borrowerPass;
-                params[jss::key_type] = "ed25519";
-                params[jss::tx_json] = txJson;
-                return params;
-            }();
-            auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
-            BEAST_EXPECTS(
-                jSignBorrower.isMember(jss::result) &&
-                    jSignBorrower[jss::result].isMember(jss::tx_json),
-                to_string(jSignBorrower));
-            auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
-            auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
-
-            // 2a. Borrower attempts to submit the transaction. It doesn't
-            // work
-            {
-                lowerFee();
-                auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
-                BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
-                auto const jSubmitBlobResult = jSubmitBlob[jss::result];
-                BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
-                // Transaction fails because the CounterpartySignature is
-                // missing
-                BEAST_EXPECT(
-                    jSubmitBlobResult.isMember(jss::engine_result) &&
-                    jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
-            }
-
-            // 3. Borrower sends the signed transaction to the lender
-            // 4. Lender signs the transaction
-            auto const lenderSignParams = [&]() {
-                json::Value params{json::ValueType::Object};
-                params[jss::passphrase] = lenderPass;
-                params[jss::key_type] = "ed25519";
-                params[jss::signature_target] = "CounterpartySignature";
-                params[jss::tx_json] = txBorrowerSignResult;
-                return params;
-            }();
-            auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
-            BEAST_EXPECT(
-                jSignLender.isMember(jss::result) &&
-                jSignLender[jss::result].isMember(jss::tx_json));
-            auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
-            auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
-
-            // 5. Lender submits the signed transaction blob
-            lowerFee();
-            auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
-            BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
-            auto const jSubmitBlobResult = jSubmitBlob[jss::result];
-            BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
-            auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
-            // To get far enough to return tecNO_ENTRY means that the
-            // signatures all validated. Of course the transaction won't
-            // succeed because no Vault or Broker were created.
-            BEAST_EXPECTS(
-                jSubmitBlobResult.isMember(jss::engine_result) &&
-                    jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
-                to_string(jSubmitBlobResult));
-
-            BEAST_EXPECT(
-                !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
-
-            // 4-alt. Lender submits the transaction json originally
-            // received from the Borrower. It gets signed, but is now a
-            // duplicate, so fails. Borrower could done this instead of
-            // steps 4 and 5.
-            lowerFee();
-            auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams));
-            BEAST_EXPECT(jSubmitJson.isMember(jss::result));
-            auto const jSubmitJsonResult = jSubmitJson[jss::result];
-            BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
-            auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
-            // Since the previous tx claimed a fee, this duplicate is not
-            // going anywhere
-            BEAST_EXPECTS(
-                jSubmitJsonResult.isMember(jss::engine_result) &&
-                    jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
-                to_string(jSubmitJsonResult));
-
-            BEAST_EXPECT(
-                !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
-
-            BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
-        }
-
-        {
-            testcase("RPC LoanSet - sign and submit lender initiated");
-            // 1. Lender creates the transaction
-            json::Value txJson{json::ValueType::Object};
-            txJson[sfTransactionType] = "LoanSet";
-            txJson[sfAccount] = lender.human();
-            txJson[sfCounterparty] = borrower.human();
-            txJson[sfLoanBrokerID] =
-                "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
-                "F83F"
-                "5C";
-            txJson[sfPrincipalRequested] = "100000000";
-            txJson[sfPaymentTotal] = 10000;
-            txJson[sfPaymentInterval] = 3600;
-            txJson[sfGracePeriod] = 300;
-            txJson[sfFlags] = 65536;  // tfLoanOverpayment
-            txJson[sfFee] = to_string(24 * baseFee / 10);
-
-            // 2. Lender signs the transaction
-            auto const lenderSignParams = [&]() {
-                json::Value params{json::ValueType::Object};
-                params[jss::passphrase] = lenderPass;
-                params[jss::key_type] = "ed25519";
-                params[jss::tx_json] = txJson;
-                return params;
-            }();
-            auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
-            BEAST_EXPECT(
-                jSignLender.isMember(jss::result) &&
-                jSignLender[jss::result].isMember(jss::tx_json));
-            auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
-            auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
-
-            // 2a. Lender attempts to submit the transaction. It doesn't
-            // work
-            {
-                lowerFee();
-                auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
-                BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
-                auto const jSubmitBlobResult = jSubmitBlob[jss::result];
-                BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
-                // Transaction fails because the CounterpartySignature is
-                // missing
-                BEAST_EXPECT(
-                    jSubmitBlobResult.isMember(jss::engine_result) &&
-                    jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
-            }
-
-            // 3. Lender sends the signed transaction to the Borrower
-            // 4. Borrower signs the transaction
-            auto const borrowerSignParams = [&]() {
-                json::Value params{json::ValueType::Object};
-                params[jss::passphrase] = borrowerPass;
-                params[jss::key_type] = "ed25519";
-                params[jss::signature_target] = "CounterpartySignature";
-                params[jss::tx_json] = txLenderSignResult;
-                return params;
-            }();
-            auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
-            BEAST_EXPECT(
-                jSignBorrower.isMember(jss::result) &&
-                jSignBorrower[jss::result].isMember(jss::tx_json));
-            auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
-            auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
-
-            // 5. Borrower submits the signed transaction blob
-            lowerFee();
-            auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
-            BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
-            auto const jSubmitBlobResult = jSubmitBlob[jss::result];
-            BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
-            auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
-            // To get far enough to return tecNO_ENTRY means that the
-            // signatures all validated. Of course the transaction won't
-            // succeed because no Vault or Broker were created.
-            BEAST_EXPECTS(
-                jSubmitBlobResult.isMember(jss::engine_result) &&
-                    jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
-                to_string(jSubmitBlobResult));
-
-            BEAST_EXPECT(
-                !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
-
-            // 4-alt. Borrower submits the transaction json originally
-            // received from the Lender. It gets signed, but is now a
-            // duplicate, so fails. Lender could done this instead of steps
-            // 4 and 5.
-            lowerFee();
-            auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams));
-            BEAST_EXPECT(jSubmitJson.isMember(jss::result));
-            auto const jSubmitJsonResult = jSubmitJson[jss::result];
-            BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
-            auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
-            // Since the previous tx claimed a fee, this duplicate is not
-            // going anywhere
-            BEAST_EXPECTS(
-                jSubmitJsonResult.isMember(jss::engine_result) &&
-                    jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
-                to_string(jSubmitJsonResult));
-
-            BEAST_EXPECT(
-                !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
-
-            BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
-        }
-    }
-
-    void
-    testServiceFeeOnBrokerDeepFreeze()
-    {
-        testcase << "Service Fee On Broker Deep Freeze";
-        using namespace jtx;
-        using namespace loan;
-        Account const issuer("issuer");
-        Account const borrower("borrower");
-        Account const broker("broker");
-        auto const iou = issuer["IOU"];
-
-        for (bool const deepFreeze : {true, false})
-        {
-            Env env(*this);
-
-            auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) {
-                if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID));
-                    BEAST_EXPECT(le))
-                {
-                    auto const account = le->at(accountField);
-                    if (auto const sleLine = env.le(keylet::trustLine(account, iou));
-                        BEAST_EXPECT(sleLine))
-                    {
-                        STAmount balance = sleLine->at(sfBalance);
-                        if (account > issuer.id())
-                            balance.negate();
-                        return balance;
-                    }
-                }
-                return STAmount{iou};
-            };
-
-            env.fund(XRP(20'000), issuer, broker, borrower);
-            env.close();
-
-            env(trust(broker, iou(20'000'000)));
-            env(pay(issuer, broker, iou(10'000'000)));
-            env.close();
-
-            auto const brokerInfo = createVaultAndBroker(env, iou, broker);
-
-            BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
-
-            auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
-
-            env(set(borrower, brokerInfo.brokerID, 10'000),
-                Sig(sfCounterpartySignature, broker),
-                kLoanServiceFee(iou(100).value()),
-                kPaymentInterval(100),
-                Fee(XRP(100)));
-            env.close();
-
-            env(trust(borrower, iou(20'000'000)));
-            // The borrower increases their limit and acquires some IOU so
-            // they can pay interest
-            env(pay(issuer, borrower, iou(500)));
-            env.close();
-
-            if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le))
-            {
-                if (deepFreeze)
-                {
-                    env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
-                    env.close();
-                }
-
-                env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)));
-                env.close();
-
-                if (deepFreeze)
-                {
-                    // The fee goes to the broker pseudo-account
-                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100));
-                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000));
-                }
-                else
-                {
-                    // The fee goes to the broker account
-                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100));
-                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
-                }
-            }
-        };
-    }
-
-    void
-    testIssuerLoan()
-    {
-        testcase << "Issuer Loan";
-
-        using namespace jtx;
-        using namespace loan;
-        Account const issuer("issuer");
-        Account const borrower = issuer;
-        Account const lender("lender");
-        Env env(*this);
-
-        env.fund(XRP(1'000), issuer, lender);
-
-        static constexpr std::int64_t kIssuerBalance = 10'000'000;
-        MPTTester const asset(
-            {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance});
-
-        BrokerParameters const brokerParams{
-            .debtMax = 200,
-        };
-        auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        // Create Loan
-        env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee);
-        env.close();
-        // Issuer should not create MPToken
-        BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer)));
-        // Issuer "borrowed" 200, OutstandingAmount decreased by 200
-        BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200));
-        // Pay Loan
-        auto const loanKeylet = keylet::loan(broker.brokerID, 1);
-        env(pay(borrower, loanKeylet.key, asset(200)));
-        env.close();
-        // Issuer "re-payed" 200, OutstandingAmount increased by 200
-        BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance));
-    }
-
-    void
-    testInvalidLoanDelete()
-    {
-        testcase("Invalid LoanDelete");
-        using namespace jtx;
-        using namespace loan;
-
-        // preflight: temINVALID, LoanID == zero
-        {
-            Account const alice{"alice"};
-            Env env(*this);
-            env.fund(XRP(1'000), alice);
-            env.close();
-            env(del(alice, beast::kZero), Ter(temINVALID));
-        }
-    }
-
-    void
-    testInvalidLoanManage()
-    {
-        testcase("Invalid LoanManage");
-        using namespace jtx;
-        using namespace loan;
-
-        // preflight: temINVALID, LoanID == zero
-        {
-            Account const alice{"alice"};
-            Env env(*this);
-            env.fund(XRP(1'000), alice);
-            env.close();
-            env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID));
-        }
-    }
-
-    void
-    testInvalidLoanPay()
-    {
-        testcase("Invalid LoanPay");
-        using namespace jtx;
-        using namespace loan;
-        Account const lender{"lender"};
-        Account const issuer{"issuer"};
-        Account const borrower{"borrower"};
-        auto const iou = issuer["IOU"];
-
-        // preclaim
-        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 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();
-
-        std::uint32_t const loanSequence = 1;
-        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence);
-
-        env(fset(issuer, asfGlobalFreeze));
-        env.close();
-
-        // preclaim: tecFROZEN
-        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
-        env.close();
-
-        env(fclear(issuer, asfGlobalFreeze));
-        env.close();
-
-        auto const pseudoBroker = [&]() -> std::optional {
-            if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-                BEAST_EXPECT(brokerSle))
-            {
-                return Account{"pseudo", brokerSle->at(sfAccount)};
-            }
-
-            return std::nullopt;
-        }();
-        if (!pseudoBroker)
-            return;
-
-        // Lender and pseudoaccount must both be frozen
-        env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze));
-        env(trust(
-            issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze));
-        env.close();
-
-        // preclaim: tecFROZEN due to deep frozen
-        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
-        env.close();
-
-        // Only one needs to be unfrozen
-        env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
-        env.close();
-
-        // The payment is late by this point
-        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
-        env.close();
-        env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
-        env.close();
-
-        // preclaim: tecKILLED
-        // note that tecKILLED in loanMakePayment()
-        // doesn't happen because of the preclaim check.
-        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED));
-    }
-
-    void
-    testInvalidLoanSet()
-    {
-        testcase("Invalid LoanSet");
-        using namespace jtx;
-        using namespace loan;
-        Account const lender{"lender"};
-        Account const issuer{"issuer"};
-        Account const borrower{"borrower"};
-        Account const sponsor{"sponsor"};
-        auto const iou = issuer["IOU"];
-
-        auto testWrapper = [&](auto&& test) {
-            Env env(*this);
-            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)};
-
-            auto const loanSetFee = Fee(env.current()->fees().base * 2);
-            Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
-            test(env, brokerInfo, loanSetFee, debtMaximumRequest);
-        };
-
-        // preflight:
-        testWrapper([&](Env& env,
-                        BrokerInfo const& brokerInfo,
-                        jtx::Fee const& loanSetFee,
-                        Number const& debtMaximumRequest) {
-            for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee})
-            {
-                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                    sponsor::As(sponsor, sponsorFlags),
-                    Sig(sfCounterpartySignature, lender),
-                    loanSetFee,
-                    Ter(temINVALID_FLAG));
-            }
-
-            // first temBAD_SIGNER: TODO
-            // invalid grace period
-            {
-                // zero grace period
-                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                    Sig(sfCounterpartySignature, lender),
-                    kGracePeriod(0),
-                    loanSetFee,
-                    Ter(temINVALID));
-
-                // grace period less than default minimum
-                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                    Sig(sfCounterpartySignature, lender),
-                    kGracePeriod(LoanSet::kDefaultGracePeriod - 1),
-                    loanSetFee,
-                    Ter(temINVALID));
-
-                // grace period greater than payment interval
-                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                    Sig(sfCounterpartySignature, lender),
-                    kPaymentInterval(120),
-                    kGracePeriod(121),
-                    loanSetFee,
-                    Ter(temINVALID));
-            }
-            // empty/zero broker ID
-            {
-                auto jv = set(borrower, uint256{}, debtMaximumRequest);
-
-                auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) {
-                    // empty broker ID
-                    jv[sfLoanBrokerID] = id;
-                    env(jv,
-                        Sig(sfCounterpartySignature, lender),
-                        loanSetFee,
-                        Txflags(flags),
-                        Ter(temINVALID));
-                };
-                // empty broker ID
-                testZeroBrokerID(std::string(""));
-                // zero broker ID
-                // needs a flag to distinguish the parsed STTx from the prior
-                // test
-                testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig);
-            }
-
-            // preflightCheckSigningKey() failure:
-            // can it happen? the signature is checked before transactor
-            // executes
-
-            JTx const tx = env.jt(
-                set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee);
-            STTx local = *(tx.stx);
-            auto counterpartySig = local.getFieldObject(sfCounterpartySignature);
-            auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey);
-            badPubKey[20] ^= 0xAA;
-            counterpartySig.setFieldVL(sfSigningPubKey, badPubKey);
-            local.setFieldObject(sfCounterpartySignature, counterpartySig);
-            json::Value jvResult;
-            jvResult[jss::tx_blob] = strHex(local.getSerializer().slice());
-            auto res = env.rpc("json", "submit", to_string(jvResult))["result"];
-            BEAST_EXPECT(
-                res[jss::error] == "invalidTransaction" &&
-                res[jss::error_exception] ==
-                    "fails local checks: Counterparty: Invalid signature.");
-        });
-
-        // preclaim:
-        testWrapper([&](Env& env,
-                        BrokerInfo const& brokerInfo,
-                        jtx::Fee const& loanSetFee,
-                        Number const& debtMaximumRequest) {
-            // canAddHoldingFailure (IOU only, if MPT doesn't have
-            // MPTCanTransfer set, then can't create Vault/LoanBroker,
-            // and LoanSet will fail with different error
-            env(fclear(issuer, asfDefaultRipple));
-            env.close();
-            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee,
-                Ter(terNO_RIPPLE));
-        });
-
-        // doApply:
-        testWrapper([&](Env& env,
-                        BrokerInfo const& brokerInfo,
-                        jtx::Fee const& loanSetFee,
-                        Number const& debtMaximumRequest) {
-            auto const amt =
-                env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal);
-            env(pay(borrower, issuer, amt));
-
-            // tecINSUFFICIENT_RESERVE
-            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee,
-                Ter(tecINSUFFICIENT_RESERVE));
-
-            // addEmptyHolding failure
-            env(pay(issuer, borrower, amt));
-            env(fset(issuer, asfGlobalFreeze));
-            env.close();
-
-            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee,
-                Ter(tecFROZEN));
-        });
-    }
-
-    void
-    testAccountSendMptMinAmountInvariant(FeatureBitset features)
-    {
-        // (From FIND-006)
-        testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
-                    "and MPT";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-        PrettyAsset const mptAsset = mptt.issuanceID();
-        mptt.authorize({.account = lender});
-        mptt.authorize({.account = borrower});
-        env(pay(issuer, lender, mptAsset(2'000'000)));
-        env(pay(issuer, borrower, mptAsset(1'000)));
-        env.close();
-
-        BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["CloseInterestRate"] = 76671;
-        createJson["ClosePaymentFee"] = "2061925410";
-        createJson["GracePeriod"] = 434;
-        createJson["InterestRate"] = 50302;
-        createJson["LateInterestRate"] = 30322;
-        createJson["LatePaymentFee"] = "294427911";
-        createJson["LoanOriginationFee"] = "3250635102";
-        createJson["LoanServiceFee"] = "9557386";
-        createJson["OverpaymentFee"] = 51249;
-        createJson["OverpaymentInterestRate"] = 14304;
-        createJson["PaymentInterval"] = 434;
-        createJson["PaymentTotal"] = "2891743748";
-        createJson["PrincipalRequested"] = "8516.98";
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(temINVALID));
-        env.close();
-    }
-
-    void
-    testLoanPayDebtDecreaseInvariant(FeatureBitset features)
-    {
-        // From FIND-007
-        testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease "
-                    "rounding good";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        using namespace Lending;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
-        env(trustLenderTx);
-        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
-        env(trustBorrowerTx);
-        auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
-        env(payLenderTx);
-        auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
-        env(payIssuerTx);
-        env.close();
-
-        BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)};
-
-        using namespace loan;
-
-        auto const baseFee = env.current()->fees().base;
-        auto const loanSetFee = Fee(baseFee * 2);
-        Number const principalRequest{1, 3};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["ClosePaymentFee"] = "0";
-        createJson["GracePeriod"] = 60;
-        createJson["InterestRate"] = 24346;
-        createJson["LateInterestRate"] = 65535;
-        createJson["LatePaymentFee"] = "0";
-        createJson["LoanOriginationFee"] = "218";
-        createJson["LoanServiceFee"] = "0";
-        createJson["PaymentInterval"] = 60;
-        createJson["PaymentTotal"] = 5678;
-        createJson["PrincipalRequested"] = "9924.81";
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(tesSUCCESS));
-        env.close();
-
-        auto const pseudoAcct = [&]() {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return Account{lender};
-            auto const brokerPseudo = brokerSle->at(sfAccount);
-            return Account("Broker pseudo-account", brokerPseudo);
-        }();
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
-        auto const originalState = getCurrentState(env, broker, keylet);
-        verifyLoanStatus(originalState);
-
-        Number const payment{3'269'349'176'470'588, -12};
-        XRPAmount const payFee{
-            baseFee *
-            ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)};
-        auto loanPayTx =
-            env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee));
-        BEAST_EXPECT(to_string(payment) == "3269.349176470588");
-        env(loanPayTx, Ter(tesSUCCESS));
-        env.close();
-
-        auto const newState = getCurrentState(env, broker, keylet);
-        BEAST_EXPECT(
-            isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale));
-        BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding);
-        BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale));
-        BEAST_EXPECT(
-            isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
-    }
-
-    void
-    testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features)
-    {
-        // From FIND-010
-        testcase << "xrpl::loanComputePaymentParts : valid total interest";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
-        env(trustLenderTx);
-        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
-        env(trustBorrowerTx);
-        auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
-        env(payLenderTx);
-        auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
-        env(payIssuerTx);
-        env.close();
-
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["CloseInterestRate"] = 47299;
-        createJson["ClosePaymentFee"] = "3985819770";
-        createJson["InterestRate"] = 92;
-        createJson["LatePaymentFee"] = "3866894865";
-        createJson["LoanOriginationFee"] = "0";
-        createJson["LoanServiceFee"] = "2348810240";
-        createJson["OverpaymentFee"] = 58545;
-        createJson["PaymentInterval"] = 60;
-        createJson["PaymentTotal"] = 1;
-        createJson["PrincipalRequested"] = "0.000763058";
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson);
-        env.close();
-
-        auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
-        loanPayTx["Amount"]["value"] = "0.000281284125490196";
-        env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT));
-        env.close();
-    }
-
-    void
-    testDosLoanPay(FeatureBitset features)
-    {
-        bool const feeCapped = features[fixCleanup3_1_3];
-
-        // From FIND-005
-        testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped");
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        using namespace Lending;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3));
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        env(trust(lender, iouAsset(100'000'000)));
-        env(trust(borrower, iouAsset(100'000'000)));
-        env(pay(issuer, lender, iouAsset(10'000'000)));
-        env(pay(issuer, borrower, iouAsset(1'000)));
-        env.close();
-
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{3959'37, -2};
-        auto const baseFee = env.current()->fees().base;
-
-        auto const createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object),
-            kClosePaymentFee(0),
-            kGracePeriod(60),
-            kInterestRate(TenthBips32(20930)),
-            kLateInterestRate(TenthBips32(77049)),
-            kLatePaymentFee(0),
-            kLoanServiceFee(0),
-            kOverpaymentFee(TenthBips32(7)),
-            kOverpaymentInterestRate(TenthBips32(66653)),
-            kPaymentInterval(60),
-            kPaymentTotal(3239184));
-
-        // There are enough payments due on this loan that it only needs to be
-        // created once, and can be paid on multiple times. Just don't create a
-        // gazillion test cases.
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        env(createJson, Sig(sfCounterpartySignature, lender));
-        env.close();
-
-        auto const roundedPayment = [&]() {
-            auto const stateBefore = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(stateBefore.paymentRemaining == 3239184);
-            BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
-
-            return roundToAsset(
-                iouAsset,
-                stateBefore.periodicPayment,
-                stateBefore.loanScale,
-                Number::RoundingMode::Upward);
-        }();
-
-        auto test = [&](int const payFactor,
-                        int const feeFactor,
-                        TER const expectedTer = tesSUCCESS) {
-            auto const stateBefore = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184);
-            BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
-
-            Number const amount = roundedPayment * payFactor;
-            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount}));
-            XRPAmount const payFee{baseFee * feeFactor};
-            env(loanPayTx, Ter(expectedTer), Fee(payFee));
-            env.close();
-            auto const expectedChange = isTesSuccess(expectedTer)
-                ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor)
-                : 0;
-
-            auto const stateAfter = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(
-                stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange);
-        };
-
-        static constexpr std::int64_t kMaxFeeIncrements =
-            kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement;
-
-        TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P;
-
-        // * Amount well above threshold -> capped fee
-        // The original test case - way over the limit - more fee is always ok
-        test(1819878, 363976);
-        // The capped fee is only sufficient if the amendment is enabled.
-        test(1819878, kMaxFeeIncrements, failWithoutFix);
-
-        // * Amount exactly at threshold -> capped fee
-        test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements);
-        // More fee is always ok
-        test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10);
-
-        // * Amount below threshold -> normal calculation
-        test(1, 1);
-        test(kLoanPaymentsPerFeeIncrement * 2, 2);
-        test(0, 0, temBAD_AMOUNT);
-        test(0, 1, temBAD_AMOUNT);
-        // Fee difference rounds evenly
-        test(
-            kLoanMaximumPaymentsPerTransaction - 10,
-            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1,
-            telINSUF_FEE_P);
-        test(
-            kLoanMaximumPaymentsPerTransaction - 10,
-            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement));
-        // More fee is always ok
-        test(
-            kLoanMaximumPaymentsPerTransaction - 10,
-            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3);
-        // Fee rounds up
-        for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under)
-        {
-            test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P);
-            test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements);
-        }
-        // Only when you get one less fee increment can you pay less
-        test(
-            kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement,
-            kMaxFeeIncrements - 1);
-        // And again, more fee is always ok.
-        test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements);
-    }
-
-    void
-    testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features)
-    {
-        // From FIND-009
-        testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
-                    "rounded";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        using namespace Lending;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
-        env(trustLenderTx);
-        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
-        env(trustBorrowerTx);
-        auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
-        env(payLenderTx);
-        auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000));
-        env(payIssuerTx);
-        env.close();
-
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["ClosePaymentFee"] = "0";
-        createJson["InterestRate"] = 24346;
-        createJson["LateInterestRate"] = 65535;
-        createJson["LatePaymentFee"] = "0";
-        createJson["LoanOriginationFee"] = "218";
-        createJson["LoanServiceFee"] = "0";
-        createJson["PaymentInterval"] = 60;
-        createJson["PaymentTotal"] = 5678;
-        createJson["PrincipalRequested"] = "9924.81";
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(tesSUCCESS));
-        env.close();
-
-        auto const baseFee = env.current()->fees().base;
-
-        auto const stateBefore = getCurrentState(env, broker, keylet);
-
-        {
-            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
-            Number const amount{3074'745'058'823'529, -12};
-            BEAST_EXPECT(to_string(amount) == "3074.745058823529");
-            XRPAmount const payFee{
-                baseFee *
-                (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
-            loanPayTx["Amount"]["value"] = to_string(amount);
-            env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
-            env.close();
-        }
-
-        {
-            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
-            Number const amount{6732'118'170'944'051, -12};
-            BEAST_EXPECT(to_string(amount) == "6732.118170944051");
-            XRPAmount const payFee{
-                baseFee *
-                (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
-            loanPayTx["Amount"]["value"] = to_string(amount);
-            env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
-            env.close();
-        }
-
-        auto const stateAfter = getCurrentState(env, broker, keylet);
-        // Total interest outstanding is non-negative
-        BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding);
-        // Principal paid is non-negative
-        BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding);
-        // Total value change is non-negative
-        BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue);
-        // Value delta is larger or same as principal delta (meaning
-        // non-negative interest paid)
-        BEAST_EXPECT(
-            (stateBefore.totalValue - stateAfter.totalValue) >=
-            (stateBefore.principalOutstanding - stateAfter.principalOutstanding));
-    }
-
-    void
-    testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features)
-    {
-        // From FIND-008
-        testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        using namespace Lending;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
-        env(trustLenderTx);
-        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
-        env(trustBorrowerTx);
-        auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
-        env(payLenderTx);
-        auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000));
-        env(payIssuerTx);
-        env.close();
-
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
-        {
-            auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value();
-            env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue));
-            env.close();
-        }
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 3};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["ClosePaymentFee"] = "0";
-        createJson["InterestRate"] = 12833;
-        createJson["LateInterestRate"] = 77048;
-        createJson["LatePaymentFee"] = "0";
-        createJson["LoanOriginationFee"] = "218";
-        createJson["LoanServiceFee"] = "0";
-        createJson["PaymentInterval"] = 752;
-        createJson["PaymentTotal"] = 5678;
-        createJson["PrincipalRequested"] = "9924.81";
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(tesSUCCESS));
-        env.close();
-
-        auto const baseFee = env.current()->fees().base;
-
-        auto const stateBefore = getCurrentState(env, broker, keylet);
-        BEAST_EXPECT(stateBefore.paymentRemaining == 5678);
-        BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
-
-        auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
-        Number const amount{9924'81, -2};
-        BEAST_EXPECT(to_string(amount) == "9924.81");
-        XRPAmount const payFee{
-            baseFee * (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
-        loanPayTx["Amount"]["value"] = to_string(amount);
-        env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
-        env.close();
-
-        auto const stateAfter = getCurrentState(env, broker, keylet);
-        BEAST_EXPECT(
-            stateAfter.paymentRemaining ==
-            stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction);
-    }
-
-    void
-    testLoanNextPaymentDueDateOverflow(FeatureBitset features)
-    {
-        // For FIND-013
-        testcase << "Prevent nextPaymentDueDate overflow";
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        using namespace Lending;
-        Env env{*this, features};
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
-        env(trustLenderTx);
-        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
-        env(trustBorrowerTx);
-        auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000));
-        env(payLenderTx);
-        auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000));
-        env(payIssuerTx);
-        env.close();
-
-        BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}};
-        BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-
-        using timeType = decltype(sfNextPaymentDueDate)::type::value_type;
-        static_assert(std::is_same_v);
-        constexpr timeType kMaxTime = std::numeric_limits::max();
-        static_assert(kMaxTime == 4'294'967'295);
-
-        auto const baseJson = [&]() {
-            auto createJson = env.json(
-                set(borrower, broker.brokerID, Number{55524'81, -2}),
-                Fee(loanSetFee),
-                kClosePaymentFee(0),
-                kGracePeriod(LoanSet::kDefaultGracePeriod),
-                kInterestRate(TenthBips32(12833)),
-                kLateInterestRate(TenthBips32(77048)),
-                kLatePaymentFee(0),
-                kLoanOriginationFee(218),
-                Json(sfCounterpartySignature, json::ValueType::Object));
-
-            createJson.removeMember(sfSequence.getJsonName());
-
-            return createJson;
-        }();
-
-        auto const baseFee = env.current()->fees().base;
-
-        auto parentCloseTime = [&]() {
-            return env.current()->parentCloseTime().time_since_epoch().count();
-        };
-        auto maxLoanTime = [&]() {
-            auto const startDate = parentCloseTime();
-
-            BEAST_EXPECT(startDate >= 50);
-
-            return kMaxTime - startDate;
-        };
-
-        {
-            // straight-up overflow: interval
-            auto const interval = maxLoanTime() + 1;
-            auto const total = 1;
-            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // straight-up overflow: total
-            // min interval is 60
-            auto const interval = 60;
-            auto const total = maxLoanTime() + 1;
-            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // straight-up overflow: grace period
-            // min interval is 60
-            auto const interval = maxLoanTime() + 1;
-            auto const total = 1;
-            auto const grace = interval;
-            auto createJson = env.json(
-                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
-
-            // The grace period can't be larger than the interval.
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // Overflow with multiplication of a few large intervals
-            auto const interval = 1'000'000'000;
-            auto const total = 10;
-            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // Overflow with multiplication of many small payments
-            // min interval is 60
-            auto const interval = 60;
-            auto const total = 1'000'000'000;
-            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // Overflow with an absurdly large grace period
-            // min interval is 60
-            auto const total = 60;
-            auto const interval = (maxLoanTime() - total) / total;
-            auto const grace = interval;
-            auto createJson = env.json(
-                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
-            env.close();
-        }
-        {
-            // Start date when the ledger is closed will be larger
-            auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-            auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-            auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-            auto const grace = 100;
-            auto const interval = maxLoanTime() - grace;
-            auto const total = 1;
-            auto createJson = env.json(
-                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
-            env.close();
-
-            // The transaction is killed in the closed ledger
-            auto const meta = env.meta();
-            if (BEAST_EXPECT(meta))
-            {
-                BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED);
-            }
-
-            // If the transaction had succeeded, the loan would exist
-            auto const loanSle = env.le(keylet);
-            // but it doesn't
-            BEAST_EXPECT(!loanSle);
-        }
-        {
-            // Start date when the ledger is closed will be larger
-            auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-            auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-            auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
-            auto const grace = 5'000;
-            auto const interval = kMaxTime - closeStartDate - grace;
-            auto const total = 1;
-            auto createJson = env.json(
-                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
-            env.close();
-
-            // The transaction succeeds in the closed ledger
-            auto const meta = env.meta();
-            if (BEAST_EXPECT(meta))
-            {
-                BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
-            }
-
-            // This loan exists
-            auto const afterState = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
-            BEAST_EXPECT(afterState.previousPaymentDate == 0);
-            BEAST_EXPECT(afterState.paymentRemaining == 1);
-        }
-
-        {
-            // Ensure the borrower has funds to pay back the loan
-            env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2})));
-
-            // Start date when the ledger is closed will be larger
-            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
-            auto const grace = 5'000;
-            auto const maxLoanTime = kMaxTime - closeStartDate - grace;
-            auto const total = [&]() {
-                if (maxLoanTime % 5 == 0)
-                    return 5;
-                if (maxLoanTime % 3 == 0)
-                    return 3;
-                if (maxLoanTime % 2 == 0)
-                    return 2;
-                return 0;
-            }();
-            if (!BEAST_EXPECT(total != 0))
-                return;
-
-            auto const brokerState = env.le(keylet::loanBroker(broker.brokerID));
-            // Intentionally shadow the outer values
-            auto const loanSequence = brokerState->at(sfLoanSequence);
-            auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-            auto const interval = maxLoanTime / total;
-            auto createJson = env.json(
-                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
-
-            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
-            env.close();
-
-            // This loan exists
-            auto const beforeState = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval);
-            BEAST_EXPECT(beforeState.previousPaymentDate == 0);
-            BEAST_EXPECT(beforeState.paymentRemaining == total);
-            BEAST_EXPECT(beforeState.periodicPayment > 0);
-
-            // pay all but the last payment
-            {
-                NumberRoundModeGuard const mg{Number::RoundingMode::Upward};
-                Number const payment = beforeState.periodicPayment * (total - 1);
-                XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)};
-                STAmount const paymentAmount =
-                    roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale);
-                auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee));
-                env(loanPayTx, Ter(tesSUCCESS));
-                env.close();
-            }
-
-            // The loan is on the last payment
-            auto const afterState = getCurrentState(env, broker, keylet);
-            BEAST_EXPECT(afterState.paymentRemaining == 1);
-            BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
-            BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval);
-        }
-    }
-
-    void
-    testRequireAuth()
-    {
-        testcase("Require Auth - Implicit Pseudo-account authorization");
-        using namespace jtx;
-        using namespace loan;
-        Account const lender{"lender"};
-        Account const issuer{"issuer"};
-        Account const borrower{"borrower"};
-        Env env(*this);
-
-        env.fund(XRP(100'000), issuer, lender, borrower);
-        env.close();
-
-        auto asset = MPTTester({
-            .env = env,
-            .issuer = issuer,
-            .holders = {lender, borrower},
-            .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock,
-            .authHolder = true,
-        });
-
-        env(pay(issuer, lender, asset(5'000'000)));
-        BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)};
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
-
-        auto forUnauthAuth = [&](auto&& doTx) {
-            for (auto const flag : {tfMPTUnauthorize, 0u})
-            {
-                asset.authorize({.account = issuer, .holder = borrower, .flags = flag});
-                env.close();
-                doTx(flag == 0);
-                env.close();
-            }
-        };
-
-        // Can't create a loan if the borrower is not authorized
-        forUnauthAuth([&](bool authorized) {
-            auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
-            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee,
-                err);
-        });
-
-        static constexpr std::uint32_t kLoanSequence = 1;
-        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence);
-
-        // Can't loan pay if the borrower is not authorized
-        forUnauthAuth([&](bool authorized) {
-            auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
-            env(pay(borrower, loanKeylet.key, debtMaximumRequest), err);
-        });
-    }
-
-    void
-    testLendingCanTradeDisabledNoImpact()
-    {
-        testcase("Lending: CanTrade disabled has no impact");
-        using namespace jtx;
-        using namespace loan;
-        using namespace loanBroker;
-
-        Env env(*this, all_);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        MPTTester mpt(
-            {.env = env,
-             .issuer = issuer,
-             .holders = {lender, borrower},
-             .flags = tfMPTCanTransfer | tfMPTCanLock,
-             .mutableFlags = tmfMPTCanEnableCanTrade});
-        PrettyAsset const asset = mpt.issuanceID();
-        env(pay(issuer, lender, asset(10'000'000)));
-        env(pay(issuer, borrower, asset(100'000)));
-        env.close();
-
-        auto const broker = createVaultAndBroker(env, asset, lender);
-
-        // CanTrade is not set
-        env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
-        env.close();
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-
-        // New cover deposits still work.
-        env(coverDeposit(lender, broker.brokerID, asset(100)));
-        env.close();
-
-        // New loan issuance still works.
-        env(loan::set(borrower, broker.brokerID, 1'000),
-            Sig(sfCounterpartySignature, lender),
-            loanSetFee);
-        env.close();
-        auto const loanKeylet = keylet::loan(broker.brokerID, 1);
-        BEAST_EXPECT(env.le(loanKeylet));
-
-        // Repayment still works.
-        env(pay(borrower, loanKeylet.key, asset(1'000)));
-        env.close();
-
-        // Cover withdrawal still works.
-        env(coverWithdraw(lender, broker.brokerID, asset(100)));
-        env.close();
-
-        // Enable CanTrade and verify the DEX path is restored.
-        mpt.set({.mutableFlags = tmfMPTSetCanTrade});
-        env.close();
-
-        env(offer(lender, XRP(1), asset(10)));
-        env.close();
-    }
-
-#if LOAN_TODO
-    void
-    testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features)
-    {
-        testcase("LoanPay full payment skips late penalties");
-        using namespace jtx;
-        using namespace loan;
-        using namespace std::chrono_literals;
-
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-
-        PrettyAsset const asset = issuer[iouCurrency];
-        env(trust(lender, asset(100'000'000)));
-        env(trust(borrower, asset(100'000'000)));
-        env(pay(issuer, lender, asset(50'000'000)));
-        env(pay(issuer, borrower, asset(5'000'000)));
-        env.close();
-
-        BrokerInfo broker{createVaultAndBroker(env, asset, lender)};
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-
-        auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID));
-        if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value())
-            return;
-
-        auto const loanSequence = brokerPreLoan->at(sfLoanSequence);
-        auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-
-        Number const principal = asset(1'000).value();
-        Number const serviceFee = asset(2).value();
-        Number const lateFee = asset(5).value();
-        Number const closeFee = asset(4).value();
-
-        env(set(borrower, broker.brokerID, principal),
-            Sig(sfCounterpartySignature, lender),
-            kLoanServiceFee(serviceFee),
-            kLatePaymentFee(lateFee),
-            kClosePaymentFee(closeFee),
-            kInterestRate(percentageToTenthBips(12)),
-            kLateInterestRate(percentageToTenthBips(24) / 10),
-            kCloseInterestRate(percentageToTenthBips(5)),
-            kPaymentTotal(12),
-            kPaymentInterval(600),
-            kGracePeriod(0),
-            Fee(loanSetFee));
-        env.close();
-
-        auto state1 = getCurrentState(env, broker, loanKeylet);
-        if (!BEAST_EXPECT(state1.paymentRemaining > 1))
-            return;
-
-        using d = NetClock::duration;
-        using tp = NetClock::time_point;
-        auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}};
-        env.close(overdueClose);
-
-        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSle = env.le(loanKeylet);
-        if (!BEAST_EXPECT(brokerSle && loanSle))
-            return;
-
-        auto state = getCurrentState(env, broker, loanKeylet);
-
-        TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
-        TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)};
-        TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)};
-        TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)};
-
-        Number const closePaymentFeeRounded =
-            roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale);
-        Number const latePaymentFeeRounded =
-            roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale);
-
-        auto const roundedLoanState = constructLoanState(
-            state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
-        Number const totalInterestOutstanding = roundedLoanState.interestDue;
-
-        auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval);
-        auto const rawLoanState = computeTheoreticalLoanState(
-            env.current()->rules(),
-            state.periodicPayment,
-            periodicRate,
-            state.paymentRemaining,
-            managementFeeRate);
-
-        auto const parentCloseTime = env.current()->parentCloseTime();
-        auto const startDateSeconds =
-            static_cast(state.startDate.time_since_epoch().count());
-
-        Number const fullPaymentInterest = computeFullPaymentInterest(
-            rawLoanState.principalOutstanding,
-            periodicRate,
-            parentCloseTime,
-            state.paymentInterval,
-            state.previousPaymentDate,
-            startDateSeconds,
-            closeInterestRateValue);
-
-        Number const roundedFullInterestAmount =
-            roundToAsset(broker.asset, fullPaymentInterest, state.loanScale);
-        Number const roundedFullManagementFee = computeManagementFee(
-            broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale);
-        Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee;
-
-        Number const trackedValueDelta =
-            state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding;
-        Number const untrackedManagementFee =
-            closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding;
-        Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding;
-
-        Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee;
-        BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale));
-
-        auto const overdueSeconds =
-            parentCloseTime.time_since_epoch().count() - state.nextPaymentDate;
-        if (!BEAST_EXPECT(overdueSeconds > 0))
-            return;
-
-        Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds);
-        Number const lateInterestRaw = state.principalOutstanding * overdueRate;
-        Number const lateInterestRounded =
-            roundToAsset(broker.asset, lateInterestRaw, state.loanScale);
-        Number const lateManagementFeeRounded = computeManagementFee(
-            broker.asset, lateInterestRounded, managementFeeRate, state.loanScale);
-        Number const penaltyDue =
-            lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded;
-        BEAST_EXPECT(penaltyDue > Number{});
-
-        auto const balanceBefore = env.balance(borrower, broker.asset).number();
-
-        STAmount const paymentAmount{broker.asset.raw(), baseFullDue};
-        env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment));
-        env.close();
-
-        if (auto const meta = env.meta(); BEAST_EXPECT(meta))
-            BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
-
-        auto const balanceAfter = env.balance(borrower, broker.asset).number();
-        Number const actualPaid = balanceBefore - balanceAfter;
-        BEAST_EXPECT(actualPaid == baseFullDue);
-
-        Number const expectedWithPenalty = baseFullDue + penaltyDue;
-        BEAST_EXPECT(expectedWithPenalty > actualPaid);
-        BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue);
-    }
-
-    void
-    testLoanCoverMinimumRoundingExploit(FeatureBitset features)
-    {
-        auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
-            testcase << "LoanBrokerCoverClawback drains cover via rounding"
-                     << " principalRequested=" << to_string(principalRequest);
-
-            using namespace jtx;
-            using namespace loan;
-            using namespace loanBroker;
-
-            Env env(*this, features);
-
-            Account const issuer{"issuer"};
-            Account const lender{"lender"};
-            Account const borrower{"borrower"};
-
-            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
-            env.close();
-
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const asset = issuer[iouCurrency];
-            env(trust(lender, asset(2'000'0000)));
-            env(trust(borrower, asset(2'000'0000)));
-            env.close();
-
-            env(pay(issuer, lender, asset(2'000'0000)));
-            env.close();
-
-            BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
-            BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
-
-            auto const loanSetFee = Fee(env.current()->fees().base * 2);
-            auto createTx = env.jt(
-                set(borrower, broker.brokerID, principalRequest),
-                Sig(sfCounterpartySignature, lender),
-                loanSetFee,
-                kPaymentInterval(600),
-                kPaymentTotal(1),
-                kGracePeriod(60));
-            env(createTx);
-            env.close();
-
-            auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerBefore);
-            if (!brokerBefore)
-                return;
-
-            Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
-            Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
-
-            BEAST_EXPECT(debtOutstanding > Number{});
-            BEAST_EXPECT(coverAvailableBefore > Number{});
-
-            log << "debt=" << to_string(debtOutstanding)
-                << " cover_available=" << to_string(coverAvailableBefore);
-
-            env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
-            env.close();
-
-            auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
-            BEAST_EXPECT(brokerAfter);
-            if (!brokerAfter)
-                return;
-
-            Number const debtAfter = brokerAfter->at(sfDebtTotal);
-            // the debt has not changed
-            BEAST_EXPECT(debtAfter == debtOutstanding);
-
-            Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
-
-            // since the cover rate min != 0, the cover available should not
-            // be zero
-            BEAST_EXPECT(coverAvailableAfter != Number{});
-        };
-
-        // Call the lambda with different principal values
-        testLoanCoverMinimumRoundingExploit(Number{1, -30});  // 1e-30 units
-        testLoanCoverMinimumRoundingExploit(Number{1, -20});  // 1e-20 units
-        testLoanCoverMinimumRoundingExploit(Number{1, -10});  // 1e-10 units
-        testLoanCoverMinimumRoundingExploit(Number{1, 1});    // 1e-10 units
-    }
-#endif
-
-    void
-    testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
-    {
-        // --- PoC Summary ----------------------------------------------------
-        // Scenario: Borrower makes one periodic payment early (before next due)
-        // so doPayment sets sfPreviousPaymentDueDate to the (future)
-        // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one
-        // interval. Borrower then immediately performs a full-payment
-        // (tfLoanFullPayment). Why it matters: Full-payment interest accrual
-        // uses
-        //   delta = now - max(prevPaymentDate, startDate)
-        // with an unsigned clock representation (uint32). If prevPaymentDate is
-        // in the future, the subtraction underflows to a very large positive
-        // number. This inflates roundedFullInterest and total full-close due,
-        // and LoanPay applies the inflated valueChange to the vault
-        // (sfAssetsTotal), increasing NAV.
-        // --------------------------------------------------------------------
-        testcase("PoC: Unsigned-underflow full-pay accrual after early periodic");
-
-        using namespace jtx;
-        using namespace loan;
-        using namespace std::chrono_literals;
-
-        Env env{*this, features};
-
-        Account const lender{"poc_lender4"};
-        Account const borrower{"poc_borrower4"};
-        env.fund(XRP(3'000'000), lender, borrower);
-        env.close();
-
-        PrettyAsset const asset{xrpIssue(), 1'000'000};
-        BrokerParameters const brokerParams{};
-        auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
-
-        // Create a 3-payment loan so full-payment path is enabled after 1
-        // periodic payment.
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest = asset(1000).value();
-        auto const originationFee = asset(0).value();
-        auto const serviceFee = asset(1).value();
-        auto const serviceFeePA = asset(1);
-        auto const lateFee = asset(0).value();
-        auto const closeFee = asset(0).value();
-        auto const interest = percentageToTenthBips(12);
-        auto const lateInterest = percentageToTenthBips(12) / 10;
-        auto const closeInterest = percentageToTenthBips(12) / 10;
-        auto const overpaymentInterest = percentageToTenthBips(12) / 10;
-        auto const total = 3u;
-        auto const interval = 600u;
-        auto const grace = 60u;
-
-        auto createJtx = env.jt(
-            set(borrower, broker.brokerID, principalRequest, 0),
-            Sig(sfCounterpartySignature, lender),
-            kLoanOriginationFee(originationFee),
-            kLoanServiceFee(serviceFee),
-            kLatePaymentFee(lateFee),
-            kClosePaymentFee(closeFee),
-            kOverpaymentFee(percentageToTenthBips(5) / 10),
-            kInterestRate(interest),
-            kLateInterestRate(lateInterest),
-            kCloseInterestRate(closeInterest),
-            kOverpaymentInterestRate(overpaymentInterest),
-            kPaymentTotal(total),
-            kPaymentInterval(interval),
-            kGracePeriod(grace),
-            Fee(loanSetFee));
-
-        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-        BEAST_EXPECT(brokerSle);
-        auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
-        auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-
-        env(createJtx);
-        env.close();
-
-        // Compute a regular periodic due and pay it early (before next due).
-        auto state = getCurrentState(env, broker, loanKeylet);
-        Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
-        auto const components = xrpl::detail::computePaymentComponents(
-            env.current()->rules(),
-            asset.raw(),
-            state.loanScale,
-            state.totalValue,
-            state.principalOutstanding,
-            state.managementFeeOutstanding,
-            state.periodicPayment,
-            periodicRate,
-            state.paymentRemaining,
-            brokerParams.managementFeeRate);
-        STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()};
-        // now < nextDue immediately after creation, so this is an early pay.
-        env(pay(borrower, loanKeylet.key, regularDue));
-        env.close();
-
-        // Immediately attempt a full payoff. Compute the exact full-payment
-        // due to ensure the tx applies.
-        auto after = getCurrentState(env, broker, loanKeylet);
-        auto const loanSle = env.le(loanKeylet);
-        BEAST_EXPECT(loanSle);
-        auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID));
-        BEAST_EXPECT(brokerSle2);
-
-        auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{};
-        auto const closeInterestRate =
-            loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{};
-        auto const managementFeeRate =
-            brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{};
-
-        Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval);
-        // Accrued + prepayment-penalty interest based on current periodic
-        // schedule
-        auto const fullPaymentInterest = computeFullPaymentInterest(
-            xrpl::detail::loanPrincipalFromPeriodicPayment(
-                env.current()->rules(),
-                after.periodicPayment,
-                periodicRate2,
-                after.paymentRemaining),
-            periodicRate2,
-            env.current()->parentCloseTime(),
-            after.paymentInterval,
-            after.previousPaymentDate,
-            static_cast(after.startDate.time_since_epoch().count()),
-            closeInterestRate);
-
-        // Round to asset scale and split interest/fee parts
-        auto const roundedInterest =
-            roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale);
-        Number const roundedFullMgmtFee =
-            computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale);
-        Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee;
-
-        // Show both signed and unsigned deltas to highlight the underflow.
-        auto const nowSecs =
-            static_cast(env.current()->parentCloseTime().time_since_epoch().count());
-        auto const startSecs =
-            static_cast(after.startDate.time_since_epoch().count());
-        auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs);
-        auto const signedDelta =
-            static_cast(nowSecs) - static_cast(lastPaymentDate);
-        auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate);
-        log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs
-            << " now=" << nowSecs << " signedDelta=" << signedDelta
-            << " unsignedDelta=" << unsignedDelta << std::endl;
-
-        // Reference (clamped) computation: emulate a non-negative accrual
-        // window by clamping prevPaymentDate to 'now' for the full-pay path.
-        auto const prevClamped = std::min(after.previousPaymentDate, nowSecs);
-        auto const fullPaymentInterestClamped = computeFullPaymentInterest(
-            xrpl::detail::loanPrincipalFromPeriodicPayment(
-                env.current()->rules(),
-                after.periodicPayment,
-                periodicRate2,
-                after.paymentRemaining),
-            periodicRate2,
-            env.current()->parentCloseTime(),
-            after.paymentInterval,
-            prevClamped,
-            startSecs,
-            closeInterestRate);
-        auto const roundedInterestClamped =
-            roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale);
-        Number const roundedFullMgmtFeeClamped = computeManagementFee(
-            asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale);
-        Number const roundedFullInterestClamped =
-            roundedInterestClamped - roundedFullMgmtFeeClamped;
-        STAmount const fullDueClamped{
-            asset,
-            after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped +
-                closePaymentFee};
-
-        // Collect vault NAV before closing payment
-        auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{};
-        auto const vaultKey2 = keylet::vault(vaultId2);
-        auto const vaultBefore = env.le(vaultKey2);
-        BEAST_EXPECT(vaultBefore);
-        Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{};
-
-        STAmount const fullDue{
-            asset,
-            after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee +
-                closePaymentFee};
-
-        log << "PoC payoff: principalOutstanding=" << after.principalOutstanding
-            << " roundedFullInterest=" << roundedFullInterest
-            << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee
-            << " fullDue=" << to_string(fullDue.getJson()) << std::endl;
-        log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped
-            << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped
-            << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl;
-
-        env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment));
-        env.close();
-
-        // Sanity: underflow present (unsigned delta very large relative to
-        // interval)
-        BEAST_EXPECT(unsignedDelta > after.paymentInterval);
-
-        // Compare vault NAV before/after the full close
-        auto const vaultAfter = env.le(vaultKey2);
-        BEAST_EXPECT(vaultAfter);
-        if (vaultAfter)
-        {
-            auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal);
-            log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore
-                << " assetsTotalAfter=" << assetsTotalAfter
-                << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl;
-
-            // Value-based proof: underflowed window yields a payoff larger than
-            // the clamped (non-underflow) reference.
-            BEAST_EXPECT(fullDue == fullDueClamped);
-            if (fullDue > fullDueClamped)
-                log << "PoC delta: overcharge (fullDue > clamped)" << std::endl;
-        }
-
-        // Loan should be paid off
-        auto const finalLoan = env.le(loanKeylet);
-        BEAST_EXPECT(finalLoan);
-        if (finalLoan)
-        {
-            BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0);
-            BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0);
-        }
-    }
-
-    void
-    testDustManipulation(FeatureBitset features)
-    {
-        testcase("Dust manipulation");
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env{*this, features};
-
-        // Setup: Create accounts
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-        Account const victim{"victim"};
-
-        env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim);
-        env.close();
-
-        // Step 1: Create vault with IOU asset
-        auto asset = issuer["USD"];
-        env(trust(lender, asset(100000)));
-        env(trust(borrower, asset(100000)));
-        env(trust(victim, asset(100000)));
-        env(pay(issuer, lender, asset(50000)));
-        env(pay(issuer, borrower, asset(50000)));
-        env(pay(issuer, victim, asset(50000)));
-        env.close();
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 10000,
-            .debtMax = Number{0},
-            .coverRateMin = TenthBips32{1000},
-            .coverRateLiquidation = TenthBips32{2500}};
-
-        auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
-
-        auto const loanKeyletOpt = [&]() -> std::optional {
-            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return std::nullopt;
-
-            // Broker has no loans
-            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
-
-            // The loan keylet is based on the LoanSequence of the
-            // _LOAN_BROKER_ object.
-            auto const loanSequence = brokerSle->at(sfLoanSequence);
-            return keylet::loan(broker.brokerID, loanSequence);
-        }();
-        if (!loanKeyletOpt)
-            return;
-
-        auto const& vaultKeylet = broker.vaultKeylet();
-
-        {
-            auto const vaultSle = env.le(vaultKeylet);
-            Number const assetsTotal = vaultSle->at(sfAssetsTotal);
-            Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
-
-            log << "Before loan creation:" << std::endl;
-            log << "  AssetsTotal: " << assetsTotal << std::endl;
-            log << "  AssetsAvailable: " << assetsAvail << std::endl;
-            log << "  Difference: " << (assetsTotal - assetsAvail) << std::endl;
-
-            // before the loan the assets total and available should be equal
-            BEAST_EXPECT(assetsAvail == assetsTotal);
-            BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number());
-        }
-
-        Keylet const& loanKeylet = *loanKeyletOpt;
-
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = Number{100},
-            .interest = TenthBips32{1922},
-            .payTotal = 5816,
-            .payInterval = 86400 * 6,
-            .gracePd = 86400 * 5,
-        };
-
-        env(loanParams(env, broker));
-        env.close();
-
-        // Wait for loan to be late enough to default
-        env.close(std::chrono::seconds(86400 * 40));  // 40 days
-
-        {
-            auto const vaultSle = env.le(vaultKeylet);
-            Number const assetsTotal = vaultSle->at(sfAssetsTotal);
-            Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
-
-            log << "After loan creation:" << std::endl;
-            log << "  AssetsTotal: " << assetsTotal << std::endl;
-            log << "  AssetsAvailable: " << assetsAvail << std::endl;
-            log << "  Difference: " << (assetsTotal - assetsAvail) << std::endl;
-
-            auto const loanSle = env.le(loanKeylet);
-            if (!BEAST_EXPECT(loanSle))
-                return;
-            auto const state = constructLoanState(loanSle);
-
-            log << "Loan state:" << std::endl;
-            log << "  ValueOutstanding: " << state.valueOutstanding << std::endl;
-            log << "  PrincipalOutstanding: " << state.principalOutstanding << std::endl;
-            log << "  InterestOutstanding: " << state.interestOutstanding() << std::endl;
-            log << "  InterestDue: " << state.interestDue << std::endl;
-            log << "  FeeDue: " << state.managementFeeDue << std::endl;
-
-            // after loan creation the assets total and available should
-            // reflect the value of the loan
-            BEAST_EXPECT(assetsAvail < assetsTotal);
-            BEAST_EXPECT(
-                assetsAvail ==
-                broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number());
-            BEAST_EXPECT(
-                assetsTotal ==
-                broker.asset(brokerParams.vaultDeposit + state.interestDue).number());
-        }
-
-        // Step 7: Trigger default (dust adjustment will occur)
-        env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault));
-        env.close();
-
-        // Step 8: Verify phantom assets created
-        {
-            auto const vaultSle2 = env.le(vaultKeylet);
-            Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal);
-            Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable);
-
-            log << "After default:" << std::endl;
-            log << "  AssetsTotal: " << assetsTotal2 << std::endl;
-            log << "  AssetsAvailable: " << assetsAvail2 << std::endl;
-            log << "  Difference: " << (assetsTotal2 - assetsAvail2) << std::endl;
-
-            // after a default the assets total and available should be equal
-            BEAST_EXPECT(assetsAvail2 == assetsTotal2);
-        }
-    }
-
-    void
-    testRIPD3831(FeatureBitset features)
-    {
-        using namespace jtx;
-
-        testcase("RIPD-3831");
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 100000,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            // .managementFeeRate = TenthBips16{5919},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = Number{200'000, -6},
-            .lateFee = Number{200, -6},
-            .interest = TenthBips32{50'000},
-            .payTotal = 10,
-            .payInterval = 150};
-
-        auto const assetType = AssetType::XRP;
-
-        Env env{*this, features};
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
-
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-
-        using tp = NetClock::time_point;
-        using d = NetClock::duration;
-
-        auto state = getCurrentState(env, broker, loanKeylet);
-        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
-        {
-            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
-        }
-
-        topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
-
-        using namespace jtx::loan;
-
-        auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue)));
-
-        {
-            auto const submitParam = to_string(jv);
-            auto const jr = env.rpc("submit", borrower.name(), submitParam);
-
-            BEAST_EXPECT(jr.isMember(jss::result));
-            auto const jResult = jr[jss::result];
-        }
-
-        env.close();
-
-        // Make sure the system keeps responding
-        env(noop(borrower));
-        env.close();
-        env(noop(issuer));
-        env.close();
-        env(noop(lender));
-        env.close();
-    }
-
-    void
-    testRIPD3459(FeatureBitset features)
-    {
-        testcase("RIPD-3459 - LoanBroker incorrect debt total");
-
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 200'000,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = TenthBips16{500},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = Number{100'000, -4},
-            .interest = TenthBips32{100'000},
-            .payTotal = 10};
-
-        auto const assetType = AssetType::MPT;
-
-        Env env{*this, features};
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
-
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-        auto pseudoAcct = std::get(*loanResult);
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
-
-        if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
-        {
-            if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
-            {
-                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
-            }
-        }
-
-        makeLoanPayments(
-            env,
-            broker,
-            loanParams,
-            loanKeylet,
-            verifyLoanStatus,
-            issuer,
-            lender,
-            borrower,
-            PaymentParameters{.showStepBalances = true});
-
-        if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
-        {
-            if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
-            {
-                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
-                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero);
-            }
-        }
-    }
-
-    void
-    testRIPD3901()
-    {
-        testcase("Crash with tfLoanOverpayment");
-        using namespace jtx;
-        using namespace loan;
-        Account const lender{"lender"};
-        Account const issuer{"issuer"};
-        Account const borrower{"borrower"};
-        Account const depositor{"depositor"};
-        auto const txFee = Fee(XRP(100));
-
-        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()});
-        env(tx, txFee);
-        env.close();
-
-        env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}),
-            txFee);
-        env.close();
-
-        auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender));
-
-        env(loanBroker::set(lender, vaultKeyLet.key), txFee);
-        env.close();
-
-        // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}};
-
-        STAmount const debtMaximumRequest = XRPAmount(200'000);
-
-        env(set(borrower, brokerKeyLet.key, debtMaximumRequest),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(TenthBips32(50'000)),
-            kPaymentTotal(2),
-            kPaymentInterval(150),
-            Txflags(tfLoanOverpayment),
-            txFee);
-        env.close();
-
-        std::uint32_t const loanSequence = 1;
-        auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence);
-
-        if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
-        {
-            env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)),
-                Txflags(tfLoanOverpayment),
-                txFee);
-            env.close();
-        }
-    }
-
-    void
-    testRoundingAllowsUndercoverage(FeatureBitset features)
-    {
-        testcase("Minimum cover rounding allows undercoverage (XRP)");
-
-        using namespace jtx;
-        using namespace loanBroker;
-
-        Env env{*this, features};
-
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(200'000), lender, borrower);
-        env.close();
-
-        // Vault with XRP asset
-        Vault const vault{env};
-        auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
-        env(vaultCreate);
-        env.close();
-        BEAST_EXPECT(env.le(vaultKeylet));
-
-        // Seed the vault with XRP so it can fund the loan principal
-        PrettyAsset const xrpAsset{xrpIssue(), 1};
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 1'000,
-            .debtMax = Number{0},
-            .coverRateMin = TenthBips32{10'000},
-            .coverDeposit = 82,
-        };
-
-        auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams);
-        // Create a loan with principal 804 XRP and 0% interest (so
-        // DebtTotal increases by exactly 804)
-        env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
-            loan::kInterestRate(TenthBips32(0)),
-            Sig(sfCounterpartySignature, lender),
-            Fee(env.current()->fees().base * 2));
-        BEAST_EXPECT(env.ter() == tesSUCCESS);
-        env.close();
-
-        // Verify DebtTotal is exactly 804
-        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            log << *brokerSle << std::endl;
-            BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
-        }
-
-        // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
-        // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
-        env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
-            Ter(tecINSUFFICIENT_FUNDS));
-        BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
-        env.close();
-
-        // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
-        // because that leaves sufficient cover, this succeeds
-        env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
-        BEAST_EXPECT(env.ter() == tesSUCCESS);
-        env.close();
-
-        // Validate CoverAvailable == 80 XRP and DebtTotal remains 804
-        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            log << *brokerSle << std::endl;
-            BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
-            BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
-
-            // Also demonstrate that the true minimum (804 * 10%) exceeds 80
-            auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000));
-            log << "Theoretical min cover: " << theoreticalMin << std::endl;
-            BEAST_EXPECT(Number(804, -1) == theoreticalMin);
-        }
-    }
-
-    void
-    testRIPD3902(FeatureBitset features)
-    {
-        testcase("RIPD-3902 - 1 IOU loan payments");
-
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 10,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = Number{1, 0},
-            .interest = TenthBips32{100'000},
-            .payTotal = 5,
-            .payInterval = 150,
-            .gracePd = 60};
-
-        auto const assetType = AssetType::IOU;
-
-        Env env{*this, features};
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
-
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-        auto pseudoAcct = std::get(*loanResult);
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
-
-        makeLoanPayments(
-            env,
-            broker,
-            loanParams,
-            loanKeylet,
-            verifyLoanStatus,
-            issuer,
-            lender,
-            borrower,
-            PaymentParameters{.showStepBalances = true});
-    }
-
-    void
-    testBorrowerIsBroker()
-    {
-        testcase("Test Borrower is Broker");
-        using namespace jtx;
-        using namespace loan;
-        Account const broker{"broker"};
-        Account const issuer{"issuer"};
-        Account const borrower{"borrower"};
-        Account const depositor{"depositor"};
-
-        auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) {
-            Env env(*this);
-            Vault const vault(env);
-
-            if (borrower == broker)
-            {
-                env.fund(XRP(10'000), broker, issuer, depositor);
-            }
-            else
-            {
-                env.fund(XRP(10'000), broker, borrower, issuer, depositor);
-            }
-            env.close();
-
-            auto const xrpFee = XRP(100);
-            auto const txFee = Fee(xrpFee);
-
-            STAmount const debtMaximumRequest = getMaxDebt(env);
-
-            auto const& asset = debtMaximumRequest.asset();
-            auto const initialVault = asset(debtMaximumRequest * 100);
-
-            auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
-            env(tx, txFee);
-            env.close();
-
-            env(vault.deposit(
-                    {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}),
-                txFee);
-            env.close();
-
-            auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker));
-
-            env(loanBroker::set(broker, vaultKeylet.key), txFee);
-            env.close();
-
-            auto const serviceFee = 101;
-
-            env(set(broker, brokerKeylet.key, debtMaximumRequest),
-                kCounterparty(borrower),
-                Sig(sfCounterpartySignature, borrower),
-                kLoanServiceFee(serviceFee),
-                kPaymentTotal(10),
-                txFee);
-            env.close();
-
-            std::uint32_t const loanSequence = 1;
-            auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
-
-            auto const brokerBalanceBefore = env.balance(broker, asset);
-
-            if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle))
-            {
-                auto const payment = loanSle->at(sfPeriodicPayment);
-                auto const totalPayment = payment + serviceFee;
-                env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee);
-                env.close();
-                if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
-                {
-                    auto const expected = [&]() {
-                        // The service fee is transferred to the broker if
-                        // a borrower is not the broker
-                        if (borrower != broker)
-                            return brokerBalanceBefore.number() + serviceFee;
-                        // Since a borrower is the broker, the payment is
-                        // transferred to the Vault from the broker but not
-                        // the service fee.
-                        // If the asset is XRP then the broker pays the txFee.
-                        if (asset.native())
-                            return brokerBalanceBefore.number() - payment - xrpFee.number();
-                        return brokerBalanceBefore.number() - payment;
-                    }();
-                    BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value());
-                }
-            }
-        };
-        // Test when a borrower is the broker and is not to verify correct
-        // service fee transfer in both cases.
-        for (auto const& borrowerAcct : {broker, borrower})
-        {
-            testLoanAsset(
-                [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct);
-            testLoanAsset(
-                [&](Env& env) -> STAmount {
-                    auto const iou = issuer["USD"];
-                    env(trust(broker, iou(1'000'000'000)));
-                    env(trust(depositor, iou(1'000'000'000)));
-                    env(pay(issuer, broker, iou(100'000'000)));
-                    env(pay(issuer, depositor, iou(100'000'000)));
-                    env.close();
-                    return iou(200'000);
-                },
-                borrowerAcct);
-            testLoanAsset(
-                [&](Env& env) -> STAmount {
-                    MPTTester const mpt(
-                        {.env = env,
-                         .issuer = issuer,
-                         .holders = {broker, depositor},
-                         .pay = 100'000'000});
-                    return mpt(200'000);
-                },
-                borrowerAcct);
-        }
-    }
-
-    void
-    testIssuerIsBorrower(FeatureBitset features)
-    {
-        testcase("RIPD-4096 - Issuer as borrower");
-
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 100'000,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = lender, .counter = issuer, .principalRequest = Number{10000}};
-
-        auto const assetType = AssetType::IOU;
-
-        Env env{*this, features};
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
-
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-        auto pseudoAcct = std::get(*loanResult);
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
-
-        makeLoanPayments(
-            env,
-            broker,
-            loanParams,
-            loanKeylet,
-            verifyLoanStatus,
-            issuer,
-            lender,
-            issuer,
-            PaymentParameters{.showStepBalances = true});
-    }
-
-    void
-    testLimitExceeded()
-    {
-        testcase("RIPD-4125 - overpayment");
-
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 100'000,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = Number{200000, -6},
-            .interest = TenthBips32{50000},
-            .payTotal = 3,
-            .payInterval = 200,
-            .gracePd = 60,
-            .flags = tfLoanOverpayment,
-        };
-
-        auto const assetType = AssetType::XRP;
-
-        Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning);
-
-        auto loanResult =
-            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
-
-        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
-            return;
-
-        auto broker = std::get(*loanResult);
-        auto loanKeylet = std::get(*loanResult);
-        auto pseudoAcct = std::get(*loanResult);
-
-        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
-
-        auto const state = getCurrentState(env, broker, loanKeylet);
-
-        env(loan::pay(
-            borrower,
-            loanKeylet.key,
-            STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1},
-            tfLoanOverpayment));
-        env.close();
-
-        PaymentParameters const paymentParams{
-            .showStepBalances = false,
-            .validateBalances = true,
-        };
-
-        makeLoanPayments(
-            env,
-            broker,
-            loanParams,
-            loanKeylet,
-            verifyLoanStatus,
-            issuer,
-            lender,
-            borrower,
-            paymentParams);
-    }
-
-    void
-    testOverpaymentManagementFee(FeatureBitset features)
-    {
-        testcase("testOverpaymentManagementFee");
-
-        using namespace jtx;
-        using namespace loan;
-
-        Env env{*this, features};
-
-        Account const lender{"lender"}, borrower{"borrower"};
-
-        env.fund(XRP(10'000'000), lender, borrower);
-        env.close();
-
-        PrettyAsset const asset{xrpIssue(), 1000};
-
-        auto const result = createVaultAndBroker(
-            env,
-            asset,
-            lender,
-            {
-                .vaultDeposit = asset(100'000).value(),
-                .managementFeeRate = TenthBips16(10'000),
-            });
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-
-        auto const loanKeylet = keylet::loan(
-            result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence));
-        env(loan::set(
-                borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment),
-            Sig(sfCounterpartySignature, lender),
-            loan::kPaymentInterval(86400 * 30),
-            loan::kPaymentTotal(3),
-            loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
-            loanSetFee);
-
-        // From calculator
-        auto const expectedOverpaymentManagementFee = Number{33333, 0};
-        auto const loanBrokerBalanceBefore = env.balance(lender);
-
-        auto const loanPayFee = Fee(env.current()->fees().base * 2);
-        env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee);
-        env.close();
-
-        BEAST_EXPECTS(
-            env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee,
-            "overpayment management fee missmatch; expected:" +
-                to_string(expectedOverpaymentManagementFee) +
-                " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore));
-    }
-
-    void
-    testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
-    {
-        testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
-        using namespace jtx;
-        using namespace loan;
-        Account const issuer("issuer");
-        Account const borrower("borrower");
-        Account const broker("broker");
-        auto const iou = issuer["IOU"];
-        Env env(*this, features);
-        env.fund(XRP(20'000), issuer, broker, borrower);
-        env.close();
-        // Set up trustlines and fund accounts
-        env(trust(broker, iou(20'000'000)));
-        env(trust(borrower, iou(20'000'000)));
-        env(pay(issuer, broker, iou(10'000'000)));
-        env(pay(issuer, borrower, iou(1'000)));
-        env.close();
-        // Create vault and broker
-        auto const brokerInfo = createVaultAndBroker(env, iou, broker);
-        // Create a loan first (this creates debt)
-        auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
-        env(set(borrower, brokerInfo.brokerID, 10'000),
-            Sig(sfCounterpartySignature, broker),
-            kLoanServiceFee(iou(100).value()),
-            kPaymentInterval(100),
-            Fee(XRP(100)));
-        env.close();
-        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
-        // We need coverAvailable >= (debtTotal * coverRateMinimum)
-        // Deposit enough cover to ensure the fee goes to broker owner
-        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
-        // at least 1,000 cover. Default cover is 1,000, so we add more to be
-        // safe.
-        auto const additionalCover = iou(50'000).value();
-        env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover}));
-        env.close();
-        // Verify broker owner has a trustline
-        auto const brokerTrustline = keylet::trustLine(broker, iou);
-        BEAST_EXPECT(env.le(brokerTrustline) != nullptr);
-        // Broker owner deletes their trustline
-        // First, pay any positive balance to issuer to zero it out
-        auto const brokerBalance = env.balance(broker, iou);
-        env(pay(broker, issuer, brokerBalance));
-        env.close();
-        // Remove the trustline by setting limit to 0
-        env(trust(broker, iou(0)));
-        env.close();
-        // Verify trustline is deleted
-        BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
-        // Now borrower tries to make a payment
-        // We should get a tesSUCCESS instead of a tecNO_LINE.
-        env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
-        env.close();
-        // Verify trustline is still deleted
-        BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
-        // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
-            auto const balance = env.balance(pseudo, iou);
-            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
-            BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance)));
-        }
-    }
-
-    void
-    testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
-    {
-        testcase << "LoanPay Broker Owner MPT unauthorized";
-        using namespace jtx;
-        using namespace loan;
-
-        Account const issuer("issuer");
-        Account const borrower("borrower");
-        Account const broker("broker");
-
-        Env env{*this, features};
-        env.fund(XRP(20'000), issuer, broker, borrower);
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-
-        PrettyAsset const mpt{mptt.issuanceID()};
-
-        // Authorize broker and borrower
-        mptt.authorize({.account = broker});
-        mptt.authorize({.account = borrower});
-
-        env.close();
-
-        // Fund accounts
-        env(pay(issuer, broker, mpt(10'000'000)));
-        env(pay(issuer, borrower, mpt(1'000)));
-        env.close();
-
-        // Create vault and broker
-        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
-        // Create a loan first (this creates debt)
-        auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
-        env(set(borrower, brokerInfo.brokerID, 10'000),
-            Sig(sfCounterpartySignature, broker),
-            kLoanServiceFee(mpt(100).value()),
-            kPaymentInterval(100),
-            Fee(XRP(100)));
-        env.close();
-        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
-        // We need coverAvailable >= (debtTotal * coverRateMinimum)
-        // Deposit enough cover to ensure the fee goes to broker owner
-        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
-        // at least 1,000 cover. Default cover is 1,000, so we add more to be
-        // safe.
-        auto const additionalCover = mpt(50'000).value();
-        env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
-        env.close();
-        // Verify broker owner is authorized
-        auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
-        BEAST_EXPECT(env.le(brokerMpt) != nullptr);
-        // Broker owner unauthorizes.
-        // First, pay any positive balance to issuer to zero it out
-        auto const brokerBalance = env.balance(broker, mpt);
-        env(pay(broker, issuer, brokerBalance));
-        env.close();
-        // Then, unauthorize the MPT.
-        mptt.authorize({.account = broker, .flags = tfMPTUnauthorize});
-        env.close();
-        // Verify the MPT is unauthorized.
-        BEAST_EXPECT(env.le(brokerMpt) == nullptr);
-        // Now borrower tries to make a payment
-        // We should get a tesSUCCESS instead of a tecNO_AUTH.
-        auto const borrowerBalance = env.balance(borrower, mpt);
-        env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
-        env.close();
-        // Verify the MPT is still unauthorized.
-        BEAST_EXPECT(env.le(brokerMpt) == nullptr);
-        // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
-            auto const balance = env.balance(pseudo, mpt);
-            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
-            BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
-        }
-    }
-
-    void
-    testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
-    {
-        testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
-        using namespace jtx;
-        using namespace loan;
-
-        Account const issuer("issuer");
-        Account const borrower("borrower");
-        Account const broker("broker");
-
-        Env env{*this, features};
-        env.fund(XRP(20'000), issuer, broker, borrower);
-        env.close();
-
-        auto credType = "credential1";
-
-        pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}};
-        env(pdomain::setTx(issuer, credentials1));
-        env.close();
-
-        auto domainID = pdomain::getNewDomain(env.meta());
-
-        env(credentials::create(broker, issuer, credType));
-        env(credentials::accept(broker, issuer, credType));
-        env.close();
-
-        env(credentials::create(borrower, issuer, credType));
-        env(credentials::accept(borrower, issuer, credType));
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({
-            .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
-            .domainID = domainID,
-        });
-
-        PrettyAsset const mpt{mptt.issuanceID()};
-
-        // Authorize broker and borrower
-        mptt.authorize({.account = broker});
-        mptt.authorize({.account = borrower});
-
-        env.close();
-
-        // Fund accounts
-        env(pay(issuer, broker, mpt(10'000'000)));
-        env(pay(issuer, borrower, mpt(1'000)));
-        env.close();
-
-        // Create vault and broker
-        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
-        // Create a loan first (this creates debt)
-        auto const keylet = keylet::loan(brokerInfo.brokerID, 1);
-        env(set(borrower, brokerInfo.brokerID, 10'000),
-            Sig(sfCounterpartySignature, broker),
-            kLoanServiceFee(mpt(100).value()),
-            kPaymentInterval(100),
-            Fee(XRP(100)));
-        env.close();
-        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
-        // We need coverAvailable >= (debtTotal * coverRateMinimum)
-        // Deposit enough cover to ensure the fee goes to broker owner
-        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
-        // at least 1,000 cover. Default cover is 1,000, so we add more to be
-        // safe.
-        auto const additionalCover = mpt(50'000).value();
-        env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
-        env.close();
-        // Verify broker owner is authorized
-        auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
-        BEAST_EXPECT(env.le(brokerMpt) != nullptr);
-        // Remove the credentials for the Broker owner.
-        // First, pay any positive balance to issuer to zero it out
-        auto const brokerBalance = env.balance(broker, mpt);
-        env(pay(broker, issuer, brokerBalance));
-        env.close();
-
-        env(credentials::deleteCred(broker, broker, issuer, credType));
-        env.close();
-
-        // Make sure the broker is not authorized to hold the MPT after we
-        // deleted the credentials
-        env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
-
-        // Now borrower tries to make a payment
-        // We should get a tesSUCCESS instead of a tecNO_AUTH.
-        auto const borrowerBalance = env.balance(borrower, mpt);
-        env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
-        env.close();
-        // Verify broker is still not authorized
-        env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
-        // Verify the service fee went to the broker pseudo-account
-        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            BEAST_EXPECT(brokerSle))
-        {
-            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
-            auto const balance = env.balance(pseudo, mpt);
-            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
-            BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
-        }
-    }
-
-    void
-    testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
-    {
-        testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
-        using namespace jtx;
-        using namespace loan;
-
-        Account const issuer("issuer");
-        Account const borrower("borrower");
-        Account const broker("broker");
-
-        Env env{*this, features};
-        env.fund(XRP(20'000), issuer, broker, borrower);
-        env.close();
-
-        auto credType = "credential1";
-
-        pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}};
-        env(pdomain::setTx(issuer, credentials1));
-        env.close();
-
-        auto domainID = pdomain::getNewDomain(env.meta());
-
-        // Add credentials for the broker and borrower
-        env(credentials::create(broker, issuer, credType));
-        env(credentials::accept(broker, issuer, credType));
-        env.close();
-
-        env(credentials::create(borrower, issuer, credType));
-        env(credentials::accept(borrower, issuer, credType));
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({
-            .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
-            .domainID = domainID,
-        });
-
-        PrettyAsset const mpt{mptt.issuanceID()};
-
-        // Authorize broker and borrower
-        mptt.authorize({.account = broker});
-        mptt.authorize({.account = borrower});
-        env.close();
-
-        // Fund accounts
-        env(pay(issuer, broker, mpt(10'000'000)));
-        env(pay(issuer, borrower, mpt(1'000)));
-        env.close();
-
-        // Create vault and broker
-        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
-
-        // Remove the credentials for the Broker owner.
-        // Clear the balance first.
-        auto const brokerBalance = env.balance(broker, mpt);
-        env(pay(broker, issuer, brokerBalance));
-        env.close();
-        // Delete the credentials
-        env(credentials::deleteCred(broker, broker, issuer, credType));
-        env.close();
-
-        // Create a loan, this should fail for tecNO_AUTH
-        env(set(borrower, brokerInfo.brokerID, 10'000),
-            Sig(sfCounterpartySignature, broker),
-            kLoanServiceFee(mpt(100).value()),
-            kPaymentInterval(100),
-            Fee(XRP(100)),
-            Ter(tecNO_AUTH));
-        env.close();
-    }
-
-    void
-    testSequentialFLCDepletion(FeatureBitset features)
-    {
-        testcase << "First-Loss Capital Depletion on Sequential Defaults";
-
-        using namespace jtx;
-        using namespace loan;
-        using namespace loanBroker;
-
-        Env env{*this, features};
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrowerA{"borrowerA"};
-        Account const borrowerB{"borrowerB"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB);
-        env.close();
-
-        PrettyAsset const asset = xrpIssue();
-        auto const vaultDepositAmount =
-            asset(200'000);  // Enough for 2 x 50k loans plus interest/fees
-
-        auto const brokerInfo = createVaultAndBroker(
-            env,
-            asset,
-            lender,
-            {
-                .vaultDeposit = vaultDepositAmount.value(),
-                .debtMax = 0,
-                .coverRateMin = TenthBips32(20000),  // 20%
-                .coverDeposit = 21'000,
-                .managementFeeRate = TenthBips16(100),  // 0.1%
-                .coverRateLiquidation = TenthBips32(100000),
-            });
-        auto const brokerKeylet = brokerInfo.brokerKeylet();
-
-        // Create two identical loans: each 50,000 XRP principal (scaled down to
-        // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal
-        // + interest) Formula will calculate cover as: 100% × (20% × 100,000) =
-        // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first
-        // default
-        auto const principalAmount = Number(50'000);
-        auto const loanPaymentInterval = 2592000;  // 30 days
-        auto const loanGracePeriod = 604800;       // 7 days
-
-        // Create Loan A
-        auto loanATx = env.jt(
-            set(borrowerA, brokerKeylet.key, principalAmount),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(TenthBips32(500)),  // 5%
-            kPaymentTotal(12),
-            loan::kPaymentInterval(loanPaymentInterval),
-            loan::kGracePeriod(loanGracePeriod),
-            Fee(XRP(10)));  // Sufficient fee for multi-sig transaction
-        env(loanATx);
-        env.close();
-
-        auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1);
-
-        // Create Loan B
-        auto loanBTx = env.jt(
-            set(borrowerB, brokerKeylet.key, principalAmount),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(TenthBips32(500)),  // 5%
-            kPaymentTotal(12),
-            loan::kPaymentInterval(loanPaymentInterval),
-            loan::kGracePeriod(loanGracePeriod),
-            Fee(XRP(10)));  // Sufficient fee for multi-sig transaction
-        env(loanBTx);
-        env.close();
-
-        auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2);
-
-        auto loanASle = env.le(loanAKeylet);
-        if (!BEAST_EXPECT(loanASle))
-            return;
-
-        // Advance time past grace period for both loans to be defaultable
-        auto const loanANextDue = loanASle->at(sfNextPaymentDueDate);
-        auto const loanAGrace = loanASle->at(sfGracePeriod);
-        env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60});
-
-        env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
-        env.close();
-
-        // Verify Loan A is defaulted
-        loanASle = env.le(loanAKeylet);
-        if (!BEAST_EXPECT(loanASle))
-            return;
-        BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault));
-        BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0);
-
-        // Check broker state after first default (from committed ledger)
-        auto brokerSle = env.le(brokerKeylet);
-        if (!BEAST_EXPECT(brokerSle))
-            return;
-        auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
-        auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
-
-        // DebtTotal should have decreased by Loan A's debt
-        BEAST_EXPECT(afterFirstDebtTotal == 50'134);
-
-        // CoverAvailable should have decreased significantly
-        BEAST_EXPECT(afterFirstCoverAvailable == 946);
-
-        env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
-
-        brokerSle = env.le(brokerKeylet);
-        if (!BEAST_EXPECT(brokerSle))
-            return;
-        auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal);
-        auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable);
-
-        BEAST_EXPECT(afterSecondDebtTotal == 0);
-
-        BEAST_EXPECT(afterSecondCoverAvailable == 0);
-    }
-
-    void
-    testYieldTheftRounding(std::uint32_t flags)
-    {
-        testcase("Rounding manipulation does not permit yield theft");
-        using namespace jtx;
-        using namespace loan;
-
-        // 1. Setup Environment
-        Env env(*this, all_);
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1000), issuer, lender, borrower);
-        env.close();
-
-        // 2. Asset Selection
-        PrettyAsset const iou = issuer["USD"];
-        env(trust(lender, iou(100'000'000)));
-        env(trust(borrower, iou(100'000'000)));
-        env(pay(issuer, lender, iou(100'000'000)));
-        env(pay(issuer, borrower, iou(100'000'000)));
-        env.close();
-
-        // 3. Create Vault and Broker with High Debt Limit (100M)
-        auto const brokerInfo = createVaultAndBroker(
-            env,
-            iou,
-            lender,
-            {
-                .vaultDeposit = 5'000'000,
-                .debtMax = Number{100'000'000},
-                .coverDeposit = 500'000,
-            });
-        auto const [currentSeq, vaultKeylet] = [&]() {
-            auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
-            if (!BEAST_EXPECT(brokerSle))
-                return std::make_tuple(0u, keylet::unchecked(beast::kZero));
-            auto const currentSeq = brokerSle->at(sfLoanSequence);
-            auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
-            return std::make_tuple(currentSeq, vaultKeylet);
-        }();
-
-        // 4. Loan Parameters (Attack Vector)
-        Number const principal = 1'000'000;
-        TenthBips32 const interestRate = TenthBips32{1};  // 0.001%
-        std::uint32_t const paymentInterval = 86400;
-        std::uint32_t const paymentTotal = 3650;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags),
-            Sig(sfCounterpartySignature, lender),
-            loan::kInterestRate(interestRate),
-            loan::kPaymentInterval(paymentInterval),
-            loan::kPaymentTotal(paymentTotal),
-            Fee(loanSetFee));
-        env.close();
-
-        // --- RETRIEVE OBJECTS & SETUP ATTACK ---
-
-        auto borrowerBalance = [&]() { return env.balance(borrower, iou); };
-        auto const borrowerScale = static_cast(borrowerBalance()).exponent();
-
-        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq);
-        auto const maybePeriodicPayment = [&]() -> std::optional {
-            auto const loanSle = env.le(loanKeylet);
-            if (!BEAST_EXPECT(loanSle))
-                return std::nullopt;
-            // Construct Payment
-            return STAmount{iou, loanSle->at(sfPeriodicPayment)};
-        }();
-        if (!maybePeriodicPayment)
-            return;
-        auto const periodicPayment = *maybePeriodicPayment;
-        auto const roundedPayment =
-            roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward);
-
-        // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution
-        STAmount const paymentBuffer{iou, Number(1, -9)};
-        STAmount const attackPayment = periodicPayment + paymentBuffer;
-
-        auto const maybeInitialVaultAssets = [&]() -> std::optional {
-            auto const vault = env.le(vaultKeylet);
-            if (!BEAST_EXPECT(vault))
-                return std::nullopt;
-            return vault->at(sfAssetsTotal);
-        }();
-        if (!maybeInitialVaultAssets)
-            return;
-        auto const initialVaultAssets = *maybeInitialVaultAssets;
-
-        // 5. Execution Loop
-        int yieldTheftCount = 0;
-        auto previousAssetsTotal = initialVaultAssets;
-
-        for (int i = 0; i < 100; ++i)
-        {
-            auto const balanceBefore = borrowerBalance();
-            env(pay(borrower, loanKeylet.key, attackPayment, flags));
-            env.close();
-            auto const borrowerDelta = balanceBefore - borrowerBalance();
-            BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum());
-
-            auto const loanSle = env.le(loanKeylet);
-            if (!BEAST_EXPECT(loanSle))
-                break;
-            auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)};
-            BEAST_EXPECT(
-                (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) ==
-                 roundedPayment));
-            BEAST_EXPECT(
-                (updatedPayment == periodicPayment) ||
-                (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment));
-
-            auto const currentVaultSle = env.le(vaultKeylet);
-            if (!BEAST_EXPECT(currentVaultSle))
-                break;
-
-            auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal);
-            auto const delta = currentAssetsTotal - previousAssetsTotal;
-
-            BEAST_EXPECT(
-                (delta == beast::kZero && borrowerDelta <= roundedPayment) ||
-                (delta > beast::kZero && borrowerDelta > roundedPayment));
-
-            // If tx succeeded but Assets Total didn't change, interest was
-            // stolen.
-            if (delta == beast::kZero && borrowerDelta > roundedPayment)
-            {
-                yieldTheftCount++;
-            }
-
-            previousAssetsTotal = currentAssetsTotal;
-        }
-
-        BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount));
-    }
-
-    // Tests that vault withdrawals work correctly when the vault has unrealized
-    // loss from an impaired loan, ensuring the invariant check properly
-    // accounts for the loss.
-    void
-    testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
-    {
-        using namespace jtx;
-        using namespace loan;
-        using namespace std::chrono_literals;
-
-        testcase("Vault withdraw reflects sfLossUnrealized");
-
-        // Test constants
-        static constexpr std::int64_t kInitialFunding = 1'000'000;
-        static constexpr std::int64_t kLenderInitialIou = 5'000'000;
-        static constexpr std::int64_t kDepositorInitialIou = 1'000'000;
-        static constexpr std::int64_t kBorrowerInitialIou = 100'000;
-        static constexpr std::int64_t kDepositAmount = 5'000;
-        static constexpr std::int64_t kPrincipalAmount = 99;
-        static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000;
-        static constexpr std::uint32_t kLocalPaymentInterval = 600;
-        static constexpr std::uint32_t kLocalPaymentTotal = 2;
-
-        Env env{*this, features};
-
-        // Setup accounts
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const depositorA{"lpA"};
-        Account const depositorB{"lpB"};
-        Account const borrower{"borrowerA"};
-
-        env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower);
-        env.close();
-
-        // Setup trust lines
-        PrettyAsset const iouAsset = issuer[iouCurrency_];
-        env(trust(lender, iouAsset(10'000'000)));
-        env(trust(depositorA, iouAsset(10'000'000)));
-        env(trust(depositorB, iouAsset(10'000'000)));
-        env(trust(borrower, iouAsset(10'000'000)));
-        env.close();
-
-        // Fund accounts with IOUs
-        env(pay(issuer, lender, iouAsset(kLenderInitialIou)));
-        env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou)));
-        env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou)));
-        env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou)));
-        env.close();
-
-        // Create vault and broker, then add deposits from two depositors
-        auto const broker = createVaultAndBroker(env, iouAsset, lender);
-        Vault v{env};
-
-        env(v.deposit({
-                .depositor = depositorA,
-                .id = broker.vaultKeylet().key,
-                .amount = iouAsset(kDepositAmount),
-            }),
-            Ter(tesSUCCESS));
-        env(v.deposit({
-                .depositor = depositorB,
-                .id = broker.vaultKeylet().key,
-                .amount = iouAsset(kDepositAmount),
-            }),
-            Ter(tesSUCCESS));
-        env.close();
-
-        // Create a loan
-        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
-        if (!BEAST_EXPECT(sleBroker))
-            return;
-
-        auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
-
-        env(set(borrower, broker.brokerID, kPrincipalAmount),
-            Sig(sfCounterpartySignature, lender),
-            kPaymentTotal(kLocalPaymentTotal),
-            kPaymentInterval(kLocalPaymentInterval),
-            Fee(env.current()->fees().base * 2),
-            Ter(tesSUCCESS));
-        env.close();
-
-        // Impair the loan to create unrealized loss
-        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
-        env.close();
-
-        // Verify unrealized loss is recorded in the vault
-        auto const vaultAfterImpair = env.le(broker.vaultKeylet());
-        if (!BEAST_EXPECT(vaultAfterImpair))
-            return;
-
-        BEAST_EXPECT(
-            vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value());
-
-        // Helper to get share balance for a depositor
-        auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
-        auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
-            auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
-            return token ? token->getFieldU64(sfMPTAmount) : 0;
-        };
-
-        // Verify both depositors have equal shares
-        auto const sharesLpA = getShareBalance(depositorA);
-        auto const sharesLpB = getShareBalance(depositorB);
-        BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor);
-        BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor);
-        BEAST_EXPECT(sharesLpA == sharesLpB);
-
-        // Helper to attempt withdrawal
-        auto const attemptWithdrawShares = [&](Account const& depositor,
-                                               std::uint64_t shareAmount,
-                                               TER expected) {
-            STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
-            env(v.withdraw(
-                    {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
-                Ter(expected));
-            env.close();
-        };
-
-        // Regression test: Both depositors should successfully withdraw despite
-        // unrealized loss. Previously failed with invariant violation:
-        // "withdrawal must change vault and destination balance by equal
-        // amount". This was caused by sharesToAssetsWithdraw rounding down,
-        // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
-        // when unrealized loss exists.
-        attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
-        attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
-    }
-
-    // A residual overpayment can reduce the stored principal by one scale-unit
-    // *less* than computeOverpaymentComponents predicts, firing the
-    // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
-    //
-    //   trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
-    //
-    // tryOverpayment re-amortizes the loan at the reduced principal, then
-    // re-derives the theoretical principal from the new periodic payment via
-    // (P * paymentFactor) / paymentFactor. That round-trip is not exact in
-    // Number's 19-digit arithmetic; a positive residual pushes the recomputed
-    // principal a hair above the exact grid point `oldPrincipal - delta`, and
-    // the Upward rounding in tryOverpayment then bumps it a full scale-unit
-    // higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
-    //
-    // Concrete case (isolated, at the tryOverpayment level):
-    // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
-    // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
-    // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
-    // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
-    //
-    // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
-    // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
-    // lossy (P*factor)/factor round-trip, so the assertion holds and the
-    // overpayment applies cleanly. The three "principal change agrees" /
-    // "interest paid agrees" / "principal payment matches" assertions are
-    // gated behind the same amendment, so without it they are disabled (the
-    // server does not abort) and the loan keeps the pre-amendment computation.
-    //
-    // The test runs the same scenario under both amendment settings and checks
-    // the stored principal against a ground-truth value derived independently of
-    // the loan-state computation under test.
-    void
-    testBugOverpaymentPrincipalChange()
-    {
-        testcase("bug: doOverpayment asserts 'principal change agrees'");
-
-        using namespace jtx;
-        using namespace loan;
-        using namespace xrpl::detail;
-
-        struct Params
-        {
-            TenthBips32 interestRate;
-            TenthBips16 managementFeeRate;
-            std::uint32_t paymentTotal;
-            std::uint32_t paymentInterval;
-            std::int64_t principal;
-            Number overpayment;
-            TenthBips32 overpaymentInterestRate;
-            TenthBips32 overpaymentFeeRate;
-            std::optional vaultScale;
-        };
-
-        struct Result
-        {
-            Number principalOutstanding;  // stored principal after the LoanPay
-            Number expectedNewPrincipal;  // ground truth, independent of the fix
-            Number managementFeeChange;   // managementFeeOutstanding after - before
-            Number unit;                  // one scale-unit at the loan scale
-        };
-
-        auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
-            Env env(*this, features);
-
-            Account const issuer{"issuer"};
-            Account const lender{"vaultOwner"};
-            Account const borrower{"borrower"};
-
-            env.fund(XRP(1'000'000), issuer, lender, borrower);
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const iouAsset = issuer["USD"];
-            Asset const asset = iouAsset.raw();
-            STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}};
-            env(trust(lender, iouLimit));
-            env(trust(borrower, iouLimit));
-            env(pay(issuer, lender, iouAsset(1'000'000)));
-            env(pay(issuer, borrower, iouAsset(1'000'000)));
-            env.close();
-
-            auto const broker = createVaultAndBroker(
-                env,
-                iouAsset,
-                lender,
-                {.vaultDeposit = 900'000,
-                 .debtMax = 0,
-                 .managementFeeRate = p.managementFeeRate,
-                 .vaultScale = p.vaultScale});
-
-            auto const brokerSle = env.le(broker.brokerKeylet());
-            BEAST_EXPECT(brokerSle);
-            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
-            auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-
-            env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
-                Sig(sfCounterpartySignature, lender),
-                kInterestRate(p.interestRate),
-                kPaymentTotal(p.paymentTotal),
-                kPaymentInterval(p.paymentInterval),
-                kGracePeriod(p.paymentInterval),
-                kOverpaymentFee(p.overpaymentFeeRate),
-                kOverpaymentInterestRate(p.overpaymentInterestRate),
-                Fee(env.current()->fees().base * 2),
-                Ter(tesSUCCESS));
-            env.close();
-
-            // The single LoanPay below makes one regular payment (the overpayment
-            // is smaller than one period) and leaves the residual as an
-            // overpayment.
-            auto const s = getCurrentState(env, broker, loanKeylet);
-            auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
-            auto const onePeriod = computePaymentComponents(
-                env.current()->rules(),
-                asset,
-                s.loanScale,
-                s.totalValue,
-                s.principalOutstanding,
-                s.managementFeeOutstanding,
-                s.periodicPayment,
-                periodicRate,
-                s.paymentRemaining,
-                p.managementFeeRate);
-
-            // Ground truth: the stored principal must drop by exactly the regular
-            // payment's principal portion plus the overpayment's principal
-            // portion. computeOverpaymentComponents depends only on the
-            // overpayment amount and rates (not on the loan-state computation
-            // under test), so it is an independent oracle. Both components are
-            // computed under the same rules as the env so the payment factor
-            // matches.
-            auto const overpaymentComponents = computeOverpaymentComponents(
-                env.current()->rules(),
-                asset,
-                s.loanScale,
-                p.overpayment,
-                p.overpaymentInterestRate,
-                p.overpaymentFeeRate,
-                p.managementFeeRate);
-            Number const expectedNewPrincipal = s.principalOutstanding -
-                onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
-
-            Number const managementFeeBefore = s.managementFeeOutstanding;
-
-            STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
-            env(pay(borrower, loanKeylet.key, payAmount),
-                Txflags(tfLoanOverpayment),
-                Ter(tesSUCCESS));
-            env.close();
-
-            auto const loanSle = env.le(loanKeylet);
-            BEAST_EXPECT(loanSle);
-
-            return Result{
-                .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
-                .expectedNewPrincipal = expectedNewPrincipal,
-                .managementFeeChange =
-                    (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
-                    managementFeeBefore,
-                .unit = Number{1, s.loanScale}};
-        };
-
-        // Scenario 1: the original near-zero-rate principal reproduction
-        // (loanScale -10, no management fee). 0.049999998 is smaller than one
-        // period, so it stays a residual overpayment.
-        Params const principalCase{
-            .interestRate = TenthBips32{1},
-            .managementFeeRate = TenthBips16{0},
-            .paymentTotal = 3,
-            .paymentInterval = 60,
-            .principal = 100,
-            .overpayment = Number{49999998, -9},
-            .overpaymentInterestRate = TenthBips32{1000},
-            .overpaymentFeeRate = TenthBips32{1000},
-            .vaultScale = 1};
-
-        // With fixCleanup3_2_0 the stored principal lands exactly on the
-        // ground-truth grid point: it is reduced by exactly the overpayment's
-        // principal portion. This is the key correctness check: if the principal
-        // pin were removed (even with the assertions still gated off), the lossy
-        // (P * factor) / factor round-trip would leave the principal one
-        // scale-unit high and this would fail.
-        Result const fixed = runScenario(all_, principalCase);
-        BEAST_EXPECTS(
-            fixed.principalOutstanding == fixed.expectedNewPrincipal,
-            "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
-                to_string(fixed.expectedNewPrincipal));
-
-        // Without the amendment the loan amortizes with the catastrophically
-        // cancelling near-zero payment factor, so its schedule (and ground truth)
-        // differ from the fixed case; the gated assertions keep the server from
-        // aborting and the overpayment still lands exactly on that schedule.
-        Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
-        BEAST_EXPECTS(
-            legacy.principalOutstanding == legacy.expectedNewPrincipal,
-            "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
-                to_string(legacy.expectedNewPrincipal));
-
-        // Scenario 2: a normal-rate loan with a 10% management fee. At a normal
-        // rate the payment factor is identical across the amendment, so toggling
-        // fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
-        // lands on a state where both the principal and the management fee differ
-        // by one scale-unit between the fixed and legacy paths.
-        Params const feeCase{
-            .interestRate = TenthBips32{10000},
-            .managementFeeRate = TenthBips16{10000},
-            .paymentTotal = 6,
-            .paymentInterval = 30u * 24 * 60 * 60,
-            .principal = 1000,
-            .overpayment = Number{214367363, -10},
-            .overpaymentInterestRate = TenthBips32{0},
-            .overpaymentFeeRate = TenthBips32{0},
-            .vaultScale = std::nullopt};
-
-        Result const feeFixed = runScenario(all_, feeCase);
-        Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
-
-        // With the fix the principal is the exact reduction; without it the lossy
-        // (P * factor) / factor round-trip leaves it one scale-unit high.
-        BEAST_EXPECTS(
-            feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
-            "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
-                " != expected " + to_string(feeFixed.expectedNewPrincipal));
-        BEAST_EXPECTS(
-            feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
-            "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
-                " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
-
-        // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
-        // outstanding drops.
-        //
-        // Unlike the principal that is already at the correct precision, the re-amortized
-        // management fee  is tenthBipsOfValue of the new schedule's gross interest, which depends
-        // on the recomputed periodic payment. So the expected change below is a pinned constant
-        // captured from a passing run a magic value only because there is nothing simpler to
-        // compare against.
-        //
-        // At the integration level, toggling the amendment also changes the regular payment's
-        // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
-        // fix.
-        BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
-        BEAST_EXPECTS(
-            (feeFixed.managementFeeChange == Number{-8219709543, -10}),
-            "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
-    }
-
-    // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero
-    // rate). At such a near-zero rate the closed-form payment factor
-    // (1 + r)^n - 1 cancels catastrophically.
-    //
-    // Without fixCleanup3_2_0 the resulting amortization is degenerate and the
-    // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the
-    // amendment, computePowerMinusOneHybrid uses a numerically-stable series
-    // expansion, so the loan is created and the scheduled payments
-    // (2 * periodicPayment) cover the principal — no economic underpayment
-    // (yield theft).
-    //
-    // The test runs the same LoanSet under both amendment settings and pins the
-    // exact outcome for each.
-    void
-    testLoanSetNearZeroInterestRateSucceeds()
-    {
-        testcase("LoanSet near-zero interest rate covers principal");
-
-        using namespace jtx;
-        using namespace loan;
-
-        Number const principalRequested{1000};
-
-        struct Result
-        {
-            TER ter = tesSUCCESS;
-            bool created = false;
-            std::int32_t loanScale = 0;
-            Number principal;
-            Number totalValue;
-            Number managementFee;
-            Number periodicPayment;
-        };
-
-        auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result {
-            Env env(*this, features);
-
-            Account const issuer{"issuer"};
-            Account const lender{"vaultOwner"};
-            Account const borrower{"borrower"};
-
-            env.fund(XRP(1'000'000), issuer, lender, borrower);
-            env(fset(issuer, asfDefaultRipple));
-            env.close();
-
-            PrettyAsset const iouAsset = issuer["USD"];
-            STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
-            env(trust(lender, iouLimit));
-            env(trust(borrower, iouLimit));
-            env(pay(issuer, lender, iouAsset(1'000'000)));
-            env(pay(issuer, borrower, iouAsset(1'000'000)));
-            env.close();
-
-            auto const broker = createVaultAndBroker(
-                env,
-                iouAsset,
-                lender,
-                {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}});
-
-            auto const brokerSle = env.le(broker.brokerKeylet());
-            BEAST_EXPECT(brokerSle);
-            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
-            auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-
-            env(set(borrower, broker.brokerID, principalRequested),
-                Sig(sfCounterpartySignature, lender),
-                kInterestRate(TenthBips32{1}),
-                kPaymentTotal(2),
-                kPaymentInterval(400),
-                Fee(env.current()->fees().base * 2),
-                Ter(expectedTer));
-            env.close();
-
-            Result r;
-            r.ter = env.ter();
-            if (auto const loanSle = env.le(loanKeylet))
-            {
-                r.created = true;
-                r.loanScale = loanSle->at(sfLoanScale);
-                r.principal = loanSle->at(sfPrincipalOutstanding);
-                r.totalValue = loanSle->at(sfTotalValueOutstanding);
-                r.managementFee = loanSle->at(sfManagementFeeOutstanding);
-                r.periodicPayment = loanSle->at(sfPeriodicPayment);
-            }
-            return r;
-        };
-
-        Result const fixed = runScenario(all_, tesSUCCESS);
-        Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS);
-
-        // Without the amendment, the catastrophically-cancelling closed-form
-        // payment factor produces a degenerate amortization that fails
-        // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no
-        // loan is created.
-        BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS);
-        BEAST_EXPECT(!legacy.created);
-
-        // With the amendment the stable series expansion produces a valid loan
-        // at loanScale -10.
-        BEAST_EXPECT(fixed.ter == tesSUCCESS);
-        BEAST_EXPECT(fixed.created);
-        BEAST_EXPECT(fixed.loanScale == -10);
-        BEAST_EXPECT(fixed.principal == principalRequested);
-        BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10}));
-        BEAST_EXPECT(fixed.managementFee == beast::kZero);
-
-        // Periodic payment from the numerically-stable series expansion, and the
-        // scheduled total (2 * periodicPayment) which exceeds the 1000 principal
-        // — no economic underpayment / yield theft.
-        BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16}));
-        BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15}));
-        BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested);
-    }
-
-    // An overpayment whose residual amount has more precision than loanScale
-    // fires the isRounded(asset, overpayment, loanScale) assertion in
-    // computeOverpaymentComponents (and a downstream "interest paid agrees"
-    // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
-    // to loanScale before passing it in. The pre-amendment path can't be
-    // tested here because the assertion fires in Debug builds and aborts
-    // the test process — see the PR description for context.
-    void
-    testBugOverpayUnroundedAmount()
-    {
-        testcase("bug: computeOverpaymentComponents isRounded assertion");
-
-        using namespace jtx;
-        using namespace loan;
-        Env env(*this, all_);
-
-        Account const issuer{"issuer"};
-        Account const lender{"vaultOwner"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env(fset(issuer, asfDefaultRipple));
-        env.close();
-
-        PrettyAsset const iouAsset = issuer["USD"];
-        STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
-        env(trust(lender, iouLimit));
-        env(trust(borrower, iouLimit));
-        env(pay(issuer, lender, iouAsset(1'000'000)));
-        env(pay(issuer, borrower, iouAsset(1'000'000)));
-        env.close();
-
-        auto const broker = createVaultAndBroker(
-            env,
-            iouAsset,
-            lender,
-            {.vaultDeposit = 100'000,
-             .debtMax = 5000,
-             .managementFeeRate = TenthBips16{1000},
-             .vaultScale = 1});
-
-        auto const sleBroker = env.le(broker.brokerKeylet());
-        if (!BEAST_EXPECT(sleBroker))
-            return;
-        auto const loanSequence = sleBroker->at(sfLoanSequence);
-        auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-
-        using namespace loan;
-        env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
-            Sig(sfCounterpartySignature, lender),
-            kInterestRate(TenthBips32{10000}),
-            kPaymentTotal(12),
-            kPaymentInterval(60),
-            kGracePeriod(60),
-            kOverpaymentFee(TenthBips32{1000}),
-            kOverpaymentInterestRate(TenthBips32{1000}),
-            Fee(env.current()->fees().base * 2),
-            Ter(tesSUCCESS));
-        env.close();
-
-        // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
-        // has too many digits to round cleanly to loanScale=-10, so the
-        // overpayment residual fails the isRounded check.
-        STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
-        env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
-        env.close();
-    }
-
-    // Regression for the dual-rounding fix at coarse (integer-MPT) scale.
-    //
-    // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
-    // amortization schedule produces a fractional principal
-    // (~0.47) which under round-to-nearest collapses to 0 in a single
-    // step, causing `doPayment`'s strict `>` assertion on principal to
-    // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
-    // upward (sticks at 1 across the first two periods) and only clears
-    // in the final payment.
-    //
-    // The test pays one period at a time across three LoanPay
-    // transactions and verifies the loan completes (paymentRemaining=0)
-    // with totals matching the loan's economics (1 principal + 2 interest).
-    void
-    testIntegerScalePrincipalSticks(FeatureBitset features)
-    {
-        // Without fixCleanup3_2_0, this behavior will abort the server, so
-        // don't run without it.
-        if (!features[fixCleanup3_2_0])
-            return;
-
-        testcase("edge: integer MPT principal stuck mid-loan completes via final");
-
-        using namespace jtx;
-        Env env(*this, features);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(100'000), issuer, lender, borrower);
-        env.close();
-
-        MPTTester mptt{env, issuer, kMptInitNoFund};
-        mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
-        PrettyAsset const asset{mptt.issuanceID()};
-
-        mptt.authorize({.account = lender});
-        mptt.authorize({.account = borrower});
-
-        env(pay(issuer, lender, asset(10'000)));
-        env(pay(issuer, borrower, asset(10'000)));
-        env.close();
-
-        Vault const vault{env};
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
-        env(vaultTx);
-        env.close();
-
-        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
-        env.close();
-
-        auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender));
-        env(loanBroker::set(lender, vaultKeylet.key),
-            loanBroker::kDebtMaximum(Number{100}),
-            Fee(env.current()->fees().base * 2));
-        env.close();
-
-        auto const brokerStateBefore = env.le(brokerKeylet);
-        if (!BEAST_EXPECT(brokerStateBefore))
-            return;
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
-
-        env(loan::set(borrower, brokerKeylet.key, Number{1}),
-            Sig(sfCounterpartySignature, lender),
-            loan::kInterestRate(TenthBips32{50'000}),
-            loan::kPaymentTotal(3),
-            loan::kPaymentInterval(31'536'000),
-            Fee(env.current()->fees().base * 2));
-        env.close();
-
-        auto const borrowerStart = env.balance(borrower, asset).value();
-
-        // Three separate periodic payments of 1 each. Expected per-period
-        // evolution at integer MPT scale (TVO = PO + interestDue +
-        // managementFeeDue):
-        //   start:        PO=1, TVO=3, paymentRemaining=3
-        //   after pay #1: PO=1, TVO=2, paymentRemaining=2  (principal sticks)
-        //   after pay #2: PO=1, TVO=1, paymentRemaining=1  (principal sticks)
-        //   after pay #3: PO=0, TVO=0, paymentRemaining=0  (final clears)
-        std::array const expectedPO{Number{1}, Number{1}, Number{0}};
-        std::array const expectedTVO{Number{2}, Number{1}, Number{0}};
-        std::array const expectedRemaining{2, 1, 0};
-
-        for (int i = 0; i < 3; ++i)
-        {
-            env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
-            env.close();
-
-            auto const sle = env.le(loanKeylet);
-            if (!BEAST_EXPECT(sle))
-                return;
-            BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
-            BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
-            BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
-        }
-
-        // Borrower paid 3 total regardless of fee split (1 principal + 2
-        // interest+fee, matching loan economics).
-        auto const borrowerEnd = env.balance(borrower, asset).value();
-        BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
-    }
-
-    // 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
-    // the theoretical value, producing a negative theoretical interest.
-    // The payment delta then exceeds the actual outstanding interest,
-    // violating XRPL_ASSERT_PARTS in computePaymentComponents.
-    void
-    testBugInterestDueDeltaCrash()
-    {
-        testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
-
-        using namespace jtx;
-        using namespace std::chrono_literals;
-        Env env(*this, all_);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-        env(fset(issuer, asfDefaultRipple));
-        env.close();
-
-        PrettyAsset const iouAsset = issuer["USD"];
-        env(trust(lender, iouAsset(1'000'000'000)));
-        env(trust(borrower, iouAsset(1'000'000'000)));
-        env(pay(issuer, lender, iouAsset(5'000'000)));
-        env(pay(issuer, borrower, iouAsset(5'000'000)));
-        env.close();
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 1'000'000,
-            .debtMax = 1'000'000,
-            .coverRateMin = TenthBips32{0},
-            .coverDeposit = 0,
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
-
-        using namespace loan;
-
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{100};
-
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-
-        createJson["InterestRate"] = 1;  // minimum non-zero rate
-        createJson["PaymentTotal"] = 3;
-        createJson["PaymentInterval"] = 600;
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const keylet = keylet::loan(broker.brokerID, loanSequence);
-
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(tesSUCCESS));
-        env.close();
-
-        // For principal=100, n=3 the amortization schedule produces a
-        // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
-        // one period's worth — enough for the LoanPay path to enter
-        // computePaymentComponents and reach the assertion that fires
-        // when the bug is present. With the fix, the tx applies cleanly.
-        env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
-        env.close();
-    }
-
-    // Integration test: full lifecycle of a $1B loan in the bug regime.
-    // Verifies that the vault collects the economically-correct interest
-    // income and that conservation holds at the trust-line level.
-    //
-    // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only
-    // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10.
-    // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B,
-    // matching the value computed independently with arbitrary-precision
-    // Decimal arithmetic.
-    void
-    testFullLifecycleVaultPnLNearZeroRate()
-    {
-        testcase("integration: full loan lifecycle, vault interest at near-zero rate");
-
-        using namespace jtx;
-        using namespace jtx::loan;
-        using namespace std::chrono_literals;
-        Env env(*this, all_);
-
-        Account const issuer{"issuer"};
-        Account const lender{"lender"};
-        Account const borrower{"borrower"};
-
-        env.fund(XRP(1'000'000), issuer, lender, borrower);
-        env.close();
-        env(fset(issuer, asfDefaultRipple));
-        env.close();
-
-        PrettyAsset const iouAsset = issuer["USD"];
-        STAmount const trustLimit{iouAsset.raw(), Number{1, 17}};
-        env(trust(lender, trustLimit));
-        env(trust(borrower, trustLimit));
-        env.close();
-        env(pay(issuer, lender, iouAsset(5'000'000'000LL)));
-        env(pay(issuer, borrower, iouAsset(5'000'000'000LL)));
-        env.close();
-
-        auto usdBalance = [&](Account const& a) {
-            return env.balance(a, iouAsset.raw().get()).value();
-        };
-        STAmount const borrowerStartBal = usdBalance(borrower);
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = Number{2, 9},
-            .debtMax = Number{0},
-            .coverRateMin = TenthBips32{0},
-            .coverDeposit = 0,
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
-
-        auto const vaultBefore = env.le(broker.vaultKeylet());
-        BEAST_EXPECT(vaultBefore);
-        Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable);
-
-        // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32.
-        auto const loanSetFee = Fee(env.current()->fees().base * 2);
-        Number const principalRequest{1, 9};
-        auto createJson = env.json(
-            set(borrower, broker.brokerID, principalRequest),
-            Fee(loanSetFee),
-            Json(sfCounterpartySignature, json::ValueType::Object));
-        createJson["InterestRate"] = 1;
-        createJson["PaymentTotal"] = 3;
-        createJson["PaymentInterval"] = 600;
-
-        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
-        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
-        auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
-        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
-        env(createJson, Ter(tesSUCCESS));
-        env.close();
-
-        auto const loanSle = env.le(loanKeylet);
-        BEAST_EXPECT(loanSle);
-        Number const expectedTotalInterest =
-            loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding);
-
-        env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS));
-        env.close();
-
-        auto const vaultAfter = env.le(broker.vaultKeylet());
-        Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable);
-        Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore;
-
-        STAmount const borrowerEndBal = usdBalance(borrower);
-        STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal;
-
-        // Self-consistency: vault gained exactly the expected interest
-        // computed at LoanSet, and the borrower's outflow matches.
-        BEAST_EXPECT(vaultGain == expectedTotalInterest);
-        BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest);
-
-        // Mathematical correctness: the total interest for this loan
-        // configuration is 0.38051750382930729983, calculated
-        // independently using 50-digit Decimal arithmetic (no
-        // cancellation possible at that precision). At Number's 19-digit
-        // mantissa this rounds to 0.38051750382930729 — the literal
-        // below. The vault's actual gain must agree to within
-        // sub-microcent precision.
-        Number const decimalReference{38051750382930729LL, -17};
-        Number const tolerance{1, -6};  // 1e-6 USD = sub-microcent
-        Number const error = abs(vaultGain - decimalReference);
-        BEAST_EXPECTS(
-            error < tolerance,
-            "vault gain " + to_string(vaultGain) + " differs from Decimal reference " +
-                to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " +
-                to_string(tolerance));
-    }
-
-    // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the
-    // same vault-scale minimum cover when fixCleanup3_2_0 is enabled.
-    // Before the amendment, each transactor computed its minimum cover at a
-    // different precision (loanScale, debtScale, or the raw unrounded
-    // tenthBipsOfValue), which could lead to inconsistent decisions for the
-    // same broker state.  After the amendment all three use
-    // minimumBrokerCover at vaultScale.
-    void
-    testMinimumBrokerCoverConsistency(FeatureBitset features)
-    {
-        using namespace jtx;
-        using namespace loan;
-        using namespace loanBroker;
-
-        bool const withAmendment = features[fixCleanup3_2_0];
-
-        struct Ctx
-        {
-            jtx::Account issuer;
-            jtx::Account lender;
-            jtx::Account borrower;
-            jtx::PrettyAsset iou;
-            BrokerInfo broker;
-            BrokerParameters brokerParams;
-        };
-
-        // Shared setup, parametrized by vaultDeposit (the only varying setup
-        // field across the three scenarios).  Each call runs in its own Env
-        // so multiple invocations within one scenario cannot interfere.
-        // The caller is responsible for invoking testcase(...) before the
-        // first runTest call of each scenario.
-        auto runTest = [&](Number vaultDeposit, auto&& body) {
-            Env env(*this, features);
-
-            Account const issuer{"issuer"};
-            Account const lender{"lender"};
-            Account const borrower{"borrower"};
-
-            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
-            env.close();
-
-            // Enable clawback on the issuer *before* any trust lines exist
-            // (asfAllowTrustLineClawback requires an empty owner directory).
-            env(fset(issuer, asfAllowTrustLineClawback));
-            env.close();
-
-            PrettyAsset const iou = issuer[iouCurrency_];
-            env(trust(lender, iou(1'000'000'000)));
-            env(trust(borrower, iou(1'000'000'000)));
-            env.close();
-            env(pay(issuer, lender, iou(100'000'000)));
-            env(pay(issuer, borrower, iou(100'000'000)));
-            env.close();
-
-            // 13.37% — non-round rate produces a messier minimum.
-            BrokerParameters const brokerParams{
-                .vaultDeposit = vaultDeposit,
-                .debtMax = 0,
-                .coverRateMin = TenthBips32{13'370},
-                .coverDeposit = 5'000,
-                .managementFeeRate = TenthBips16{500}};
-
-            BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
-
-            body(
-                env,
-                Ctx{.issuer = issuer,
-                    .lender = lender,
-                    .borrower = borrower,
-                    .iou = iou,
-                    .broker = broker,
-                    .brokerParams = brokerParams});
-        };
-
-        // Scenario 1 — LoanPay
-        //
-        // Verify that LoanPay's minimum cover check uses vault scale (not
-        // loan scale).  Before the amendment, different loans could produce
-        // different fee routing decisions for the same broker-level state.
-        // Small vault deposit => vaultScale = -12.
-        testcase("LoanPay minimum cover scale consistency");
-        {
-            struct LoanKeylets
-            {
-                Keylet tiny;
-                Keylet big;
-            };
-
-            // Create the tiny + big loans and reduce cover via clawback so
-            // that subsequent LoanPay calls hit the minimum-cover boundary.
-            // Used by the two pay-and-check sub-tests below so each can run
-            // in its own Env.
-            auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional {
-                Asset const asset{c.iou};
-
-                // Create the TINY loan first (while vaultScale is still
-                // small).  principal 0.01, 0% interest, 1 payment =>
-                // loanScale = vaultScale.
-                auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID));
-                if (!BEAST_EXPECT(brokerSle1))
-                    return std::nullopt;
-                auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
-                auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq);
-
-                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
-                    Sig(sfCounterpartySignature, c.lender),
-                    kInterestRate(TenthBips32{0}),
-                    kPaymentTotal(1),
-                    kPaymentInterval(86400 * 365),
-                    Fee(XRP(10)));
-                env.close();
-
-                // Create the BIG loan second.  100% annual interest over 20
-                // payments pushes totalValueOutstanding high enough that
-                // loanScale > vaultScale.
-                auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID));
-                if (!BEAST_EXPECT(brokerSle2))
-                    return std::nullopt;
-                auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
-                auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq);
-
-                env(set(c.borrower, c.broker.brokerID, Number{500}),
-                    Sig(sfCounterpartySignature, c.lender),
-                    kInterestRate(TenthBips32{100'000}),
-                    kPaymentTotal(20),
-                    kPaymentInterval(86400 * 365),
-                    Fee(XRP(10)));
-                env.close();
-
-                // The tiny loan's scale is frozen at the vault's pre-big-loan
-                // scale, so it is strictly smaller than the big loan's.
-                // After the big loan is created the vault absorbs its value,
-                // pushing vaultScale up to match bigLoanScale.
-                auto const tinyLoanSle = env.le(tinyLoanKeylet);
-                auto const bigLoanSle = env.le(bigLoanKeylet);
-                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
-                if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) ||
-                    !BEAST_EXPECT(vaultSle))
-                    return std::nullopt;
-                if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
-                    !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
-                    !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
-                    return std::nullopt;
-
-                // Use issuer clawback to reduce cover to the minimum the
-                // clawback transactor allows.  Compute the amount as
-                // initialCover - expectedCoverAfter so we exercise the exact
-                // clawback rather than relying on the transactor to clip
-                // down.
-                //
-                // Before the amendment the clawback minimum is the
-                // *unrounded* tenthBipsOfValue — strictly less than the
-                // rounded-at-vaultScale minimum LoanPay uses for the big
-                // loan.  After the amendment both clawback and LoanPay use
-                // the same rounded minimum (via minimumBrokerCover), so
-                // cover lands exactly at that threshold.
-                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
-                                                                : Number{1330651855688458000, -15};
-                Number const clawbackAmount =
-                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
-
-                env(coverClawback(c.issuer),
-                    kLoanBrokerId(c.broker.brokerID),
-                    kAmount(STAmount{asset, clawbackAmount}));
-                env.close();
-
-                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
-                if (!BEAST_EXPECT(brokerSle) ||
-                    !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter))
-                    return std::nullopt;
-
-                return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet};
-            };
-
-            // Pay one loan and report whether the fee went to the broker's
-            // pseudo account (the fallback when cover < minimum) rather
-            // than to the owner.
-            auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool {
-                Asset const asset{c.iou};
-                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
-                if (!BEAST_EXPECT(brokerSle))
-                    return false;
-                auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
-                auto const pseudoBefore = env.balance(pseudoAcct, c.iou);
-
-                auto const payLoan = env.le(loanKeylet);
-                if (!BEAST_EXPECT(payLoan))
-                    return false;
-                auto const periodicPayment = payLoan->at(sfPeriodicPayment);
-                auto const serviceFee = payLoan->at(sfLoanServiceFee);
-                std::int32_t const loanScale = payLoan->at(sfLoanScale);
-
-                auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale);
-                auto const payAmt = STAmount{asset, payment + serviceFee};
-
-                env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
-                env.close();
-
-                auto const pseudoAfter = env.balance(pseudoAcct, c.iou);
-                return pseudoAfter.number() > pseudoBefore.number();
-            };
-
-            // Pay the BIG loan in its own Env so its outcome cannot affect
-            // the TINY-loan check.  With the fix, LoanPay and clawback use
-            // the same vaultScale minimum (cover == minAtVaultScale =>
-            // fee to owner).  Without the fix, LoanPay uses bigLoanScale=-11,
-            // rounds up to a larger minimum than what clawback used =>
-            // cover < min => fee to pseudo.
-            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
-                auto const loans = setupLoansAndClawback(env, c);
-                if (!loans)
-                    return;
-                BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment);
-            });
-
-            // Pay the TINY loan in its own Env.  Fee goes to the owner
-            // either way:
-            //  - With the fix: LoanPay uses vaultScale=-11 (same as
-            //    clawback) => owner.
-            //  - Without the fix: LoanPay uses tinyLoanScale=-12, rounds
-            //    up at -12 (a no-op) => min == cover => owner.
-            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
-                auto const loans = setupLoansAndClawback(env, c);
-                if (!loans)
-                    return;
-                BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny));
-            });
-        }
-
-        // Scenario 2 — LoanBrokerCoverWithdraw
-        //
-        // Verify that CoverWithdraw's minimum cover check uses vault scale
-        // (not scale(debtTotal, asset)).  Before the amendment, CoverWithdraw
-        // used:
-        //   roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
-        // which could disagree with LoanPay's minimum (which used loanScale).
-        //
-        // Use a large vault deposit so that vaultScale (from AssetsTotal) is
-        // strictly larger than debtScale (from DebtTotal).  With
-        // vaultDeposit = 100,000: after the big loan
-        //   AssetsTotal ≈ 109,500 → vaultScale = -10
-        //   DebtTotal   ≈  10,000 → debtScale  = -11
-        // The one-order-of-magnitude gap makes roundToAsset at -10 truncate
-        // more aggressively than at -11, exposing the bug.
-        testcase("CoverWithdraw minimum cover scale consistency");
-        runTest(
-            /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) {
-                Asset const asset{c.iou};
-
-                // Create only the big loan to push DebtTotal up to ~10,000
-                // while AssetsTotal stays around 109,500 (dominated by the
-                // large vault deposit).
-                env(set(c.borrower, c.broker.brokerID, Number{500}),
-                    Sig(sfCounterpartySignature, c.lender),
-                    kInterestRate(TenthBips32{100'000}),
-                    kPaymentTotal(20),
-                    kPaymentInterval(86400 * 365),
-                    Fee(XRP(10)));
-                env.close();
-
-                // Read broker state and compute both old and new minimums.
-                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
-                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
-                if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle))
-                    return;
-
-                auto const coverAvail = brokerSle->at(sfCoverAvailable);
-                auto const debtTotal = brokerSle->at(sfDebtTotal);
-                auto const vaultScale = getAssetsTotalScale(vaultSle);
-                auto const debtScale = scale(debtTotal, asset);
-
-                // Sanity: debt scale differs from vault scale for this setup.
-                BEAST_EXPECT(debtScale < vaultScale);
-
-                auto const oldMin = [&]() {
-                    NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
-                    return roundToAsset(
-                        asset,
-                        tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}),
-                        debtScale);
-                }();
-                auto const newMin = minimumBrokerCover(
-                    debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle);
-
-                // The new (vaultScale) minimum must be strictly larger than
-                // the old (debtScale) minimum — that is the gap the amendment
-                // closes.
-                Number const expectedNewMin{1330650518688500000, -15};
-                Number const expectedOldMin{1330650518688472000, -15};
-                BEAST_EXPECT(newMin == expectedNewMin);
-                BEAST_EXPECT(oldMin == expectedOldMin);
-
-                // Try to withdraw so that remaining cover lands between the
-                // two minimums:  oldMin < target < newMin.
-                auto const target = oldMin + (newMin - oldMin) / 2;
-                auto const withdrawAmount = STAmount{asset, coverAvail - target};
-
-                if (withAmendment)
-                {
-                    // CoverWithdraw now uses vaultScale: target < newMin
-                    // => FAILS.
-                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount),
-                        Ter(tecINSUFFICIENT_FUNDS));
-                }
-                else
-                {
-                    // Old CoverWithdraw uses debtScale: target > oldMin
-                    // => SUCCEEDS.
-                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount));
-                }
-                env.close();
-            });
-
-        // Scenario 3 — LoanSet
-        //
-        // Verify that LoanSet's minimum cover check uses vault scale (not the
-        // raw unrounded tenthBipsOfValue).  Before the amendment, LoanSet
-        // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no
-        // roundToAsset), while clawback/withdraw used different formulas.
-        // After the amendment all use minimumBrokerCover at vaultScale, and
-        // rounding at a coarser scale can absorb a tiny debt increase —
-        // allowing a loan that would otherwise be rejected.
-        testcase("LoanSet minimum cover scale consistency");
-        runTest(
-            /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
-                // Create the tiny loan (scale -12) AND the big loan (scale
-                // -11).  Both loans are needed so that DebtTotal has a full
-                // 16-digit mantissa — a "messy" value where roundToAsset at
-                // vaultScale actually truncates digits and produces a
-                // different result from the raw tenthBipsOfValue.  With only
-                // the big loan, DebtTotal has ~4 significant digits and
-                // rounding at scale -11 is a no-op, masking the amendment's
-                // effect.
-                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
-                    Sig(sfCounterpartySignature, c.lender),
-                    kInterestRate(TenthBips32{0}),
-                    kPaymentTotal(1),
-                    kPaymentInterval(86400 * 365),
-                    Fee(XRP(10)));
-                env.close();
-
-                env(set(c.borrower, c.broker.brokerID, Number{500}),
-                    Sig(sfCounterpartySignature, c.lender),
-                    kInterestRate(TenthBips32{100'000}),
-                    kPaymentTotal(20),
-                    kPaymentInterval(86400 * 365),
-                    Fee(XRP(10)));
-                env.close();
-
-                // Clawback to reduce cover to the clawback transactor's
-                // minimum.  Pass the exact amount rather than relying on the
-                // transactor to clip down; the setup matches Scenario 1 so
-                // the same residual-cover values apply.
-                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
-                                                                : Number{1330651855688458000, -15};
-                Number const clawbackAmount =
-                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
-                env(coverClawback(c.issuer),
-                    kLoanBrokerId(c.broker.brokerID),
-                    kAmount(c.iou(clawbackAmount)));
-                env.close();
-
-                // Verify scales.
-                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
-                if (!BEAST_EXPECT(vaultSle))
-                    return;
-                auto const vaultScale = getAssetsTotalScale(vaultSle);
-                BEAST_EXPECT(vaultScale == -11);
-
-                // Now try to create a tiny additional loan.  Principal is
-                // 1e-11 (the smallest value that survives the precision
-                // check at loanScale = vaultScale = -11), with 0% interest
-                // and 1 payment.
-                //
-                // The tiny debt increase adds ~1.337e-12 to the unrounded
-                // minimum.
-                // - Without the amendment: the old LoanSet formula rounds
-                //   up during tenthBipsOfValue (16-digit Number
-                //   normalisation), pushing the minimum past the cover left
-                //   by clawback => tecINSUFFICIENT_FUNDS.
-                // - With the amendment: minimumBrokerCover rounds at
-                //   vaultScale=-11, which absorbs the tiny increase — the
-                //   rounded minimum stays the same => tesSUCCESS.
-                auto const tinyPrincipal = Number{1, -11};
-
-                if (withAmendment)
-                {
-                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
-                        Sig(sfCounterpartySignature, c.lender),
-                        kInterestRate(TenthBips32{0}),
-                        kPaymentTotal(1),
-                        kPaymentInterval(86400 * 365),
-                        Fee(XRP(10)));
-                }
-                else
-                {
-                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
-                        Sig(sfCounterpartySignature, c.lender),
-                        kInterestRate(TenthBips32{0}),
-                        kPaymentTotal(1),
-                        kPaymentInterval(86400 * 365),
-                        Fee(XRP(10)),
-                        Ter(tecINSUFFICIENT_FUNDS));
-                }
-                env.close();
-            });
-    }
-
-    void
-    runAmendmentIndependent()
-    {
-        testDisabled();
-        testInvalidLoanSet();
-        testInvalidLoanDelete();
-        testInvalidLoanManage();
-        testInvalidLoanPay();
-        testIssuerLoan();
-        testServiceFeeOnBrokerDeepFreeze();
-        testRequireAuth();
-        testRIPD3901();
-        testBorrowerIsBroker();
-        testLimitExceeded();
-        testLendingCanTradeDisabledNoImpact();
-        testBugOverpaymentPrincipalChange();
-        testBugOverpayUnroundedAmount();
-
-        for (auto const flags : {0u, tfLoanOverpayment})
-            testYieldTheftRounding(flags);
-        testBugInterestDueDeltaCrash();
-        testFullLifecycleVaultPnLNearZeroRate();
-        testLoanSetNearZeroInterestRateSucceeds();
-    }
-
-    // Tests run under each entry in amendmentCombinations().
-    void
-    runAmendmentSensitive(FeatureBitset features)
-    {
-#if LOAN_TODO
-        testLoanPayLateFullPaymentBypassesPenalties(features);
-        testLoanCoverMinimumRoundingExploit(features);
-#endif
-        // Lifecycle
-        testLifecycle(features);
-        testLoanSet(features);
-        testDosLoanPay(features);
-        testSelfLoan(features);
-
-        // Payment paths
-        testWithdrawReflectsUnrealizedLoss(features);
-        testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features);
-        testBatchBypassCounterparty(features);
-        testLoanNextPaymentDueDateOverflow(features);
-        testSequentialFLCDepletion(features);
-
-        // Invariants
-        testLoanPayComputePeriodicPaymentValidRateInvariant(features);
-        testAccountSendMptMinAmountInvariant(features);
-        testLoanPayDebtDecreaseInvariant(features);
-        testWrongMaxDebtBehavior(features);
-        testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features);
-        testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features);
-        testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features);
-
-        // RPC
-        testRPC(features);
-
-        // Edge / rounding
-        testDustManipulation(features);
-        testRoundingAllowsUndercoverage(features);
-        testOverpaymentManagementFee(features);
-        testIssuerIsBorrower(features);
-        testIntegerScalePrincipalSticks(features);
-        testMinimumBrokerCoverConsistency(features);
-
-        // RIPD regressions
-        testRIPD3831(features);
-        testRIPD3459(features);
-        testRIPD3902(features);
-
-        // Broker-owner permissions
-        testLoanPayBrokerOwnerMissingTrustline(features);
-        testLoanPayBrokerOwnerUnauthorizedMPT(features);
-        testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
-        testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
-    }
-
-public:
-    void
-    run() override
-    {
-        runAmendmentIndependent();
-        for (auto const& features : jtx::amendmentCombinations(
-                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
-            runAmendmentSensitive(features);
-    }
-};
-
-class LoanBatch_test : public Loan_test
-{
-protected:
-    beast::xor_shift_engine engine_;
-
-    std::uniform_int_distribution<> assetDist_{0, 2};
-    std::uniform_int_distribution principalDist_{100'000, 1'000'000'000};
-    std::uniform_int_distribution interestRateDist_{0, 10000};
-    std::uniform_int_distribution<> paymentTotalDist_{12, 10000};
-    std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30};
-    std::uniform_int_distribution managementFeeRateDist_{0, 10'000};
-    std::uniform_int_distribution<> serviceFeeDist_{0, 20};
-    /*
-        # Generate parameters that are more likely to be valid
-    principal = Decimal(str(rand.randint(100000,
-   100'000'000))).quantize(ROUND_TARGET)
-
-    interest_rate = Decimal(rand.randint(1, 10000)) /
-   Decimal(100000)
-
-    payment_total = rand.randint(12, 10000)
-
-    payment_interval = Decimal(str(rand.randint(60, 2629746)))
-
-    interest_fee = Decimal(rand.randint(0, 100000)) /
-   Decimal(100000)
-*/
-
-    void
-    testRandomLoan()
-    {
-        using namespace jtx;
-
-        Account const issuer("issuer");
-        Account const lender("lender");
-        Account const borrower("borrower");
-
-        // Determine all the random parameters at once
-        auto const assetType = static_cast(assetDist_(engine_));
-        auto const principalRequest = principalDist_(engine_);
-        TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)};
-        auto const serviceFee = serviceFeeDist_(engine_);
-        TenthBips32 interest{interestRateDist_(engine_)};
-        auto const payTotal = paymentTotalDist_(engine_);
-        auto const payInterval = paymentIntervalDist_(engine_);
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = principalRequest * 10,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = managementFeeRate};
-        LoanParameters const loanParams{
-            .account = lender,
-            .counter = borrower,
-            .principalRequest = principalRequest,
-            .serviceFee = serviceFee,
-            .interest = interest,
-            .payTotal = payTotal,
-            .payInterval = payInterval,
-        };
-
-        runLoan(assetType, brokerParams, loanParams, all_);
-    }
-
-public:
-    void
-    run() override
-    {
-        auto const numIterations = [s = arg()]() -> int {
-            int const defaultNum = 5;
-            if (s.empty())
-                return defaultNum;
-            try
-            {
-                std::size_t pos = 0;
-                auto const r = stoi(s, &pos);
-                if (pos != s.size())
-                    return defaultNum;
-                return r;
-            }
-            catch (...)
-            {
-                return defaultNum;
-            }
-        }();
-
-        using namespace jtx;
-
-        auto const updateInterval = std::min(numIterations / 5, 100);
-
-        for (int i = 0; i < numIterations; ++i)
-        {
-            if (i % updateInterval == 0)
-                testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations;
-            testRandomLoan();
-        }
-    }
-};
-
-class LoanArbitrary_test : public LoanBatch_test
-{
-    void
-    run() override
-    {
-        using namespace jtx;
-
-        BrokerParameters const brokerParams{
-            .vaultDeposit = 10000,
-            .debtMax = 0,
-            .coverRateMin = TenthBips32{0},
-            .managementFeeRate = TenthBips16{0},
-            .coverRateLiquidation = TenthBips32{0}};
-        LoanParameters const loanParams{
-            .account = Account("lender"),
-            .counter = Account("borrower"),
-            .principalRequest = Number{200000, -6},
-            .interest = TenthBips32{50000},
-            .payTotal = 2,
-            .payInterval = 200};
-
-        runLoan(AssetType::XRP, brokerParams, loanParams, all_);
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl);
-BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl);
-BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp
index befc46e2ae..7086adf743 100644
--- a/src/test/app/MPToken_test.cpp
+++ b/src/test/app/MPToken_test.cpp
@@ -43,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -52,6 +53,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -59,6 +61,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -602,9 +605,9 @@ class MPToken_test : public beast::unit_test::Suite
 
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
-            // test invalid flag - only valid flags are tfMPTLock (1) and Unlock
-            // (2)
-            mptAlice.set({.account = alice, .flags = 0x00000008, .err = temINVALID_FLAG});
+            // test invalid flag - an unrecognized flag bit is always
+            // rejected, regardless of which amendments are enabled
+            mptAlice.set({.account = alice, .flags = 0x00001000, .err = temINVALID_FLAG});
 
             if (!features[featureSingleAssetVault] && !features[featureDynamicMPT] &&
                 !features[featureConfidentialTransfer])
@@ -786,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});
@@ -2114,8 +2117,8 @@ class MPToken_test : public beast::unit_test::Suite
                 jv[jss::TransactionType] = jss::SponsorshipSet;
                 jv[jss::Account] = alice.human();
                 jv[sfSponsee.fieldName] = carol.human();
-                jv[sfFeeAmount.fieldName] = mpt.getJson(JsonOptions::Values::None);
-                test(jv, sfFeeAmount.fieldName);
+                jv[sfFeeAmountDelta.fieldName] = mpt.getJson(JsonOptions::Values::None);
+                test(jv, sfFeeAmountDelta.fieldName);
             }
         }
         BEAST_EXPECT(txWithAmounts.empty());
@@ -2383,7 +2386,7 @@ class MPToken_test : public beast::unit_test::Suite
                 env.submit(tx);
                 env.close();
 
-                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq));
                 auto const sleCheck = env.le(checkKeylet);
                 BEAST_EXPECT((sleCheck != nullptr) == !bad.negative);
                 if (sleCheck && !bad.negative)
@@ -2411,7 +2414,7 @@ class MPToken_test : public beast::unit_test::Suite
                 env.submit(tx);
                 env.close();
 
-                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq));
                 BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
                 if (!bad.negative)
                 {
@@ -2439,7 +2442,7 @@ class MPToken_test : public beast::unit_test::Suite
                 env.submit(tx);
                 env.close();
 
-                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq));
                 BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
                 if (!bad.negative)
                 {
@@ -2470,7 +2473,7 @@ class MPToken_test : public beast::unit_test::Suite
                 env.submit(tx);
                 env.close();
 
-                auto const checkKeylet = keylet::check(alice.id(), checkSeq);
+                auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq));
                 BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative);
                 if (!bad.negative)
                 {
@@ -2499,7 +2502,7 @@ class MPToken_test : public beast::unit_test::Suite
                     env.jt(
                         check::cash(
                             bob,
-                            keylet::check(alice.id(), checkSeq).key,
+                            keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)).key,
                             STAmount{issue, std::uint64_t{1}})),
                     sfAmount,
                     badCashAmount,
@@ -2508,7 +2511,8 @@ class MPToken_test : public beast::unit_test::Suite
                 tx.ter = bad.holderSourcePreFixTer;
                 env.submit(tx);
                 env.close();
-                BEAST_EXPECT(env.le(keylet::check(alice.id(), checkSeq)) != nullptr);
+                BEAST_EXPECT(
+                    env.le(keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq))) != nullptr);
                 BEAST_EXPECT(
                     (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue}));
                 BEAST_EXPECT(
@@ -2532,7 +2536,7 @@ class MPToken_test : public beast::unit_test::Suite
                     env.jt(
                         check::cash(
                             bob,
-                            keylet::check(alice.id(), checkSeq).key,
+                            keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)).key,
                             STAmount{issue, std::uint64_t{1}})),
                     sfAmount,
                     badCashAmount,
@@ -2560,7 +2564,9 @@ class MPToken_test : public beast::unit_test::Suite
                 tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tecINSUFFICIENT_FUNDS};
                 env.submit(tx);
                 env.close();
-                BEAST_EXPECT(env.le(keylet::escrow(alice.id(), escrowSeq)) == nullptr);
+                BEAST_EXPECT(
+                    env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(escrowSeq))) ==
+                    nullptr);
             }
             {
                 Env env{*this, withFix};
@@ -2961,7 +2967,7 @@ class MPToken_test : public beast::unit_test::Suite
                 auto const issue = makeIssue(env);
 
                 auto const badAmount = badMPTAmount(issue, bad);
-                uint256 const fakeVaultId = keylet::vault(gw.id(), 1).key;
+                uint256 const fakeVaultId = keylet::vault(gw.id(), SeqProxy::rawSequence(1)).key;
                 auto tx = withNonCanonicalMPTAmount(
                     env.jt(
                         Vault::clawback(
@@ -3393,26 +3399,26 @@ class MPToken_test : public beast::unit_test::Suite
         using namespace test::jtx;
         Account const alice("alice");
 
-        // Can not provide MutableFlags when DynamicMPT amendment is not enabled
+        // Can not provide ImmutableFlags when DynamicMPT amendment is not enabled
         {
             Env env{*this, features - featureDynamicMPT};
             MPTTester mptAlice(env, alice);
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 2, .err = temDISABLED});
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temDISABLED});
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 2, .err = temDISABLED});
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temDISABLED});
         }
 
-        // MutableFlags contains invalid values
+        // ImmutableFlags contains invalid values
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice);
 
             // Value 1 is reserved for MPT lock.
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 1, .err = temINVALID_FLAG});
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 17, .err = temINVALID_FLAG});
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 65535, .err = temINVALID_FLAG});
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 1, .err = temINVALID_FLAG});
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 17, .err = temINVALID_FLAG});
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 65535, .err = temINVALID_FLAG});
 
-            // MutableFlags can not be 0
-            mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temINVALID_FLAG});
+            // ImmutableFlags can not be 0
+            mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temINVALID_FLAG});
         }
     }
 
@@ -3425,16 +3431,16 @@ class MPToken_test : public beast::unit_test::Suite
         Account const alice("alice");
         Account const bob("bob");
 
-        // Can not provide MutableFlags, MPTokenMetadata or TransferFee when
+        // Can not provide mutate related flags, MPTokenMetadata or TransferFee when
         // DynamicMPT amendment is not enabled
         {
             Env env{*this, features - featureDynamicMPT};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             auto const mptID = makeMptID(env.seq(alice), alice);
 
-            // MutableFlags is not allowed when DynamicMPT is not enabled
-            mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 2, .err = temDISABLED});
-            mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 0, .err = temDISABLED});
+            // Mutate related flags is not allowed when DynamicMPT is not enabled
+            mptAlice.set(
+                {.account = alice, .id = mptID, .flags = tfMPTSetCanLock, .err = temDISABLED});
 
             // MPTokenMetadata is not allowed when DynamicMPT is not enabled
             mptAlice.set({.account = alice, .id = mptID, .metadata = "test", .err = temDISABLED});
@@ -3445,19 +3451,19 @@ class MPToken_test : public beast::unit_test::Suite
             mptAlice.set({.account = alice, .id = mptID, .transferFee = 0, .err = temDISABLED});
         }
 
-        // Can not provide holder when MutableFlags, MPTokenMetadata or
+        // Can not provide holder when mutate related flags, MPTokenMetadata or
         // TransferFee is present
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             auto const mptID = makeMptID(env.seq(alice), alice);
 
-            // Holder is not allowed when MutableFlags is present
+            // Holder is not allowed when mutate related flags is present
             mptAlice.set(
                 {.account = alice,
                  .holder = bob,
                  .id = mptID,
-                 .mutableFlags = 2,
+                 .flags = tfMPTSetCanLock,
                  .err = temMALFORMED});
 
             // Holder is not allowed when MPTokenMetadata is present
@@ -3477,27 +3483,24 @@ class MPToken_test : public beast::unit_test::Suite
                  .err = temMALFORMED});
         }
 
-        // Can not set Flags when MutableFlags, MPTokenMetadata or
+        // Can not lock when mutate related flags, MPTokenMetadata or
         // TransferFee is present
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateMetadata | tmfMPTCanEnableCanLock |
-                     tmfMPTCanMutateTransferFee});
+            mptAlice.create({.ownerCount = 1});
 
-            // Setting flags is not allowed when MutableFlags is present
+            // Lock is not allowed when mutate related flags is present
             mptAlice.set(
-                {.account = alice, .flags = tfMPTCanLock, .mutableFlags = 2, .err = temMALFORMED});
+                {.account = alice, .flags = tfMPTLock | tfMPTSetCanLock, .err = temMALFORMED});
 
-            // Setting flags is not allowed when MPTokenMetadata is present
+            // Lock is not allowed when MPTokenMetadata is present
             mptAlice.set(
-                {.account = alice, .flags = tfMPTCanLock, .metadata = "test", .err = temMALFORMED});
+                {.account = alice, .flags = tfMPTLock, .metadata = "test", .err = temMALFORMED});
 
-            // setting flags is not allowed when TransferFee is present
+            // Lock is not allowed when TransferFee is present
             mptAlice.set(
-                {.account = alice, .flags = tfMPTCanLock, .transferFee = 100, .err = temMALFORMED});
+                {.account = alice, .flags = tfMPTLock, .transferFee = 100, .err = temMALFORMED});
         }
 
         // Flags being 0 or tfFullyCanonicalSig is fine
@@ -3509,48 +3512,39 @@ class MPToken_test : public beast::unit_test::Suite
                 {.transferFee = 10,
                  .ownerCount = 1,
                  .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateMetadata});
+                 .immutableFlags = tifMPTTransferFee});
 
-            mptAlice.set({.account = alice, .flags = 0, .transferFee = 100, .metadata = "test"});
-            mptAlice.set(
-                {.account = alice,
-                 .flags = tfFullyCanonicalSig,
-                 .transferFee = 200,
-                 .metadata = "test2"});
+            mptAlice.set({.account = alice, .flags = 0, .metadata = "test"});
+            mptAlice.set({.account = alice, .flags = tfFullyCanonicalSig, .metadata = "test2"});
         }
 
-        // Invalid MutableFlags
+        // Invalid flags
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             auto const mptID = makeMptID(env.seq(alice), alice);
 
-            for (auto const flags : {10000, 0, 5000})
+            for (auto const flags : {0x0200u, 0x0800u, 0x2000u, 0x0201u})
             {
                 mptAlice.set(
-                    {.account = alice, .id = mptID, .mutableFlags = flags, .err = temINVALID_FLAG});
+                    {.account = alice, .id = mptID, .flags = flags, .err = temINVALID_FLAG});
             }
         }
 
-        // Can not mutate flag which is not mutable
+        // Can not set flag which is immutable
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            mptAlice.create({.ownerCount = 1});
+            mptAlice.create(
+                {.ownerCount = 1,
+                 .immutableFlags = tifMPTCanLock | tifMPTCanTrade | tifMPTCanTransfer |
+                     tifMPTCanClawback | tifMPTCanEscrow | tifMPTRequireAuth |
+                     tifMPTCanHoldConfidentialBalance});
 
-            auto const mutableFlags = {
-                tmfMPTSetCanLock,
-                tmfMPTSetRequireAuth,
-                tmfMPTSetCanEscrow,
-                tmfMPTSetCanTrade,
-                tmfMPTSetCanTransfer,
-                tmfMPTSetCanClawback};
-
-            for (auto const& mutableFlag : mutableFlags)
+            for (auto const& f : MPTokenIssuanceSet::flagMapping)
             {
-                mptAlice.set(
-                    {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION});
+                mptAlice.set({.account = alice, .flags = f.setFlag, .err = tecNO_PERMISSION});
             }
         }
 
@@ -3559,18 +3553,18 @@ class MPToken_test : public beast::unit_test::Suite
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata});
+            mptAlice.create({.ownerCount = 1});
 
             std::string const metadata(kMaxMpTokenMetadataLength + 1, 'a');
             mptAlice.set({.account = alice, .metadata = metadata, .err = temMALFORMED});
         }
 
-        // Can not mutate metadata when it is not mutable
+        // Can not set metadata when it is immutable
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            mptAlice.create({.ownerCount = 1});
+            mptAlice.create({.ownerCount = 1, .immutableFlags = tifMPTMetadata});
             mptAlice.set({.account = alice, .metadata = "test", .err = tecNO_PERMISSION});
         }
 
@@ -3580,7 +3574,7 @@ class MPToken_test : public beast::unit_test::Suite
             MPTTester mptAlice(env, alice, {.holders = {bob}});
             auto const mptID = makeMptID(env.seq(alice), alice);
 
-            mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateTransferFee});
+            mptAlice.create({.ownerCount = 1});
 
             mptAlice.set(
                 {.account = alice,
@@ -3594,83 +3588,70 @@ class MPToken_test : public beast::unit_test::Suite
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanEnableCanTransfer});
+            mptAlice.create({.ownerCount = 1});
 
+            // MPTCanTransfer is not set, return tecNO_PERMISSION
             mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
 
-            // Can not set transfer fee even when trying to set MPTCanTransfer
-            // at the same time. MPTCanTransfer must be set first, then transfer
-            // fee can be set in a separate transaction.
-            mptAlice.set(
-                {.account = alice,
-                 .mutableFlags = tmfMPTSetCanTransfer,
-                 .transferFee = 100,
-                 .err = tecNO_PERMISSION});
+            // Setting a non-zero transfer fee is fine if MPTCanTransfer is
+            // being enabled in the same transaction
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100});
+            BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer));
+            BEAST_EXPECT(mptAlice.checkTransferFee(100));
         }
 
-        // Can not mutate transfer fee when it is not mutable
+        // Can not set transfer fee when it is immutable
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-            mptAlice.create({.transferFee = 10, .ownerCount = 1, .flags = tfMPTCanTransfer});
+            mptAlice.create(
+                {.transferFee = 10,
+                 .ownerCount = 1,
+                 .flags = tfMPTCanTransfer,
+                 .immutableFlags = tifMPTTransferFee});
 
             mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
-
             mptAlice.set({.account = alice, .transferFee = 0, .err = tecNO_PERMISSION});
         }
 
-        // Set some flags mutable. Can not mutate the others
+        // Set some flags immutable. Others can still be set.
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
 
             mptAlice.create(
                 {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer |
-                     tmfMPTCanMutateMetadata});
+                 .immutableFlags = tifMPTCanTrade | tifMPTCanTransfer | tifMPTMetadata});
 
-            // Can not mutate transfer fee
-            mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
+            auto const canEnableFlags = {
+                tfMPTSetCanLock, tfMPTSetRequireAuth, tfMPTSetCanEscrow, tfMPTSetCanClawback};
 
-            auto const invalidFlags = {
-                tmfMPTSetCanLock, tmfMPTSetRequireAuth, tmfMPTSetCanEscrow, tmfMPTSetCanClawback};
+            // Can not enable immutable flags
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanTrade, .err = tecNO_PERMISSION});
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .err = tecNO_PERMISSION});
 
-            // Can not mutate flags which are not mutable
-            for (auto const& mutableFlag : invalidFlags)
+            // Can enable flags which are not immutable
+            for (auto const& mutableFlag : canEnableFlags)
             {
-                mptAlice.set(
-                    {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION});
+                mptAlice.set({.account = alice, .flags = mutableFlag});
             }
-
-            // Can mutate MPTCanTrade
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade});
-
-            // Can mutate MPTCanTransfer
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer});
-
-            // Can mutate metadata
-            mptAlice.set({.account = alice, .metadata = "test"});
-            mptAlice.set({.account = alice, .metadata = ""});
         }
     }
 
     void
-    testMutateMPT(FeatureBitset features)
+    testSetMPT(FeatureBitset features)
     {
-        testcase("Mutate MPT");
+        testcase("Set MPT");
         using namespace test::jtx;
 
         Account const alice("alice");
 
-        // Mutate metadata
+        // Set metadata
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice);
-            mptAlice.create(
-                {.metadata = "test", .ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata});
+            mptAlice.create({.metadata = "test", .ownerCount = 1});
 
             std::vector const metadatas = {
                 "mutate metadata",
@@ -3691,7 +3672,7 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(!mptAlice.isMetadataPresent());
         }
 
-        // Mutate transfer fee
+        // Set transfer fee
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice);
@@ -3699,8 +3680,7 @@ class MPToken_test : public beast::unit_test::Suite
                 {.transferFee = 100,
                  .metadata = "test",
                  .ownerCount = 1,
-                 .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateTransferFee});
+                 .flags = tfMPTCanTransfer});
 
             for (std::uint16_t const fee :
                  std::initializer_list{1, 10, 100, 200, 500, 1000, kMaxTransferFee})
@@ -3718,33 +3698,29 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(mptAlice.checkTransferFee(10));
         }
 
-        // Test mutable flag enablement
+        // Test setting flags
         {
-            auto testFlagSet = [&](std::uint32_t createFlags, std::uint32_t setFlags) {
+            auto testFlagSet = [&](std::uint32_t setFlags) {
                 Env env{*this, features};
                 MPTTester mptAlice(env, alice);
 
-                // Create the MPT object with the specified initial flags
-                mptAlice.create({.metadata = "test", .ownerCount = 1, .mutableFlags = createFlags});
+                // Create issuance and the flags can be enabled once by default.
+                mptAlice.create({.metadata = "test", .ownerCount = 1});
 
-                // Setting the same mutable capability more than once is harmless.
-                mptAlice.set({.account = alice, .mutableFlags = setFlags});
-                mptAlice.set({.account = alice, .mutableFlags = setFlags});
+                // Setting the same immutable flag more than once is harmless.
+                mptAlice.set({.account = alice, .flags = setFlags});
+                mptAlice.set({.account = alice, .flags = setFlags});
             };
 
-            testFlagSet(tmfMPTCanEnableCanLock, tmfMPTSetCanLock);
-            testFlagSet(tmfMPTCanEnableRequireAuth, tmfMPTSetRequireAuth);
-            testFlagSet(tmfMPTCanEnableCanEscrow, tmfMPTSetCanEscrow);
-            testFlagSet(tmfMPTCanEnableCanTrade, tmfMPTSetCanTrade);
-            testFlagSet(tmfMPTCanEnableCanTransfer, tmfMPTSetCanTransfer);
-            testFlagSet(tmfMPTCanEnableCanClawback, tmfMPTSetCanClawback);
+            for (auto const& f : MPTokenIssuanceSet::flagMapping)
+                testFlagSet(f.setFlag);
         }
     }
 
     void
-    testMutateCanLock(FeatureBitset features)
+    testSetCanLock(FeatureBitset features)
     {
-        testcase("Mutate MPTCanLock");
+        testcase("Set MPTCanLock");
         using namespace test::jtx;
 
         Account const alice("alice");
@@ -3754,78 +3730,41 @@ class MPToken_test : public beast::unit_test::Suite
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .holderCount = 0,
-                 .flags = tfMPTCanLock | tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanTrade |
-                     tmfMPTCanMutateTransferFee});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0});
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
-            // Lock bob's mptoken
-            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
+            // Lock bob's mptoken fails because alice has not enabled MPTCanLock
+            mptAlice.set(
+                {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION});
 
-            // Can mutate the mutable flags and fields
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade});
-            mptAlice.set({.account = alice, .transferFee = 200});
+            // set CanLock
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanLock});
+
+            // Now can lock
+            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
         }
 
         // Global lock
         {
             Env env{*this, features};
             MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .holderCount = 0,
-                 .flags = tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback |
-                     tmfMPTCanMutateMetadata});
+            mptAlice.create({.ownerCount = 1, .holderCount = 0});
             mptAlice.authorize({.account = bob, .holderCount = 1});
 
-            // Lock issuance
-            mptAlice.set({.account = alice, .flags = tfMPTLock});
-
-            // Can mutate the mutable flags and fields
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback});
-            mptAlice.set({.account = alice, .metadata = "mutate"});
-        }
-
-        // Test lock and unlock after enabling MPTCanLock
-        {
-            Env env{*this, features};
-            MPTTester mptAlice(env, alice, {.holders = {bob}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .holderCount = 0,
-                 .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback |
-                     tmfMPTCanMutateMetadata});
-            mptAlice.authorize({.account = bob, .holderCount = 1});
-
-            // Can not lock or unlock before MPTCanLock is enabled
+            // Lock issuance fails because alice has not enabled MPTCanLock
             mptAlice.set({.account = alice, .flags = tfMPTLock, .err = tecNO_PERMISSION});
-            mptAlice.set({.account = alice, .flags = tfMPTUnlock, .err = tecNO_PERMISSION});
-            mptAlice.set(
-                {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION});
-            mptAlice.set(
-                {.account = alice, .holder = bob, .flags = tfMPTUnlock, .err = tecNO_PERMISSION});
 
-            // Set MPTCanLock
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock});
-
-            // Can lock and unlock
+            // Set CanLock
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanLock});
+            // Now can lock
             mptAlice.set({.account = alice, .flags = tfMPTLock});
-            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock});
-            mptAlice.set({.account = alice, .flags = tfMPTUnlock});
-            mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTUnlock});
         }
     }
 
     void
-    testMutateRequireAuth(FeatureBitset features)
+    testSetRequireAuth(FeatureBitset features)
     {
-        testcase("Mutate MPTRequireAuth");
+        testcase("Set MPTRequireAuth");
         using namespace test::jtx;
 
         // test enabling RequireAuth flag on the issuance and its effect on payment
@@ -3835,16 +3774,13 @@ class MPToken_test : public beast::unit_test::Suite
         Account const bob("bob");
 
         MPTTester mptAlice(env, alice, {.holders = {bob}});
-        mptAlice.create(
-            {.ownerCount = 1,
-             .flags = tfMPTCanTransfer,
-             .mutableFlags = tmfMPTCanEnableRequireAuth});
+        mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
 
         mptAlice.authorize({.account = bob});
         mptAlice.pay(alice, bob, 1000);
 
-        // Set RequireAuth because it is mutable.
-        mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth});
+        // Set RequireAuth
+        mptAlice.set({.account = alice, .flags = tfMPTSetRequireAuth});
 
         // This should fail because bob is not authorized yet.
         mptAlice.pay(alice, bob, 1000, tecNO_AUTH);
@@ -3855,9 +3791,9 @@ class MPToken_test : public beast::unit_test::Suite
     }
 
     void
-    testMutateCanEscrow(FeatureBitset features)
+    testSetCanEscrow(FeatureBitset features)
     {
-        testcase("Mutate MPTCanEscrow");
+        testcase("Set MPTCanEscrow");
         using namespace test::jtx;
         using namespace std::literals;
 
@@ -3868,11 +3804,7 @@ class MPToken_test : public beast::unit_test::Suite
         auto const carol = Account("carol");
 
         MPTTester mptAlice(env, alice, {.holders = {carol, bob}});
-        mptAlice.create(
-            {.ownerCount = 1,
-             .holderCount = 0,
-             .flags = tfMPTCanTransfer,
-             .mutableFlags = tmfMPTCanEnableCanEscrow});
+        mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
         mptAlice.authorize({.account = carol});
         mptAlice.authorize({.account = bob});
 
@@ -3888,8 +3820,8 @@ class MPToken_test : public beast::unit_test::Suite
             Fee(baseFee * 150),
             Ter(tecNO_PERMISSION));
 
-        // MPTCanEscrow is enabled now
-        mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanEscrow});
+        // Set MPTCanEscrow
+        mptAlice.set({.account = alice, .flags = tfMPTSetCanEscrow});
         env(escrow::create(carol, bob, mpt(3)),
             escrow::kCondition(escrow::kCb1),
             escrow::kFinishTime(env.now() + 1s),
@@ -3897,9 +3829,9 @@ class MPToken_test : public beast::unit_test::Suite
     }
 
     void
-    testMutateCanTransfer(FeatureBitset features)
+    testSetCanTransfer(FeatureBitset features)
     {
-        testcase("Mutate MPTCanTransfer");
+        testcase("Set MPTCanTransfer");
 
         using namespace test::jtx;
         Account const alice("alice");
@@ -3910,9 +3842,7 @@ class MPToken_test : public beast::unit_test::Suite
             Env env{*this, features};
 
             MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
-            mptAlice.create(
-                {.ownerCount = 1,
-                 .mutableFlags = tmfMPTCanEnableCanTransfer | tmfMPTCanMutateTransferFee});
+            mptAlice.create({.ownerCount = 1});
 
             mptAlice.authorize({.account = bob});
             mptAlice.authorize({.account = carol});
@@ -3926,20 +3856,10 @@ class MPToken_test : public beast::unit_test::Suite
             // Can not set non-zero transfer fee when MPTCanTransfer is not set
             mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION});
 
-            // Can not set non-zero transfer fee even when trying to set
-            // MPTCanTransfer at the same time
-            mptAlice.set(
-                {.account = alice,
-                 .mutableFlags = tmfMPTSetCanTransfer,
-                 .transferFee = 100,
-                 .err = tecNO_PERMISSION});
-
-            // Alice sets MPTCanTransfer
-            mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer});
-
-            // Can set transfer fee now
+            // Set MPTCanTransfer
             BEAST_EXPECT(!mptAlice.isTransferFeePresent());
-            mptAlice.set({.account = alice, .transferFee = 100});
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100});
+            BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer));
             BEAST_EXPECT(mptAlice.isTransferFeePresent());
 
             // Bob can pay carol
@@ -3958,16 +3878,13 @@ class MPToken_test : public beast::unit_test::Suite
             }
         }
 
-        // Can set transfer fee to zero when tmfMPTCanMutateTransferFee is set.
+        // Can set transfer fee to zero when transfer fee is mutable (i.e.
+        // tifMPTTransferFee is not set).
         {
             Env env{*this, features};
 
             MPTTester mptAlice(env, alice, {.holders = {bob, carol}});
-            mptAlice.create(
-                {.transferFee = 100,
-                 .ownerCount = 1,
-                 .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanMutateTransferFee});
+            mptAlice.create({.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer});
 
             BEAST_EXPECT(mptAlice.checkTransferFee(100));
 
@@ -3978,9 +3895,9 @@ class MPToken_test : public beast::unit_test::Suite
     }
 
     void
-    testMutateCanClawback(FeatureBitset features)
+    testSetCanClawback(FeatureBitset features)
     {
-        testcase("Mutate MPTCanClawback");
+        testcase("Set MPTCanClawback");
 
         using namespace test::jtx;
         Env env(*this, features);
@@ -3989,8 +3906,7 @@ class MPToken_test : public beast::unit_test::Suite
 
         MPTTester mptAlice(env, alice, {.holders = {bob}});
 
-        mptAlice.create(
-            {.ownerCount = 1, .holderCount = 0, .mutableFlags = tmfMPTCanEnableCanClawback});
+        mptAlice.create({.ownerCount = 1, .holderCount = 0});
 
         // Bob creates an MPToken
         mptAlice.authorize({.account = bob});
@@ -4001,13 +3917,117 @@ class MPToken_test : public beast::unit_test::Suite
         // MPTCanClawback is not enabled
         mptAlice.claw(alice, bob, 1, tecNO_PERMISSION);
 
-        // Enable MPTCanClawback
-        mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback});
+        // Set MPTCanClawback
+        mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback});
 
         // Can clawback now
         mptAlice.claw(alice, bob, 1);
     }
 
+    void
+    testSetImmutableFlags(FeatureBitset features)
+    {
+        testcase("Set MPT ImmutableFlags via MPTokenIssuanceSet");
+
+        using namespace test::jtx;
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        // ImmutableFlags requires featureDynamicMPT.
+        {
+            Env env(*this, features - featureDynamicMPT);
+            MPTTester mptAlice(env, alice);
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set(
+                {.account = alice, .immutableFlags = tifMPTCanClawback, .err = temDISABLED});
+        }
+
+        // ImmutableFlags containing tifMPTCanHoldConfidentialBalance requires
+        // featureConfidentialTransfer.
+        {
+            Env env(*this, features - featureConfidentialTransfer);
+            MPTTester mptAlice(env, alice);
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set(
+                {.account = alice,
+                 .immutableFlags = tifMPTCanHoldConfidentialBalance,
+                 .err = temDISABLED});
+        }
+
+        // ImmutableFlags of 0, or containing unknown bits, is rejected.
+        {
+            Env env(*this, features);
+            MPTTester mptAlice(env, alice);
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set({.account = alice, .immutableFlags = 0, .err = temINVALID_FLAG});
+            mptAlice.set({.account = alice, .immutableFlags = 1, .err = temINVALID_FLAG});
+        }
+
+        // Holder is not allowed alongside ImmutableFlags, and ImmutableFlags
+        // can not be combined with Lock/Unlock in the same transaction.
+        {
+            Env env(*this, features);
+            MPTTester mptAlice(env, alice, {.holders = {bob}});
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set(
+                {.account = alice,
+                 .holder = bob,
+                 .immutableFlags = tifMPTCanClawback,
+                 .err = temMALFORMED});
+
+            mptAlice.set(
+                {.account = alice,
+                 .flags = tfMPTLock,
+                 .immutableFlags = tifMPTCanClawback,
+                 .err = temMALFORMED});
+        }
+
+        // Can sets ImmutableFlags and the capability flags in the same transaction
+        {
+            Env env(*this, features);
+            MPTTester mptAlice(env, alice);
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set(
+                {.account = alice,
+                 .flags = tfMPTSetCanClawback,
+                 .immutableFlags = tifMPTCanClawback});
+
+            mptAlice.set(
+                {.account = alice,
+                 .flags = tfMPTSetCanTransfer | tfMPTSetRequireAuth,
+                 .immutableFlags = tifMPTCanTrade});
+        }
+
+        // Setting ImmutableFlags persists to the ledger, permanently blocks
+        // enabling the corresponding capability, and merges (rather than
+        // overwrites) across multiple transactions.
+        {
+            Env env(*this, features);
+            MPTTester mptAlice(env, alice);
+            mptAlice.create({.ownerCount = 1});
+
+            mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback});
+            BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback));
+
+            // The CanClawback can no longer be enabled.
+            mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback, .err = tecNO_PERMISSION});
+
+            // A distinct bit merges with the first rather than overwriting it.
+            // Both CanClawback and CanTrade are now immutable.
+            mptAlice.set({.account = alice, .immutableFlags = tifMPTCanTrade});
+            BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade));
+
+            // Setting the same bit again is a harmless no-op.
+            mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback});
+            BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade));
+        }
+    }
+
     void
     testMultiSendMaximumAmount(FeatureBitset features)
     {
@@ -4398,14 +4418,14 @@ class MPToken_test : public beast::unit_test::Suite
                  .holders = {alice, carol},
                  .pay = 100,
                  .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanEnableCanTrade});
+                 .immutableFlags = tifMPTCanTrade});
             MPTTester const eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol},
                  .pay = 100,
                  .flags = tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanEnableCanTrade});
+                 .immutableFlags = tifMPTCanTrade});
 
             // Can't create
             env(offer(gw, eth(10), btc(10)), Ter(tecNO_PERMISSION));
@@ -4641,30 +4661,25 @@ class MPToken_test : public beast::unit_test::Suite
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTrade |
-                     tmfMPTCanEnableCanTransfer});
+                 .flags = tfMPTCanLock | kMptDexFlags});
             MPTTester eth(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = tfMPTCanLock | kMptDexFlags,
-                 .mutableFlags = tmfMPTCanEnableCanTransfer});
+                 .flags = tfMPTCanLock | kMptDexFlags});
             MPTTester const usd(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanEnableCanTransfer});
+                 .flags = kMptDexFlags | tfMPTCanLock});
             MPTTester const cad(
                 {.env = env,
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = kMptDexFlags | tfMPTCanLock,
-                 .mutableFlags = tmfMPTCanEnableCanTransfer});
+                 .flags = kMptDexFlags | tfMPTCanLock});
 
             env(offer(bob, eth(1'000), btc(1'000)), Txflags(tfPassive));
             env.close();
@@ -4694,7 +4709,7 @@ class MPToken_test : public beast::unit_test::Suite
             env(pay(gw, ed, eth(100)));
             env(pay(gw, ed, btc(100)));
             env.close();
-            btc.set({.mutableFlags = tmfMPTSetRequireAuth});
+            btc.set({.flags = tfMPTSetRequireAuth});
             // authorize bob to enable the offers trading
             btc.authorize({.account = gw, .holder = bob});
             env.close();
@@ -4932,8 +4947,7 @@ class MPToken_test : public beast::unit_test::Suite
                  .issuer = gw,
                  .holders = {alice, carol, bob},
                  .pay = 1'000,
-                 .flags = tfMPTCanTransfer,
-                 .mutableFlags = tmfMPTCanEnableCanTrade});
+                 .flags = tfMPTCanTransfer});
             MPTTester const eth(
                 {.env = env,
                  .issuer = gw,
@@ -4952,7 +4966,7 @@ class MPToken_test : public beast::unit_test::Suite
             env.close();
 
             // Enable MPTCanTrade so BTC can be crossed through offers.
-            btc.set({.mutableFlags = tmfMPTSetCanTrade});
+            btc.set({.flags = tfMPTSetCanTrade});
             env(offer(bob, XRP(1), btc(1)));
             env(offer(bob, btc(1), eth(1)));
             env(offer(bob, eth(1), usd(1)));
@@ -6551,7 +6565,7 @@ class MPToken_test : public beast::unit_test::Suite
             auto const mpt = mptTester["MPT"];
             mptTester.authorize({.account = alice});
 
-            uint256 const checkId{keylet::check(gw, env.seq(gw)).key};
+            uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key};
 
             env(check::create(gw, alice, mpt(100)), Ter(temDISABLED));
             env.close();
@@ -6572,7 +6586,7 @@ class MPToken_test : public beast::unit_test::Suite
             mptTester.authorize({.account = alice});
             mptTester.pay(gw, alice, 50);
 
-            uint256 const checkId{keylet::check(alice, env.seq(alice)).key};
+            uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key};
 
             // can create
             env(check::create(alice, carol, mpt(100)));
@@ -6602,7 +6616,7 @@ class MPToken_test : public beast::unit_test::Suite
                  .flags = tfMPTCanTransfer | tfMPTCanTrade});
             auto const mpt = mptTester["MPT"];
 
-            uint256 const checkId{keylet::check(gw, env.seq(gw)).key};
+            uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key};
 
             // can create
             env(check::create(gw, alice, mpt(200)));
@@ -6753,14 +6767,10 @@ class MPToken_test : public beast::unit_test::Suite
             env.close();
 
             MPTTester mpt(
-                {.env = env,
-                 .issuer = gw,
-                 .holders = {alice, carol},
-                 .flags = tfMPTCanTrade,
-                 .mutableFlags = tmfMPTCanEnableCanTransfer});
+                {.env = env, .issuer = gw, .holders = {alice, carol}, .flags = tfMPTCanTrade});
 
             // src is issuer
-            uint256 checkId{keylet::check(gw, env.seq(gw)).key};
+            uint256 checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key};
 
             // can create
             env(check::create(gw, alice, mpt(100)));
@@ -6774,7 +6784,7 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(gw, mpt) == mpt(-100));
 
             // dst is issuer
-            checkId = keylet::check(alice, env.seq(alice)).key;
+            checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
 
             // can create
             env(check::create(alice, gw, mpt(100)));
@@ -6788,13 +6798,13 @@ class MPToken_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(gw, mpt) == mpt(0));
 
             // neither src nor dst is issuer, can't create
-            checkId = keylet::check(alice, env.seq(alice)).key;
+            checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(check::create(alice, carol, mpt(100)), Ter(tecNO_AUTH));
             env.close();
 
             // can create now
-            mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTransfer});
-            checkId = keylet::check(alice, env.seq(alice)).key;
+            mpt.set({.account = gw, .flags = tfMPTSetCanTransfer});
+            checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(check::create(alice, carol, mpt(100)));
             env.close();
             env(pay(gw, alice, mpt(10)));
@@ -6818,7 +6828,7 @@ class MPToken_test : public beast::unit_test::Suite
                  .pay = 10,
                  .flags = tfMPTCanTransfer});
 
-            uint256 const checkId{keylet::check(alice, env.seq(alice)).key};
+            uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key};
 
             // can create
             env(check::create(alice, carol, mpt(100)));
@@ -6892,7 +6902,7 @@ class MPToken_test : public beast::unit_test::Suite
             env.fund(XRP(1'000), alice, carol);
 
             // src is issuer
-            uint256 const checkId{keylet::check(alice, env.seq(alice)).key};
+            uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key};
 
             // can create
             env(check::create(alice, carol, mpt(100)));
@@ -6920,7 +6930,7 @@ class MPToken_test : public beast::unit_test::Suite
             auto const mpt = mptTester["MPT"];
             mptTester.authorize({.account = alice});
 
-            uint256 const checkId{keylet::check(gw, env.seq(gw)).key};
+            uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key};
 
             env(check::create(gw, alice, mpt(100)));
             env.close();
@@ -7223,37 +7233,26 @@ class MPToken_test : public beast::unit_test::Suite
             auto const txfee = Fee(drops(increment));
             auto const badMPT = MPT(gw, 1'000);
 
-            auto const makeMPT = [&](std::uint32_t const flags,
-                                     Holders holders = {},
-                                     std::uint64_t const pay = 0,
-                                     std::optional const mutableFlags =
-                                         std::nullopt) {
-                return MPTTester(
-                    {.env = env,
-                     .issuer = gw,
-                     .holders = holders,
-                     .pay = pay ? std::optional{pay} : std::nullopt,
-                     .flags = flags,
-                     .mutableFlags = mutableFlags});
-            };
+            auto const makeMPT =
+                [&](std::uint32_t const flags, Holders holders = {}, std::uint64_t const pay = 0) {
+                    return MPTTester(
+                        {.env = env,
+                         .issuer = gw,
+                         .holders = holders,
+                         .pay = pay ? std::optional{pay} : std::nullopt,
+                         .flags = flags});
+                };
 
             auto const makeDexMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
-                return makeMPT(
-                    tfMPTCanLock | kMptDexFlags,
-                    holders,
-                    pay,
-                    tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTransfer |
-                        tmfMPTCanEnableCanTrade);
+                return makeMPT(tfMPTCanLock | kMptDexFlags, holders, pay);
             };
 
             auto const makeNoTransferMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
-                return makeMPT(
-                    tfMPTCanLock | tfMPTCanTrade, holders, pay, tmfMPTCanEnableCanTransfer);
+                return makeMPT(tfMPTCanLock | tfMPTCanTrade, holders, pay);
             };
 
             auto const makeNoTradeMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) {
-                return makeMPT(
-                    tfMPTCanLock | tfMPTCanTransfer, holders, pay, tmfMPTCanEnableCanTrade);
+                return makeMPT(tfMPTCanLock | tfMPTCanTransfer, holders, pay);
             };
 
             // AMMCreate
@@ -7299,7 +7298,7 @@ class MPToken_test : public beast::unit_test::Suite
                 // MPTRequireAuth is set
                 // alice is not authorized
                 usd.set({.flags = tfMPTUnlock});
-                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                usd.set({.flags = tfMPTSetRequireAuth});
                 createFail(usd, alice, tecNO_AUTH);
                 // issuer can create
                 createDeleteAMM(usd, gw);
@@ -7316,7 +7315,7 @@ class MPToken_test : public beast::unit_test::Suite
                     createFail(usd2, alice, tecNO_AUTH);
                     // issuer can create
                     createDeleteAMM(usd2, gw);
-                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    usd2.set({.flags = tfMPTSetCanTransfer});
                     // alice can create
                     createDeleteAMM(usd2, alice);
                 }
@@ -7328,7 +7327,7 @@ class MPToken_test : public beast::unit_test::Suite
                     // alice and issuer can't create
                     createFail(usd3, alice, tecNO_PERMISSION);
                     createFail(usd3, gw, tecNO_PERMISSION);
-                    usd3.set({.mutableFlags = tmfMPTSetCanTrade});
+                    usd3.set({.flags = tfMPTSetCanTrade});
                     // alice can create
                     createDeleteAMM(usd3, alice);
                 }
@@ -7383,7 +7382,7 @@ class MPToken_test : public beast::unit_test::Suite
 
                 // MPTRequireAuth is set
                 // carol is not authorized by the issuer
-                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                usd.set({.flags = tfMPTSetRequireAuth});
                 env.close();
                 amm.deposit(
                     {.account = carol,
@@ -7429,7 +7428,7 @@ class MPToken_test : public beast::unit_test::Suite
                          .err = Ter(tecNO_AUTH)});
                     // issuer can deposit
                     amm2.deposit({.account = gw, .tokens = 1'000});
-                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    usd2.set({.flags = tfMPTSetCanTransfer});
                     // carol can deposit
                     amm2.deposit({.account = carol, .tokens = 1'000});
                 }
@@ -7499,7 +7498,7 @@ class MPToken_test : public beast::unit_test::Suite
                 usd.set({.flags = tfMPTUnlock});
 
                 // MPTRequireAuth is set
-                usd.set({.mutableFlags = tmfMPTSetRequireAuth});
+                usd.set({.flags = tfMPTSetRequireAuth});
                 usd.authorize({.account = gw, .holder = carol, .flags = tfMPTUnauthorize});
                 // carol can't withdraw
                 amm.withdraw(
@@ -7543,7 +7542,7 @@ class MPToken_test : public beast::unit_test::Suite
                     usd2.authorize({.account = bob, .flags = tfMPTUnauthorize});
                     // Can redeem
                     env(pay(carol, gw, usd2(1)));
-                    usd2.set({.mutableFlags = tmfMPTSetCanTransfer});
+                    usd2.set({.flags = tfMPTSetCanTransfer});
                     // carol can withdraw
                     amm2.withdraw({.account = carol, .asset1Out = usd2(1), .asset2Out = eur(1)});
                 }
@@ -7658,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
@@ -7704,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
@@ -7739,13 +7790,14 @@ public:
         // Dynamic MPT
         testInvalidCreateDynamic(all);
         testInvalidSetDynamic(all);
-        testMutateMPT(all);
-        testMutateCanLock(all);
-        testMutateRequireAuth(all);
-        testMutateCanEscrow(all);
-        testMutateCanTransfer(all);
-        testMutateCanTransfer(all - featureMPTokensV2);
-        testMutateCanClawback(all);
+        testSetMPT(all);
+        testSetCanLock(all);
+        testSetRequireAuth(all);
+        testSetCanEscrow(all);
+        testSetCanTransfer(all);
+        testSetCanTransfer(all - featureMPTokensV2);
+        testSetCanClawback(all);
+        testSetImmutableFlags(all);
 
         // Test offer crossing
         testOfferCrossing(all);
@@ -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 50e8ab4a8d..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
@@ -399,7 +397,8 @@ public:
         BEAST_EXPECT(
             ManifestDisposition::Accepted ==
             cache.applyManifest(
-                makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0)));
+                makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0),
+                ManifestRateLimitCapPolicy::Capped));
         BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first);
         BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk);
 
@@ -411,7 +410,8 @@ public:
         BEAST_EXPECT(
             ManifestDisposition::Accepted ==
             cache.applyManifest(
-                makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1)));
+                makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1),
+                ManifestRateLimitCapPolicy::Capped));
         BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
         BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
         BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -421,7 +421,8 @@ public:
         BEAST_EXPECT(
             ManifestDisposition::BadEphemeralKey ==
             cache.applyManifest(
-                makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2)));
+                makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2),
+                ManifestRateLimitCapPolicy::Capped));
         BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first);
         BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk);
         BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -431,7 +432,8 @@ public:
         // key from a revoked master public key
         BEAST_EXPECT(
             ManifestDisposition::Accepted ==
-            cache.applyManifest(makeRevocation(sk, KeyType::Ed25519)));
+            cache.applyManifest(
+                makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped));
         BEAST_EXPECT(cache.revoked(pk));
         BEAST_EXPECT(cache.getSigningKey(pk) == pk);
         BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first);
@@ -902,39 +904,69 @@ public:
             // applyManifest should accept new manifests with
             // higher sequence numbers
             auto const seq0 = cache.sequence();
-            BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Accepted);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
             BEAST_EXPECT(cache.sequence() > seq0);
 
             auto const seq1 = cache.sequence();
-            BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
             BEAST_EXPECT(cache.sequence() == seq1);
 
-            BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Accepted);
-            BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale);
-            BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
 
-            BEAST_EXPECT(cache.applyManifest(clone(sA2)) == ManifestDisposition::BadEphemeralKey);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA2), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::BadEphemeralKey);
 
             // applyManifest should accept manifests with max sequence numbers
             // that revoke the master public key
             BEAST_EXPECT(!cache.revoked(pkA));
             BEAST_EXPECT(sAMax.revoked());
-            BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Accepted);
-            BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Stale);
-            BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale);
-            BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
             BEAST_EXPECT(cache.revoked(pkA));
 
             // applyManifest should reject manifests with invalid signatures
-            BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Accepted);
-            BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Stale);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Stale);
             BEAST_EXPECT(!deserializeManifest(fake));
-            BEAST_EXPECT(cache.applyManifest(clone(sB1)) == ManifestDisposition::Invalid);
-            BEAST_EXPECT(cache.applyManifest(clone(sB2)) == ManifestDisposition::Accepted);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sB1), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Invalid);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sB2), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
 
             auto const sC0 = makeManifest(
                 kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47);
-            BEAST_EXPECT(cache.applyManifest(clone(sC0)) == ManifestDisposition::BadMasterKey);
+            BEAST_EXPECT(
+                cache.applyManifest(clone(sC0), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::BadMasterKey);
         }
 
         testLoadStore(cache);
diff --git a/src/test/app/NFTokenAuth_test.cpp b/src/test/app/NFTokenAuth_test.cpp
index 66716a13b7..e82a47a5d7 100644
--- a/src/test/app/NFTokenAuth_test.cpp
+++ b/src/test/app/NFTokenAuth_test.cpp
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -43,7 +44,8 @@ class NFTokenAuth_test : public beast::unit_test::Suite
         env(token::mint(account, 0), token::XferFee(xfee), Txflags(tfTransferable));
         env.close();
 
-        auto const sellIdx = keylet::nftokenOffer(account, env.seq(account)).key;
+        auto const sellIdx =
+            keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key;
         env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken));
         env.close();
 
@@ -74,7 +76,7 @@ public:
         env(pay(g1, a1, usd(1000)));
 
         auto const [nftID, _] = mintAndOfferNFT(env, a2, drops(1));
-        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
 
         // It should be possible to create a buy offer even if NFT owner is not
         // authorized
@@ -179,7 +181,7 @@ public:
         env(pay(g1, a2, usd(10)));
         env.close();
 
-        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
         env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2));
         env.close();
 
@@ -244,7 +246,7 @@ public:
             // Authorizing trustline to make an offer creation possible
             env(trust(g1, usd(0), a2, tfSetfAuth));
             env.close();
-            auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
+            auto const sellIdx = keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key;
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
             env.close();
             //
@@ -268,7 +270,7 @@ public:
         }
         else
         {
-            auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key;
+            auto const sellIdx = keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key;
 
             // Old behavior: sell offer can be created without authorization
             env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken));
@@ -353,7 +355,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -422,7 +424,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -483,7 +485,7 @@ public:
         env.close();
 
         auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10));
-        auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
         env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2));
         env.close();
 
@@ -559,7 +561,7 @@ public:
         auto const [nftID, minterSellIdx] = mintAndOfferNFT(env, minter, drops(1), 1);
         env(token::acceptSellOffer(a1, minterSellIdx));
 
-        uint256 const sellIdx = keylet::nftokenOffer(a1, env.seq(a1)).key;
+        uint256 const sellIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key;
         env(token::createOffer(a1, nftID, usd(100)), Txflags(tfSellNFToken));
 
         if (features[fixEnforceNFTokenTrustlineV2])
diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp
index 140fe2de15..cc54c4feb5 100644
--- a/src/test/app/NFTokenBurn_test.cpp
+++ b/src/test/app/NFTokenBurn_test.cpp
@@ -25,12 +25,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -76,7 +78,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         for (uint32_t i = 0; i < tokenCancelCount; ++i)
         {
             // Create sell offer
-            offerIndexes.push_back(keylet::nftokenOffer(owner, env.seq(owner)).key);
+            offerIndexes.push_back(
+                keylet::nftokenOffer(owner, SeqProxy::rawSequence(env.seq(owner))).key);
             env(token::createOffer(owner, nftokenID, drops(1)), Txflags(tfSellNFToken));
             env.close();
         }
@@ -114,33 +117,30 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 std::cout << "Ledger state is not array!" << std::endl;
                 return;
             }
-            for (json::UInt i = 0; i < state.size(); ++i)
+            for (auto& i : state)
             {
-                if (state[i].isMember(sfNFTokens.jsonName) &&
-                    state[i][sfNFTokens.jsonName].isArray())
+                if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                 {
-                    std::uint32_t const tokenCount = state[i][sfNFTokens.jsonName].size();
-                    std::cout << tokenCount << " NFtokens in page "
-                              << state[i][jss::index].asString() << std::endl;
+                    std::uint32_t const tokenCount = i[sfNFTokens.jsonName].size();
+                    std::cout << tokenCount << " NFtokens in page " << i[jss::index].asString()
+                              << std::endl;
 
                     if (vol == Volume::Noisy)
                     {
-                        std::cout << state[i].toStyledString() << std::endl;
+                        std::cout << i.toStyledString() << std::endl;
                     }
                     else
                     {
                         if (tokenCount > 0)
                         {
-                            std::cout
-                                << "first: " << state[i][sfNFTokens.jsonName][0u].toStyledString()
-                                << std::endl;
+                            std::cout << "first: " << i[sfNFTokens.jsonName][0u].toStyledString()
+                                      << std::endl;
                         }
                         if (tokenCount > 1)
                         {
-                            std::cout
-                                << "last: "
-                                << state[i][sfNFTokens.jsonName][tokenCount - 1].toStyledString()
-                                << std::endl;
+                            std::cout << "last: "
+                                      << i[sfNFTokens.jsonName][tokenCount - 1].toStyledString()
+                                      << std::endl;
                         }
                     }
                 }
@@ -237,7 +237,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 // We do the same work on alice and minter, so make a lambda.
                 auto xferNFT = [&env, &becky](AcctStat& acct, auto& iter) {
                     uint256 const offerIndex =
-                        keylet::nftokenOffer(acct.acct, env.seq(acct.acct)).key;
+                        keylet::nftokenOffer(acct.acct, SeqProxy::rawSequence(env.seq(acct.acct)))
+                            .key;
                     env(token::createOffer(acct, *iter, XRP(0)), Txflags(tfSellNFToken));
                     env.close();
                     env(token::acceptSellOffer(becky, offerIndex));
@@ -415,12 +416,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
@@ -455,11 +455,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 json::Value jrr = env.rpc("json", "ledger_data", to_string(jvParams));
 
-                json::Value& state = jrr[jss::result][jss::state];
+                json::Value const& state = jrr[jss::result][jss::state];
 
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto const& i : state)
                 {
-                    BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName));
+                    BEAST_EXPECT(!i.isMember(sfNFTokens.jsonName));
                 }
             }
         };
@@ -753,8 +753,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // We're going to fire an Invariant failure that is difficult to
             // cause.  We do it here because the tools are here.
             //
-            // See Invariants_test.cpp for examples of other invariant tests
-            // that this one is modeled after.
+            // See InvariantsMisc_test.cpp for examples of other invariant
+            // tests that this one is modeled after.
 
             // Generate three closely packed NFTokenPages.
             std::vector nfts = genPackedTokens();
@@ -791,7 +791,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
@@ -827,7 +827,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
@@ -871,7 +871,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             }
 
             // Becky creates a buy offer
-            uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftokenID, drops(1)), token::Owner(alice));
             env.close();
 
@@ -1046,7 +1047,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 env.close();
 
                 // Minter creates an offer for the NFToken.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nfts.back(), XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -1070,12 +1072,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
@@ -1117,7 +1118,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             nfts.pop_back();
 
             // alice creates an offer for the NFToken.
-            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, last32NFTs.back(), XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -1151,7 +1153,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
         for (uint256 const nftID : last32NFTs)
         {
             // minter creates an offer for the NFToken.
-            uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
diff --git a/src/test/app/NFTokenDir_test.cpp b/src/test/app/NFTokenDir_test.cpp
index 7dd0b14fe5..7770741a36 100644
--- a/src/test/app/NFTokenDir_test.cpp
+++ b/src/test/app/NFTokenDir_test.cpp
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -142,7 +143,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
         std::vector offers;
         for (uint256 const& nftID : nftIDs)
         {
-            offers.emplace_back(keylet::nftokenOffer(issuer, env.seq(issuer)).key);
+            offers.emplace_back(
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key);
             env(token::createOffer(issuer, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
         }
@@ -214,7 +216,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
+                offers.emplace_back(
+                    keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
                     Txflags(tfSellNFToken));
@@ -237,7 +240,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
             // generates a non-tesSUCCESS error code.
             for (uint256 const& nftID : nftIDs)
             {
-                uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const offerID =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -418,7 +422,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
+                offers.emplace_back(
+                    keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
                     Txflags(tfSellNFToken));
@@ -445,7 +450,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
             // generates a non-tesSUCCESS error code.
             for (uint256 const& nftID : nftIDs)
             {
-                uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const offerID =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
                 env.close();
 
@@ -648,7 +654,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
             env.close();
 
             // Create an offer to give the NFT to buyer for free.
-            offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
+            offers.emplace_back(
+                keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key);
             env(token::createOffer(account, nftID, XRP(0)),
                 token::Destination(buyer),
                 Txflags(tfSellNFToken));
@@ -684,7 +691,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
         // a non-tesSUCCESS error code.
         for (uint256 const& nftID : nftIDs)
         {
-            uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerID =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -820,7 +828,8 @@ class NFTokenDir_test : public beast::unit_test::Suite
                 env.close();
 
                 // Create an offer to give the NFT to buyer for free.
-                offers[i].emplace_back(keylet::nftokenOffer(account, env.seq(account)).key);
+                offers[i].emplace_back(
+                    keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key);
                 env(token::createOffer(account, nftID, XRP(0)),
                     token::Destination(buyer),
                     Txflags(tfSellNFToken));
diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp
index acd54ae26a..89c14fbfc5 100644
--- a/src/test/app/NFToken_test.cpp
+++ b/src/test/app/NFToken_test.cpp
@@ -29,12 +29,15 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -143,7 +146,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             env.fund(XRP(10000), alice);
             env.close();
-            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId1, XRP(1000)), token::Owner(master));
             env.close();
 
@@ -861,7 +865,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == 1);
 
         // This is the offer we'll try to cancel.
-        uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+        uint256 const buyerOfferIndex =
+            keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
         env(token::createOffer(buyer, nftAlice0ID, XRP(1)), token::Owner(alice), Ter(tesSUCCESS));
         env.close();
         BEAST_EXPECT(ownerCount(env, buyer) == 1);
@@ -904,7 +909,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // List of offer IDs containing zero is invalid.
             // craftedIndex is not a valid offer index but it is not zero.
-            auto const craftedIndex = keylet::nftokenOffer(gw, env.seq(gw)).key;
+            auto const craftedIndex =
+                keylet::nftokenOffer(gw, SeqProxy::rawSequence(env.seq(gw))).key;
             env(token::cancelOffer(buyer, {buyerOfferIndex, uint256{}, craftedIndex}),
                 Ter(temMALFORMED));
             env.close();
@@ -944,7 +950,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         {
             // gw attempts to cancel a Check as through it is an NFTokenOffer.
-            auto const gwCheckId = keylet::check(gw, env.seq(gw)).key;
+            auto const gwCheckId = keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key;
             env(check::create(gw, env.master, XRP(300)));
             env.close();
 
@@ -1006,32 +1012,37 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // alice creates sell offers for her nfts.
-        uint256 const plainOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const plainOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftAlice0ID, XRP(10)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const audOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const audOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftAlice0ID, gwAUD(30)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const xrpOnlyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const xrpOnlyOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftXrpOnlyID, XRP(20)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
-        uint256 const noXferOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const noXferOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftNoXferID, XRP(30)), Txflags(tfSellNFToken));
         env.close();
         aliceCount++;
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // alice creates a sell offer that will expire soon.
-        uint256 const aliceExpOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const aliceExpOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftNoXferID, XRP(40)),
             Txflags(tfSellNFToken),
             token::Expiration(lastClose(env) + 5));
@@ -1040,7 +1051,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == aliceCount);
 
         // buyer creates a Buy offer that will expire soon.
-        uint256 const buyerExpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+        uint256 const buyerExpOfferIndex =
+            keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
         env(token::createOffer(buyer, nftAlice0ID, XRP(40)),
             token::Owner(alice),
             token::Expiration(lastClose(env) + 5));
@@ -1108,7 +1120,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
 
         // The buy offer must be present in the ledger.
-        uint256 const missingOfferIndex = keylet::nftokenOffer(alice, 1).key;
+        uint256 const missingOfferIndex = keylet::nftokenOffer(alice, SeqProxy::rawSequence(1)).key;
         env(token::acceptBuyOffer(buyer, missingOfferIndex), Ter(tecOBJECT_NOT_FOUND));
         env.close();
         BEAST_EXPECT(ownerCount(env, buyer) == buyerCount);
@@ -1171,7 +1183,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // corresponding buy and sell offers.
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(29)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1204,7 +1217,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         }
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(31)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1243,7 +1257,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // preclaim buy
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftAlice0ID, gwAUD(30)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1270,7 +1285,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice gives her NFT to gw, so alice no longer owns nftAlice0.
             {
-                uint256 const offerIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+                uint256 const offerIndex =
+                    keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
                 env(token::createOffer(alice, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(gw, offerIndex));
@@ -1295,7 +1311,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // preclaim sell
         {
             // buyer creates a buy offer for one of alice's nfts.
-            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftXrpOnlyID, XRP(30)), token::Owner(alice));
             env.close();
             buyerCount++;
@@ -1323,7 +1340,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // buyer attempting to accept one of alice's offers with
             // insufficient funds.
             {
-                uint256 const offerIndex = keylet::nftokenOffer(gw, env.seq(gw)).key;
+                uint256 const offerIndex =
+                    keylet::nftokenOffer(gw, SeqProxy::rawSequence(env.seq(gw))).key;
                 env(token::createOffer(gw, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(alice, offerIndex));
@@ -1376,7 +1394,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(minter1, 0u), token::Issuer(alice), Txflags(flags));
             env.close();
 
-            uint256 const offerIndex = keylet::nftokenOffer(minter1, env.seq(minter1)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(minter1, SeqProxy::rawSequence(env.seq(minter1))).key;
             env(token::createOffer(minter1, nftID, XRP(0)), Txflags(tfSellNFToken));
             env.close();
 
@@ -1479,13 +1498,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             BEAST_EXPECT(ownerCount(env, alice) == 2);
-            uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftIOUsOkayID, gwAUD(50)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 3);
 
             BEAST_EXPECT(ownerCount(env, buyer) == 1);
-            uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyerOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftIOUsOkayID, gwAUD(50)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(ownerCount(env, buyer) == 2);
@@ -1588,7 +1609,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 env.close();
 
                 // becky buys the nft for 1 drop.
-                uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(alice));
                 env.close();
                 env(token::acceptBuyOffer(alice, beckyBuyOfferIndex));
@@ -1596,14 +1618,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
                 // becky attempts to sell the nft for AUD.
                 TER const createOfferTER = (xferFee != 0u) ? TER(tecNO_LINE) : TER(tesSUCCESS);
-                uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckyOfferIndex =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)),
                     Txflags(tfSellNFToken),
                     Ter(createOfferTER));
                 env.close();
 
                 // cheri offers to buy the nft for CAD.
-                uint256 const cheriOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key;
+                uint256 const cheriOfferIndex =
+                    keylet::nftokenOffer(cheri, SeqProxy::rawSequence(env.seq(cheri))).key;
                 env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)),
                     token::Owner(becky),
                     Ter(createOfferTER));
@@ -1641,14 +1665,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                         break;
                 }
                 // becky buys the nft for 1 drop.
-                uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(alice));
                 env.close();
                 env(token::acceptBuyOffer(alice, beckyBuyOfferIndex));
                 env.close();
 
                 // becky sells the nft for AUD.
-                uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckySellOfferIndex =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
                 env.close();
                 env(token::acceptSellOffer(cheri, beckySellOfferIndex));
@@ -1659,7 +1685,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
                 // becky buys the nft back for CAD.
                 uint256 const beckyBuyBackOfferIndex =
-                    keylet::nftokenOffer(becky, env.seq(becky)).key;
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftAutoTrustID, gwCAD(50)), token::Owner(cheri));
                 env.close();
                 env(token::acceptBuyOffer(cheri, beckyBuyBackOfferIndex));
@@ -1679,7 +1705,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 env.close();
 
                 // alice sells the nft using AUD.
-                uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+                uint256 const aliceSellOfferIndex =
+                    keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
                 env(token::createOffer(alice, nftNoAutoTrustID, gwAUD(200)),
                     Txflags(tfSellNFToken));
                 env.close();
@@ -1696,7 +1723,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                     Txflags(tfSellNFToken),
                     Ter(tecNO_LINE));
                 env.close();
-                uint256 const cheriSellOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key;
+                uint256 const cheriSellOfferIndex =
+                    keylet::nftokenOffer(cheri, SeqProxy::rawSequence(env.seq(cheri))).key;
                 env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)),
                     Txflags(tfSellNFToken));
                 env.close();
@@ -1743,7 +1771,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 Ter(tefNFTOKEN_IS_NOT_TRANSFERABLE));
 
             // alice offers to sell the nft and becky accepts the offer.
-            uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceSellOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftAliceNoTransferID, XRP(20)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(becky, aliceSellOfferIndex));
@@ -1771,7 +1800,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice offers to buy the nft back from becky.  becky accepts
             // the offer.
-            uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceBuyOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftAliceNoTransferID, XRP(22)), token::Owner(becky));
             env.close();
             env(token::acceptBuyOffer(becky, aliceBuyOfferIndex));
@@ -1827,7 +1857,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter successfully offers their nft for sale.
             BEAST_EXPECT(ownerCount(env, minter) == 1);
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftMinterNoTransferID, XRP(22)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, minter) == 2);
@@ -1862,7 +1893,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // alice can create an offer to buy the nft.
             BEAST_EXPECT(ownerCount(env, alice) == 0);
-            uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceBuyOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftMinterNoTransferID, XRP(25)), token::Owner(becky));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 1);
@@ -1877,7 +1909,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Now minter can create an offer to buy the nft.
             BEAST_EXPECT(ownerCount(env, minter) == 0);
-            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftMinterNoTransferID, XRP(26)), token::Owner(becky));
             env.close();
             BEAST_EXPECT(ownerCount(env, minter) == 1);
@@ -1916,12 +1949,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, alice) == 1);
 
             // Both alice and becky can make offers for alice's nft.
-            uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceSellOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftAliceID, XRP(20)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 2);
 
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAliceID, XRP(21)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 2);
@@ -1933,7 +1968,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, becky) == 2);
 
             // becky offers to sell the nft.
-            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAliceID, XRP(22)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 0);
@@ -1948,7 +1984,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, minter) == 1);
 
             // minter offers to sell the nft.
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftAliceID, XRP(23)), Txflags(tfSellNFToken));
             env.close();
             BEAST_EXPECT(ownerCount(env, alice) == 0);
@@ -2030,7 +2067,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2042,7 +2080,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to carol.  alice's balance should not change.
-            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, beckySellOfferIndex));
@@ -2052,7 +2091,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990));
 
             // minter buys nft from carol.  alice's balance should not change.
-            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, minterBuyOfferIndex));
@@ -2064,7 +2104,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells the nft to alice.  gwXAU balances should finish
             // where they started.
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, minterSellOfferIndex));
@@ -2091,7 +2132,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2103,7 +2145,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to carol.  alice's balance goes up.
-            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, beckySellOfferIndex));
@@ -2114,7 +2157,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990));
 
             // minter buys nft from carol.  alice's balance goes up.
-            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, minterBuyOfferIndex));
@@ -2127,7 +2171,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells the nft to alice.  Because alice is part of the
             // transaction no transfer fee is removed.
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, minterSellOfferIndex));
@@ -2172,7 +2217,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Becky buys the nft for XAU(10).  Check balances.
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice));
             env.close();
             BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000));
@@ -2184,7 +2230,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990));
 
             // becky sells nft to minter.  alice's balance goes up.
-            uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckySellOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, gwXAU(100)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(minter, beckySellOfferIndex));
@@ -2195,7 +2242,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900));
 
             // carol buys nft from minter.  alice's balance goes up.
-            uint256 const carolBuyOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
+            uint256 const carolBuyOfferIndex =
+                keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key;
             env(token::createOffer(carol, nftID, gwXAU(10)), token::Owner(minter));
             env.close();
             env(token::acceptBuyOffer(minter, carolBuyOfferIndex));
@@ -2208,7 +2256,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // carol sells the nft to alice.  Because alice is part of the
             // transaction no transfer fee is removed.
-            uint256 const carolSellOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key;
+            uint256 const carolSellOfferIndex =
+                keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key;
             env(token::createOffer(carol, nftID, gwXAU(10)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, carolSellOfferIndex));
@@ -2249,7 +2298,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // alice there should be no transfer fee.
             STAmount aliceBalance = env.balance(alice);
             STAmount minterBalance = env.balance(minter);
-            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, XRP(1)), token::Owner(alice));
             env.close();
             env(token::acceptBuyOffer(alice, minterBuyOfferIndex));
@@ -2263,7 +2313,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // alice does not get any transfer fee.
             auto pmt = drops(50000);
             STAmount carolBalance = env.balance(carol);
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, pmt), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, minterSellOfferIndex));
@@ -2277,7 +2328,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // carol sells to becky. This is the smallest amount to pay for a
             // transfer that enables a transfer fee of 1 basis point.
             STAmount beckyBalance = env.balance(becky);
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             pmt = drops(50001);
             env(token::createOffer(becky, nftID, pmt), token::Owner(carol));
             env.close();
@@ -2322,7 +2374,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // alice there should be no transfer fee.
             STAmount aliceBalance = env.balance(alice, gwXAU);
             STAmount minterBalance = env.balance(minter, gwXAU);
-            uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterBuyOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, tinyXAU), token::Owner(alice));
             env.close();
             env(token::acceptBuyOffer(alice, minterBuyOfferIndex));
@@ -2334,7 +2387,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // minter sells to carol.
             STAmount carolBalance = env.balance(carol, gwXAU);
-            uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const minterSellOfferIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftID, tinyXAU), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(carol, minterSellOfferIndex));
@@ -2352,7 +2406,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             STAmount const cheapNFT(gwXAU, STAmount::kMinValue, STAmount::kMinOffset + 5);
 
             STAmount beckyBalance = env.balance(becky, gwXAU);
-            uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftID, cheapNFT), token::Owner(carol));
             env.close();
             env(token::acceptBuyOffer(carol, beckyBuyOfferIndex));
@@ -2582,22 +2637,26 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Test how adding a Destination field to an offer affects permissions
         // for canceling offers.
         {
-            uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToIssuer =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(issuer),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBuyer =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToMinter =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(minter));
 
-            uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToBuyer =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(buyer));
@@ -2639,7 +2698,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // accepting that offer.
         {
             uint256 const offerMinterSellsToBuyer =
-                keylet::nftokenOffer(minter, env.seq(minter)).key;
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
                 Txflags(tfSellNFToken));
@@ -2668,7 +2727,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // accepting that offer.
         {
             uint256 const offerMinterBuysFromBuyer =
-                keylet::nftokenOffer(minter, env.seq(minter)).key;
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Owner(buyer),
                 token::Destination(buyer));
@@ -2696,7 +2755,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // destination must act as a broker.  The NFToken owner may not
             // simply accept the offer.
             uint256 const offerBuyerBuysFromMinter =
-                keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(broker));
@@ -2719,12 +2778,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Show that a sell offer's Destination can broker that sell offer
         // to another account.
         {
-            uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBroker =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID, drops(1)), token::Owner(minter));
 
             env.close();
@@ -2756,15 +2817,18 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Destination doesn't match, but can complete if the Destination
         // does match.
         {
-            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Destination(minter),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBuyer =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)), token::Owner(buyer));
 
-            uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToBuyer =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftokenID, drops(1)), token::Owner(buyer));
 
             env.close();
@@ -2812,12 +2876,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         // Show that if a buy and a sell offer both have the same destination,
         // then that destination can broker the offers.
         {
-            uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToBroker =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerBuyerToBroker = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToBroker =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID, drops(1)),
                 token::Owner(minter),
                 token::Destination(broker));
@@ -2887,7 +2953,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer (allowed now) then cancel
         {
-            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -2900,7 +2967,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer, enable flag, then cancel
         {
-            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -2919,7 +2987,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // create offer then transfer
         {
-            uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerIndex =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
 
             env(token::createOffer(minter, nftokenID, drops(1)),
                 token::Destination(buyer),
@@ -3006,23 +3075,27 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToIssuer =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Destination(issuer),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerMinterToAnyone = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offerMinterToAnyone =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const offerIssuerToMinter =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
 
-            uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerToMinter =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
@@ -3082,13 +3155,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offer0 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const offer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const offer1 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
@@ -3153,7 +3228,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3172,13 +3248,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const offer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offer0 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
             buyerCount++;
 
-            uint256 const offer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offer1 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Owner(minter),
                 token::Expiration(expiration));
@@ -3241,7 +3319,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3260,23 +3339,27 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
             minterCount++;
 
-            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)), token::Owner(minter));
             buyerCount++;
 
-            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)), token::Owner(minter));
             buyerCount++;
 
@@ -3335,7 +3418,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3354,18 +3438,22 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)), Txflags(tfSellNFToken));
 
-            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID1, drops(1)), Txflags(tfSellNFToken));
 
-            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
 
-            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
@@ -3416,7 +3504,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3435,22 +3524,26 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         {
             std::uint32_t const expiration = lastClose(env) + 25;
 
-            uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer0 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key;
+            uint256 const sellOffer1 =
+                keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
             env(token::createOffer(minter, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 Txflags(tfSellNFToken));
 
-            uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer0 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
 
-            uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyOffer1 =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID1, drops(1)),
                 token::Expiration(expiration),
                 token::Owner(minter));
@@ -3492,7 +3585,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Transfer nftokenID0 back to minter so we start the next test in
             // a simple place.
-            uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerSellBack =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftokenID0, XRP(0)),
                 Txflags(tfSellNFToken),
                 token::Destination(minter));
@@ -3530,7 +3624,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.close();
 
         // Anyone can cancel an expired offer.
-        uint256 const expiredOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const expiredOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             Txflags(tfSellNFToken),
@@ -3552,7 +3647,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // Create a couple of offers with a destination.  Those offers
         // should be cancellable by the creator and the destination.
-        uint256 const dest1OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const dest1OfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             token::Destination(becky),
@@ -3570,7 +3666,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ownerCount(env, alice) == 1);
 
         // alice can cancel her own offer, even if becky is the destination.
-        uint256 const dest2OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const dest2OfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
 
         env(token::createOffer(alice, nftokenID, XRP(1000)),
             token::Destination(becky),
@@ -3589,7 +3686,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env(token::mint(minter, 0), token::Issuer(alice), Txflags(tfTransferable));
         env.close();
 
-        uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+        uint256 const minterOfferIndex =
+            keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
 
         env(token::createOffer(minter, mintersNFTokenID, XRP(1000)), Txflags(tfSellNFToken));
         env.close();
@@ -3647,7 +3745,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(nftAcct, 0), token::Uri(uri), Txflags(tfTransferable));
             env.close();
 
-            offerIndexes.push_back(keylet::nftokenOffer(offerAcct, env.seq(offerAcct)).key);
+            offerIndexes.push_back(
+                keylet::nftokenOffer(offerAcct, SeqProxy::rawSequence(env.seq(offerAcct))).key);
             env(token::createOffer(offerAcct, nftokenID, drops(1)),
                 token::Owner(nftAcct),
                 token::Expiration(lastClose(env) + 5));
@@ -3682,7 +3781,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env(token::mint(alice, 0), token::Uri(uri), Txflags(tfTransferable));
             env.close();
 
-            offerIndexes.push_back(keylet::nftokenOffer(alice, env.seq(alice)).key);
+            offerIndexes.push_back(
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key);
             env(token::createOffer(alice, nftokenID, drops(1)), Txflags(tfSellNFToken));
             env.close();
 
@@ -3793,13 +3893,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3835,13 +3937,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3884,13 +3988,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3926,13 +4032,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates their offer.  Note: a buy offer can never
                 // offer zero.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter));
                 env.close();
 
@@ -3999,14 +4107,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT();
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, gwXAU(1000)), Txflags(tfSellNFToken));
                 env.close();
 
                 {
                     // buyer creates an offer for more XAU than they currently
                     // own.
-                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex =
+                        keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                     env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter));
                     env.close();
 
@@ -4023,7 +4133,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 {
                     // buyer creates an offer for less that what minter is
                     // asking.
-                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex =
+                        keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                     env(token::createOffer(buyer, nftID, gwXAU(999)), token::Owner(minter));
                     env.close();
 
@@ -4039,7 +4150,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 }
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4076,13 +4188,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee);
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
                 {
                     // buyer creates an offer for more XAU than they currently
                     // own.
-                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex =
+                        keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                     env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter));
                     env.close();
 
@@ -4099,7 +4213,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 {
                     // buyer creates an offer for less that what minter is
                     // asking.
-                    uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                    uint256 const buyOfferIndex =
+                        keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                     env(token::createOffer(buyer, nftID, gwXAU(899)), token::Owner(minter));
                     env.close();
 
@@ -4114,7 +4229,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                     env.close();
                 }
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4154,12 +4270,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee / 2);  // 25%
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4191,12 +4309,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 uint256 const nftID = mintNFT(kMaxTransferFee / 2);  // 25%
 
                 // minter creates their offer.
-                uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key;
+                uint256 const minterOfferIndex =
+                    keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key;
                 env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken));
                 env.close();
 
                 // buyer creates a large enough offer.
-                uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+                uint256 const buyOfferIndex =
+                    keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
                 env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter));
                 env.close();
 
@@ -4246,9 +4366,11 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(nftCount(env, buyer2) == 0);
 
         // Both buyer1 and buyer2 create buy offers for nftId.
-        uint256 const buyer1OfferIndex = keylet::nftokenOffer(buyer1, env.seq(buyer1)).key;
+        uint256 const buyer1OfferIndex =
+            keylet::nftokenOffer(buyer1, SeqProxy::rawSequence(env.seq(buyer1))).key;
         env(token::createOffer(buyer1, nftId, XRP(100)), token::Owner(issuer));
-        uint256 const buyer2OfferIndex = keylet::nftokenOffer(buyer2, env.seq(buyer2)).key;
+        uint256 const buyer2OfferIndex =
+            keylet::nftokenOffer(buyer2, SeqProxy::rawSequence(env.seq(buyer2))).key;
         env(token::createOffer(buyer2, nftId, XRP(100)), token::Owner(issuer));
         env.close();
 
@@ -4336,7 +4458,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // NFTokenCreateOffer
         BEAST_EXPECT(ownerCount(env, buyer) == 10);
-        uint256 const offerIndex0 = keylet::nftokenOffer(buyer, buyerTicketSeq).key;
+        uint256 const offerIndex0 =
+            keylet::nftokenOffer(buyer, SeqProxy::rawSequence(buyerTicketSeq)).key;
         env(token::createOffer(buyer, nftId, XRP(1)),
             token::Owner(issuer),
             ticket::Use(buyerTicketSeq++));
@@ -4351,7 +4474,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(ticketCount(env, buyer) == 8);
 
         // NFTokenCreateOffer.  buyer tries again.
-        uint256 const offerIndex1 = keylet::nftokenOffer(buyer, buyerTicketSeq).key;
+        uint256 const offerIndex1 =
+            keylet::nftokenOffer(buyer, SeqProxy::rawSequence(buyerTicketSeq)).key;
         env(token::createOffer(buyer, nftId, XRP(2)),
             token::Owner(issuer),
             ticket::Use(buyerTicketSeq++));
@@ -4428,7 +4552,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env(token::createOffer(becky, nftId, XRP(2)), token::Owner(minter));
         env.close();
 
-        uint256 const carlaOfferIndex = keylet::nftokenOffer(carla, env.seq(carla)).key;
+        uint256 const carlaOfferIndex =
+            keylet::nftokenOffer(carla, SeqProxy::rawSequence(env.seq(carla))).key;
         env(token::createOffer(carla, nftId, XRP(3)), token::Owner(minter));
         env.close();
 
@@ -4667,6 +4792,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)
     {
@@ -4706,25 +4912,29 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             TER const offerCreateTER = temBAD_AMOUNT;
 
             // Make offers with negative amounts for the NFTs
-            uint256 const sellNegXrpOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const sellNegXrpOfferIndex =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftID0, XRP(-2)),
                 Txflags(tfSellNFToken),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const sellNegIouOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const sellNegIouOfferIndex =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftID1, gwXAU(-2)),
                 Txflags(tfSellNFToken),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const buyNegXrpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyNegXrpOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftID0, XRP(-1)),
                 token::Owner(issuer),
                 Ter(offerCreateTER));
             env.close();
 
-            uint256 const buyNegIouOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const buyNegIouOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::createOffer(buyer, nftID1, gwXAU(-1)),
                 token::Owner(issuer),
                 Ter(offerCreateTER));
@@ -4887,7 +5097,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                                       uint256 const& nftID,
                                       STAmount const& amount,
                                       std::optional const terCode = {}) {
-                uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key;
+                uint256 const offerID =
+                    keylet::nftokenOffer(offerer, SeqProxy::rawSequence(env.seq(offerer))).key;
                 env(token::createOffer(offerer, nftID, amount),
                     token::Owner(owner),
                     terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS)));
@@ -4900,7 +5111,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                                        uint256 const& nftID,
                                        STAmount const& amount,
                                        std::optional const terCode = {}) {
-                uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key;
+                uint256 const offerID =
+                    keylet::nftokenOffer(offerer, SeqProxy::rawSequence(env.seq(offerer))).key;
                 env(token::createOffer(offerer, nftID, amount),
                     Txflags(tfSellNFToken),
                     terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS)));
@@ -5413,10 +5625,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // Bob creates a buy offer for 5 XRP.  Alice creates a sell offer
         // for 0 XRP.
-        uint256 const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
+        uint256 const bobBuyOfferIndex =
+            keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
         env(token::createOffer(bob, nftId, XRP(5)), token::Owner(alice));
 
-        uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key;
+        uint256 const aliceSellOfferIndex =
+            keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(token::createOffer(alice, nftId, XRP(0)),
             token::Destination(bob),
             Txflags(tfSellNFToken));
@@ -5430,7 +5644,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         BEAST_EXPECT(env.le(keylet::nftokenOffer(bobBuyOfferIndex)));
 
         // Bob creates a sell offer for the gift NFT from alice.
-        uint256 const bobSellOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
+        uint256 const bobSellOfferIndex =
+            keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
         env(token::createOffer(bob, nftId, XRP(4)), Txflags(tfSellNFToken));
         env.close();
 
@@ -6047,7 +6262,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 token::Amount(XRP(10)),
                 token::Destination(buyer),
                 token::Expiration(lastClose(env) + 25));
-            uint256 const offerAliceSellsToBuyer = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceSellsToBuyer =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::cancelOffer(alice, {offerAliceSellsToBuyer}));
             env.close();
 
@@ -6056,7 +6272,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
                 token::Amount(XRP(10)),
                 token::Destination(alice),
                 token::Expiration(lastClose(env) + 25));
-            uint256 const offerBuyerSellsToAlice = keylet::nftokenOffer(buyer, env.seq(buyer)).key;
+            uint256 const offerBuyerSellsToAlice =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
             env(token::cancelOffer(alice, {offerBuyerSellsToAlice}));
             env.close();
 
@@ -6110,84 +6327,162 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         env.fund(XRP(10000), alice, bob, broker);
         env.close();
 
-        // Verify `nftoken_id` value equals to the NFTokenID that was
-        // changed in the most recent NFTokenMint or NFTokenAcceptOffer
-        // transaction
-        auto verifyNFTokenID = [&](uint256 const& actualNftID) {
+        // Transaction metadata is not always reported under the same field
+        // name: the `ledger` RPC uses `metaData`, the others use `meta`.
+        auto const getMeta = [](json::Value const& tx) -> json::Value const* {
+            if (tx.isMember(jss::meta))
+                return &tx[jss::meta];
+            if (tx.isMember(jss::metaData))
+                return &tx[jss::metaData];
+            return nullptr;
+        };
+
+        // Neither is the transaction hash: api_version 1 nests the
+        // transaction under `tx`, later versions use `tx_json`, and some
+        // responses put the hash on the entry itself.
+        auto const getHash = [](json::Value const& entry) -> std::string {
+            if (entry.isMember(jss::tx) && entry[jss::tx].isMember(jss::hash))
+                return entry[jss::tx][jss::hash].asString();
+            if (entry.isMember(jss::tx_json) && entry[jss::tx_json].isMember(jss::hash))
+                return entry[jss::tx_json][jss::hash].asString();
+            return entry[jss::hash].asString();
+        };
+
+        // Run `verifyMeta` against the metadata of the most recent
+        // transaction as reported by the `tx`, `ledger` and `account_tx`
+        // RPCs, so that the synthetic fields are checked in every response
+        // that carries them. Runs under both api_version 1 (`tx`/`meta`)
+        // and the latest api_version (`tx_json`/synthetic fields alongside
+        // it), since the two versions place fields differently.
+        auto verifyMetaInAllResponses = [&](auto verifyMeta) {
             // Get the hash for the most recent transaction.
             std::string const txHash{
                 env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
 
             env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
 
-            // Expect nftokens_id field
-            if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id)))
-                return;
+            for (unsigned const apiVersion :
+                 {unsigned{rpc::kApiMinimumSupportedVersion},
+                  unsigned{rpc::kApiMaximumSupportedVersion}})
+            {
+                // Test 1: Check tx RPC response
+                json::Value const txResult = env.rpc(apiVersion, "tx", txHash)[jss::result];
+                verifyMeta(txResult[jss::meta]);
 
-            // Check the value of NFT ID in the meta with the
-            // actual value
-            uint256 nftID;
-            BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString()));
-            BEAST_EXPECT(nftID == actualNftID);
+                // Test 2: Check ledger RPC response with expanded
+                // transactions
+                json::Value ledgerParams;
+                ledgerParams[jss::ledger_index] = txResult[jss::ledger_index].asUInt();
+                ledgerParams[jss::transactions] = true;
+                ledgerParams[jss::expand] = true;
+
+                auto const ledgerResult =
+                    env.rpc(apiVersion, "json", "ledger", to_string(ledgerParams));
+                auto const& ledgerTx =
+                    ledgerResult[jss::result][jss::ledger][jss::transactions][0u];
+
+                // Verify transaction hash matches
+                BEAST_EXPECT(getHash(ledgerTx) == txHash);
+
+                if (auto const* meta = getMeta(ledgerTx); BEAST_EXPECT(meta != nullptr))
+                    verifyMeta(*meta);
+
+                // Test 3: Check account_tx RPC response
+                // The transaction is not necessarily alice's, so query
+                // account_tx for the account that actually submitted it.
+                json::Value accountTxParams;
+                accountTxParams[jss::account] = txResult.isMember(jss::tx_json)
+                    ? txResult[jss::tx_json][jss::Account].asString()
+                    : txResult[jss::Account].asString();
+
+                auto const accountTxResult =
+                    env.rpc(apiVersion, "json", "account_tx", to_string(accountTxParams));
+
+                // account_tx ordering is not guaranteed, so find our
+                // transaction by hash rather than assuming it is the most
+                // recent one.
+                json::Value const* accountTx = nullptr;
+                for (auto const& entry : accountTxResult[jss::result][jss::transactions])
+                {
+                    if (getHash(entry) == txHash)
+                    {
+                        accountTx = &entry;
+                        break;
+                    }
+                }
+
+                if (!BEAST_EXPECT(accountTx != nullptr))
+                    continue;
+
+                if (auto const* meta = getMeta(*accountTx); BEAST_EXPECT(meta != nullptr))
+                    verifyMeta(*meta);
+            }
+        };
+
+        // Verify `nftoken_id` value equals to the NFTokenID that was
+        // changed in the most recent NFTokenMint or NFTokenAcceptOffer
+        // transaction
+        auto verifyNFTokenID = [&](uint256 const& actualNftID) {
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect nftoken_id field
+                if (!BEAST_EXPECT(meta.isMember(jss::nftoken_id)))
+                    return;
+
+                // Check the value of NFT ID matches
+                uint256 nftID;
+                BEAST_EXPECT(nftID.parseHex(meta[jss::nftoken_id].asString()));
+                BEAST_EXPECT(nftID == actualNftID);
+            });
         };
 
         // Verify `nftoken_ids` value equals to the NFTokenIDs that were
         // changed in the most recent NFTokenCancelOffer transaction
         auto verifyNFTokenIDsInCancelOffer = [&](std::vector actualNftIDs) {
-            // Get the hash for the most recent transaction.
-            std::string const txHash{
-                env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
-
-            env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
-
-            // Expect nftokens_ids field and verify the values
-            if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids)))
-                return;
-
-            // Convert NFT IDs from json::Value to uint256
-            std::vector metaIDs;
-            std::transform(
-                meta[jss::nftoken_ids].begin(),
-                meta[jss::nftoken_ids].end(),
-                std::back_inserter(metaIDs),
-                [this](json::Value id) {
-                    uint256 nftID;
-                    BEAST_EXPECT(nftID.parseHex(id.asString()));
-                    return nftID;
-                });
-
-            // Sort both array to prepare for comparison
-            std::ranges::sort(metaIDs);
+            // Sort to prepare for comparison
             std::ranges::sort(actualNftIDs);
 
-            // Make sure the expect number of NFTs is correct
-            BEAST_EXPECT(metaIDs.size() == actualNftIDs.size());
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect nftoken_ids field and verify the values
+                if (!BEAST_EXPECT(meta.isMember(jss::nftoken_ids)))
+                    return;
 
-            // Check the value of NFT ID in the meta with the
-            // actual values
-            for (size_t i = 0; i < metaIDs.size(); ++i)
-                BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]);
+                // Convert NFT IDs from json::Value to uint256
+                std::vector metaIDs;
+                std::transform(
+                    meta[jss::nftoken_ids].begin(),
+                    meta[jss::nftoken_ids].end(),
+                    std::back_inserter(metaIDs),
+                    [this](json::Value id) {
+                        uint256 nftID;
+                        BEAST_EXPECT(nftID.parseHex(id.asString()));
+                        return nftID;
+                    });
+
+                std::ranges::sort(metaIDs);
+
+                // Make sure the expect number of NFTs is correct
+                if (!BEAST_EXPECT(metaIDs.size() == actualNftIDs.size()))
+                    return;
+
+                // Check the value of NFT ID in the meta with the
+                // actual values
+                for (size_t i = 0; i < metaIDs.size(); ++i)
+                    BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]);
+            });
         };
 
         // Verify `offer_id` value equals to the offerID that was
         // changed in the most recent NFTokenCreateOffer tx
         auto verifyNFTokenOfferID = [&](uint256 const& offerID) {
-            // Get the hash for the most recent transaction.
-            std::string const txHash{
-                env.tx()->getJson(JsonOptions::Values::None)[jss::hash].asString()};
+            verifyMetaInAllResponses([&](json::Value const& meta) {
+                // Expect offer_id field and verify the value
+                if (!BEAST_EXPECT(meta.isMember(jss::offer_id)))
+                    return;
 
-            env.close();
-            json::Value const meta = env.rpc("tx", txHash)[jss::result][jss::meta];
-
-            // Expect offer_id field and verify the value
-            if (!BEAST_EXPECT(meta.isMember(jss::offer_id)))
-                return;
-
-            uint256 metaOfferID;
-            BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString()));
-            BEAST_EXPECT(metaOfferID == offerID);
+                uint256 metaOfferID;
+                BEAST_EXPECT(metaOfferID.parseHex(meta[jss::offer_id].asString()));
+                BEAST_EXPECT(metaOfferID == offerID);
+            });
         };
 
         // Check new fields in tx meta when for all NFTtransactions
@@ -6207,12 +6502,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // Alice creates one sell offer for each NFT
             // Verify the offer indexes are correct in the NFTokenCreateOffer tx
             // meta
-            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -6226,7 +6523,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // Bobs creates a buy offer for nftId1
             // Verify the offer id is correct in the NFTokenCreateOffer tx meta
-            auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            auto const bobBuyOfferIndex =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice));
             env.close();
             verifyNFTokenOfferID(bobBuyOfferIndex);
@@ -6247,7 +6545,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenID(nftId);
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
@@ -6255,7 +6554,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenOfferID(offerAliceToBroker);
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice));
             env.close();
             verifyNFTokenOfferID(offerBobToBroker);
@@ -6276,12 +6576,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             verifyNFTokenID(nftId);
 
             // Alice creates 2 sell offers for the same NFT
-            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             env.close();
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -6296,7 +6598,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         if (features[featureNFTokenMintOffer])
         {
             uint256 const aliceMintWithOfferIndex1 =
-                keylet::nftokenOffer(alice, env.seq(alice)).key;
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::mint(alice), token::Amount(XRP(0)));
             env.close();
             verifyNFTokenOfferID(aliceMintWithOfferIndex1);
@@ -6319,7 +6621,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // acct makes an sell offer
-            uint256 const sellOfferIndex = keylet::nftokenOffer(acct, env.seq(acct)).key;
+            uint256 const sellOfferIndex =
+                keylet::nftokenOffer(acct, SeqProxy::rawSequence(env.seq(acct))).key;
             env(token::createOffer(acct, nftId, amt), Txflags(tfSellNFToken));
             env.close();
 
@@ -6488,7 +6791,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Bob makes a buy offer for 1 XRP
-            auto const buyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            auto const buyOfferIndex =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice));
             env.close();
 
@@ -6532,14 +6836,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             env.close();
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, XRP(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
             env.close();
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice));
             env.close();
 
@@ -6633,10 +6939,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // becky buys the nfts for 1 drop each.
             {
-                uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex1 =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer));
 
-                uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key;
+                uint256 const beckyBuyOfferIndex2 =
+                    keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
                 env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer));
 
                 env.close();
@@ -6647,7 +6955,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
             // becky creates offers to sell the nfts for AUD.
             uint256 const beckyAutoTrustOfferIndex =
-                keylet::nftokenOffer(becky, env.seq(becky)).key;
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6666,7 +6974,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             BEAST_EXPECT(ownerCount(env, issuer) == 1);
 
             uint256 const beckyNoAutoTrustOfferIndex =
-                keylet::nftokenOffer(becky, env.seq(becky)).key;
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6790,10 +7098,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
 
         // becky buys the nfts for 1 drop each.
         {
-            uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex1 =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer));
 
-            uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key;
+            uint256 const beckyBuyOfferIndex2 =
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer));
 
             env.close();
@@ -6821,7 +7131,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // However if the NFToken has the tfTrustLine flag set,
             // then becky can create the offer.
             uint256 const beckyAutoTrustOfferIndex =
-                keylet::nftokenOffer(becky, env.seq(becky)).key;
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -6839,11 +7149,11 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             // With featureNFTokenMintOffer things go better.
             // becky creates offers to sell the nfts for ISU.
             uint256 const beckyNoAutoTrustOfferIndex =
-                keylet::nftokenOffer(becky, env.seq(becky)).key;
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftNoAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
             uint256 const beckyAutoTrustOfferIndex =
-                keylet::nftokenOffer(becky, env.seq(becky)).key;
+                keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key;
             env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken));
             env.close();
 
@@ -7077,7 +7387,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             checkURI(issuer, "uri", __LINE__);
 
             // Account != Owner
-            uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+            uint256 const offerID =
+                keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
             env(token::createOffer(issuer, nftId, XRP(0)), Txflags(tfSellNFToken));
             env.close();
             env(token::acceptSellOffer(alice, offerID));
@@ -7124,6 +7435,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()};
 
@@ -7155,6 +7587,7 @@ protected:
         testNFTokenWithTickets(features);
         testNFTokenDeleteAccount(features);
         testNftXxxOffers(features);
+        testNftXxxOffersMarkerWrongSide(features);
         testNFTokenNegOffer(features);
         testIOUWithTransferFee(features);
         testBrokeredSaleToSelf(features);
@@ -7165,6 +7598,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/Offer_test.cpp b/src/test/app/Offer_test.cpp
index 7fc7161e36..500372bca3 100644
--- a/src/test/app/Offer_test.cpp
+++ b/src/test/app/Offer_test.cpp
@@ -38,6 +38,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -797,13 +798,15 @@ public:
             // The offer expires (it's not removed yet).
             env.close();
             env.require(Owners(bob, 1), offers(bob, 1));
-            auto const expiredBobOffer = keylet::offer(bob, env.seq(bob) - 1);
+            auto const expiredBobOffer =
+                keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1));
 
             // bob creates the offer that will be crossed.
             env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS));
             env.close();
             env.require(Owners(bob, 2), offers(bob, 2));
-            auto const crossedBobOffer = keylet::offer(bob, env.seq(bob) - 1);
+            auto const crossedBobOffer =
+                keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1));
 
             env(trust(alice, usd(1000)), Ter(tesSUCCESS));
             env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS));
@@ -850,7 +853,7 @@ public:
 
             env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS));
             env.close();
-            auto const bobOffer = keylet::offer(bob, env.seq(bob) - 1);
+            auto const bobOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1));
 
             env(trust(alice, usd(1000)), Ter(tesSUCCESS));
             env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS));
@@ -4324,6 +4327,165 @@ public:
         env.require(Balance(bob, gwUSD(10)));
     }
 
+    void
+    testDisallowIncomingTrustline(FeatureBitset features)
+    {
+        testcase("DisallowIncomingTrustline in OfferCreate");
+
+        // Test that asfDisallowIncomingTrustline flag prevents offer crossing
+        // when the taker doesn't have a trustline.
+        //
+        // 1. alice creates a trustline and sells USD/gw tokens.
+        //
+        // 2. gw sets asfDisallowIncomingTrustline flag.
+        //
+        // 3. An account without a trustline tries to create an offer for USD/gw.
+        //    Without amendment: succeeds and crosses alice's offer (backward compatible).
+        //    With amendment: fails with tecNO_LINE (new behavior).
+        //
+        // 4. An account WITH an existing trustline can create an offer.
+        //    The offer succeeds and crosses alice's offer.
+        //
+        // Note: The DisallowIncomingTrustline flag also prevents NEW trustlines
+        // from being created via TrustSet (enforced by fixDisallowIncomingV1).
+        // So accounts must create trustlines BEFORE the issuer sets the flag.
+
+        using namespace jtx;
+        auto const gw = Account("gw");
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const carol = Account("carol");
+        auto const dan = Account("dan");
+        auto const eve = Account("eve");
+        auto const gwUSD = gw["USD"];
+
+        // Test without fixCleanup3_4_0 amendment
+        {
+            Env env{*this, features - fixCleanup3_4_0};
+
+            env.fund(XRP(400000), gw, alice, bob);
+            env.close();
+
+            // Alice creates trustline and gets some USD
+            env(trust(alice, gwUSD(100)));
+            env.close();
+            env(pay(gw, alice, gwUSD(50)));
+            env.close();
+
+            // Alice creates sell offer
+            env(offer(alice, XRP(4000), gwUSD(40)));
+            env.close();
+            env.require(offers(alice, 1));
+
+            // GW sets DisallowIncomingTrustline flag
+            env(fset(gw, asfDisallowIncomingTrustline));
+            env.close();
+
+            // Without the amendment, bob can still create offer without trustline
+            // and the offer should cross (old behavior)
+            env(offer(bob, gwUSD(40), XRP(4000)));
+            env.close();
+
+            // Offer should have crossed
+            env.require(offers(alice, 0));
+            env.require(offers(bob, 0));
+            env.require(Balance(bob, gwUSD(40)));
+        }
+
+        // Test with fixCleanup3_4_0 amendment
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(400000), gw, alice, bob, carol, dan);
+            env.close();
+
+            // Alice creates trustline and gets some USD
+            env(trust(alice, gwUSD(100)));
+            env.close();
+            env(pay(gw, alice, gwUSD(50)));
+            env.close();
+
+            // Bob and carol create trustlines BEFORE the flag is set
+            env(trust(bob, gwUSD(100)));
+            env.close();
+            env(trust(carol, gwUSD(100)));
+            env.close();
+
+            // Alice creates sell offer
+            env(offer(alice, XRP(4000), gwUSD(40)));
+            env.close();
+            env.require(offers(alice, 1));
+            env.require(Balance(alice, gwUSD(50)));
+
+            // GW sets DisallowIncomingTrustline flag
+            env(fset(gw, asfDisallowIncomingTrustline));
+            env.close();
+
+            // Dan tries to create offer without trustline - should fail
+            env(offer(dan, gwUSD(40), XRP(4000)), Ter(tecNO_LINE));
+            env.close();
+
+            // Alice's offer should still exist
+            env.require(offers(alice, 1));
+            env.require(Balance(alice, gwUSD(50)));
+
+            // Dan shouldn't have any offers or balance
+            env.require(offers(dan, 0));
+            BEAST_EXPECT(env.le(keylet::trustLine(dan, gwUSD)) == nullptr);
+
+            // Bob already has trustline, so his offer should succeed and cross
+            env(offer(bob, gwUSD(40), XRP(4000)));
+            env.close();
+
+            // Offer should have crossed
+            env.require(offers(alice, 0));
+            env.require(offers(bob, 0));
+            env.require(Balance(alice, gwUSD(10)));
+            env.require(Balance(bob, gwUSD(40)));
+
+            // Test scenario where carol already has a trustline (created before flag was set)
+            // Carol should be able to create offer since trustline already exists
+            env(pay(gw, alice, gwUSD(50)));
+            env.close();
+            env(offer(alice, XRP(1000), gwUSD(10)));
+            env.close();
+            env.require(offers(alice, 1));
+
+            env(offer(carol, gwUSD(10), XRP(1000)));
+            env.close();
+
+            // Offer should have crossed
+            env.require(offers(alice, 0));
+            env.require(offers(carol, 0));
+            env.require(Balance(alice, gwUSD(50)));
+            env.require(Balance(carol, gwUSD(10)));
+
+            // Test that gw can clear the flag
+            env(fclear(gw, asfDisallowIncomingTrustline));
+            env.close();
+
+            // Create new account eve without trustline
+            env.fund(XRP(400000), eve);
+            env.close();
+
+            // Bob creates another sell offer
+            env(pay(gw, bob, gwUSD(50)));
+            env.close();
+            env(offer(bob, XRP(5000), gwUSD(50)));
+            env.close();
+            env.require(offers(bob, 1));
+
+            // Eve should now be able to create offer without trustline (flag is cleared)
+            env(offer(eve, gwUSD(50), XRP(5000)));
+            env.close();
+
+            // Offer should have crossed
+            env.require(offers(bob, 0));
+            env.require(offers(eve, 0));
+            env.require(Balance(eve, gwUSD(50)));
+        }
+    }
+
     void
     testRCSmoketest(FeatureBitset features)
     {
@@ -5167,6 +5329,7 @@ public:
         testSelfPayUnlimitedFunds(features);
         testRequireAuth(features);
         testMissingAuth(features);
+        testDisallowIncomingTrustline(features);
         testRCSmoketest(features);
         testSelfAuth(features);
         testDeletedOfferIssuer(features);
diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp
index 3ba67b58a6..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 
@@ -112,10 +117,10 @@ public:
             MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = 100});
 
         auto& app = env.app();
-        Resource::Charge loadType = Resource::kFeeReferenceRpc;
-        Resource::Consumer c;
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
 
-        RPC::JsonContext context{
+        rpc::JsonContext context{
             {.j = env.journal,
              .app = app,
              .loadType = loadType,
@@ -125,39 +130,39 @@ public:
              .role = Role::USER,
              .coro = {},
              .infoSub = {},
-             .apiVersion = RPC::kApiVersionIfUnspecified},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
             {},
             {}};
         json::Value result;
         Gate g;
-        // Test RPC::Tuning::max_src_cur source currencies.
+        // Test rpc::tuning::max_src_cur source currencies.
         std::vector numSrc;
-        numSrc.reserve(RPC::Tuning::kMaxSrcCur);
-        for (std::uint8_t i = 0; i < RPC::Tuning::kMaxSrcCur; ++i)
+        numSrc.reserve(rpc::tuning::kMaxSrcCur);
+        for (std::uint8_t i = 0; i < rpc::tuning::kMaxSrcCur; ++i)
             numSrc.push_back(makeMptID(i, bob));
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = xrpl::test::detail::rpf(alice, bob, usd, numSrc);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(!result.isMember(jss::error));
 
-        // Test more than RPC::Tuning::max_src_cur source currencies.
-        numSrc.push_back(makeMptID(RPC::Tuning::kMaxSrcCur, bob));
+        // Test more than rpc::tuning::max_src_cur source currencies.
+        numSrc.push_back(makeMptID(rpc::tuning::kMaxSrcCur, bob));
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = xrpl::test::detail::rpf(alice, bob, usd, numSrc);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(result.isMember(jss::error));
 
-        // Test RPC::Tuning::max_auto_src_cur source currencies.
+        // Test rpc::tuning::max_auto_src_cur source currencies.
         numSrc.clear();
-        for (auto i = 0; i < (RPC::Tuning::kMaxAutoSrcCur - 1); ++i)
+        for (auto i = 0; i < (rpc::tuning::kMaxAutoSrcCur - 1); ++i)
         {
             auto curm = MPTTester({.env = env, .issuer = alice, .holders = {bob}});
             numSrc.push_back(curm.issuanceID());
@@ -165,18 +170,18 @@ public:
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = xrpl::test::detail::rpf(alice, bob, usd, {});
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(!result.isMember(jss::error));
 
-        // Test more than RPC::Tuning::max_auto_src_cur source currencies.
+        // Test more than rpc::tuning::max_auto_src_cur source currencies.
         auto curm = MPTTester({.env = env, .issuer = alice, .holders = {bob}});
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = xrpl::test::detail::rpf(alice, bob, usd, {});
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
@@ -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 8f19a419a0..5ecad1a420 100644
--- a/src/test/app/Path_test.cpp
+++ b/src/test/app/Path_test.cpp
@@ -25,6 +25,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,15 +47,20 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test {
 
@@ -146,15 +152,16 @@ 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;
 
         auto& app = env.app();
-        Resource::Charge loadType = Resource::kFeeReferenceRpc;
-        Resource::Consumer c;
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
 
-        RPC::JsonContext context{
+        rpc::JsonContext context{
             {.j = env.journal,
              .app = app,
              .loadType = loadType,
@@ -164,7 +171,7 @@ public:
              .role = Role::USER,
              .coro = {},
              .infoSub = {},
-             .apiVersion = RPC::kApiVersionIfUnspecified},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
             {},
             {}};
 
@@ -180,6 +187,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)
@@ -190,7 +201,7 @@ public:
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = std::move(params);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
 
@@ -208,10 +219,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;
@@ -262,10 +274,10 @@ public:
         env.close();
 
         auto& app = env.app();
-        Resource::Charge loadType = Resource::kFeeReferenceRpc;
-        Resource::Consumer c;
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
 
-        RPC::JsonContext context{
+        rpc::JsonContext context{
             {.j = env.journal,
              .app = app,
              .loadType = loadType,
@@ -275,55 +287,102 @@ public:
              .role = Role::USER,
              .coro = {},
              .infoSub = {},
-             .apiVersion = RPC::kApiVersionIfUnspecified},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
             {},
             {}};
         json::Value result;
         Gate g;
-        // Test RPC::Tuning::max_src_cur source currencies.
+        // Test rpc::tuning::max_src_cur source currencies.
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
-            context.params = rpf(Account("alice"), Account("bob"), RPC::Tuning::kMaxSrcCur);
+            context.params = rpf(Account("alice"), Account("bob"), rpc::tuning::kMaxSrcCur);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(!result.isMember(jss::error));
 
-        // Test more than RPC::Tuning::max_src_cur source currencies.
+        // Test more than rpc::tuning::max_src_cur source currencies.
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
-            context.params = rpf(Account("alice"), Account("bob"), RPC::Tuning::kMaxSrcCur + 1);
+            context.params = rpf(Account("alice"), Account("bob"), rpc::tuning::kMaxSrcCur + 1);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(result.isMember(jss::error));
 
-        // Test RPC::Tuning::max_auto_src_cur source currencies.
-        for (auto i = 0; i < (RPC::Tuning::kMaxAutoSrcCur - 1); ++i)
+        // Test rpc::tuning::max_auto_src_cur source currencies.
+        for (auto i = 0; i < (rpc::tuning::kMaxAutoSrcCur - 1); ++i)
             env.trust(Account("alice")[std::to_string(i + 100)](100), "bob");
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = rpf(Account("alice"), Account("bob"), 0);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         BEAST_EXPECT(!result.isMember(jss::error));
 
-        // Test more than RPC::Tuning::max_auto_src_cur source currencies.
+        // Test more than rpc::tuning::max_auto_src_cur source currencies.
         env.trust(Account("alice")["AUD"](100), "bob");
         app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
             context.params = rpf(Account("alice"), Account("bob"), 0);
             context.coro = coro;
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
             g.signal();
         });
         BEAST_EXPECT(g.waitFor(5s));
         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()
     {
@@ -1866,10 +1925,317 @@ public:
         BEAST_EXPECT(same(st, stpath(gw_, ipe(xrpIssue()))));
     }
 
+    void
+    testAssembleAddDeduplication()
+    {
+        testcase("STPathSet::assembleAdd deduplication — O(N^2) regression");
+
+        static constexpr std::string_view kAccount1 = "A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09";
+        static constexpr std::string_view kAccount2 = "1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D";
+        static constexpr std::string_view kAccount3 = "F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6";
+        static constexpr std::string_view kAccount4 = "4B6209E7F1A3C85D0E94B27Af3D6018C5A7E92B4";
+        static constexpr std::string_view kAccount5 = "9E2D7041BCA3F6589D013E7B2A4C6F80159D3E7A";
+        static constexpr std::string_view kAccount6 = "7C5A91E384F2D06BA19C4E73D820F516B3A9C0E4";
+        static constexpr std::string_view kAccount7 = "2F8B043C6A1E9D75B0C38E14F6A2D509731BC4E8";
+        static constexpr std::string_view kAccount8 = "E61D9A30F47C285BA0D31E96C7B4F802513A8D6F";
+
+        static constexpr AccountID kAccountID1{kAccount1};
+        static constexpr AccountID kAccountID2{kAccount2};
+        static constexpr AccountID kAccountID3{kAccount3};
+        static constexpr AccountID kAccountID4{kAccount4};
+        static constexpr AccountID kAccountID5{kAccount5};
+        static constexpr AccountID kAccountID6{kAccount6};
+        static constexpr AccountID kAccountID7{kAccount7};
+        static constexpr AccountID kAccountID8{kAccount8};
+
+        auto ps = STPathSet{STPathSet::DeduplicationTag{}};
+
+        auto createPathElements = [](auto const& account1, auto const& account2) {
+            auto base = STPath{};
+            base.pushBack(
+                STPathElement{STPathElement::TypeAccount, account1, xrpCurrency(), account1});
+            auto tail =
+                STPathElement{STPathElement::TypeAccount, account2, xrpCurrency(), account2};
+            return std::make_pair(base, tail);
+        };
+
+        {
+            auto [base, tail] = createPathElements(kAccountID1, kAccountID2);
+
+            for (auto i = 0uz; i < 10000; ++i)
+            {
+                ps.assembleAdd(base, tail);
+            }
+
+            BEAST_EXPECT(ps.size() == 1);
+        }
+
+        {
+            auto [base, tail] = createPathElements(kAccountID3, kAccountID4);
+            ps.assembleAdd(base, tail);
+        }
+
+        {
+            auto [base, tail] = createPathElements(kAccountID5, kAccountID6);
+            ps.assembleAdd(base, tail);
+        }
+
+        {
+            auto [base, tail] = createPathElements(kAccountID7, kAccountID8);
+
+            auto before = ps.size();
+
+            for (auto i = 0uz; i < 10000; ++i)
+            {
+                ps.assembleAdd(base, tail);
+            }
+
+            BEAST_EXPECT(ps.size() - before == 1);
+        }
+
+        {
+            auto [base, tail] = createPathElements(kAccountID1, kAccountID3);
+            auto copy = base;
+            copy.pushBack(tail);
+
+            auto before = ps.size();
+
+            ps.pushBack(copy);
+            ps.assembleAdd(base, tail);
+
+            BEAST_EXPECT(ps.size() - before == 1);
+        }
+
+        {
+            auto [base, tail] = createPathElements(kAccountID2, kAccountID4);
+            auto copy = base;
+            copy.pushBack(tail);
+
+            auto before = ps.size();
+
+            ps.emplaceBack(copy);
+            ps.assembleAdd(base, tail);
+
+            BEAST_EXPECT(ps.size() - before == 1);
+        }
+
+        BEAST_EXPECT(ps.size() == 6);
+    }
+
+    void
+    testPushBackDeduplication()
+    {
+        testcase("STPathSet::pushBack/emplaceBack deduplication");
+
+        // pushBack and emplaceBack reject duplicates on a set built with the
+        // DeduplicationTag, and append unconditionally without it.  Both
+        // report which happened.  The unconditional case is the one the wire
+        // and JSON paths rely on: collapsing duplicates there would change the
+        // signed content of a transaction.
+
+        static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"};
+        static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"};
+        static constexpr AccountID kAccountID3{"F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6"};
+
+        auto makePath = [](AccountID const& account) {
+            auto p = STPath{};
+            p.pushBack(STPathElement{STPathElement::TypeAccount, account, xrpCurrency(), account});
+            return p;
+        };
+
+        auto const first = makePath(kAccountID1);
+        auto const second = makePath(kAccountID2);
+        auto const third = makePath(kAccountID3);
+
+        // Deduplicating set: the second insert of a path is rejected, and the
+        // rejection is reported rather than silently swallowed.
+        {
+            auto ps = STPathSet{STPathSet::DeduplicationTag{}};
+
+            BEAST_EXPECT(ps.pushBack(first));
+            BEAST_EXPECT(ps.size() == 1);
+
+            BEAST_EXPECT(!ps.pushBack(first));
+            BEAST_EXPECT(ps.size() == 1);
+
+            // emplaceBack sees paths registered by pushBack...
+            BEAST_EXPECT(!ps.emplaceBack(first));
+            BEAST_EXPECT(ps.size() == 1);
+
+            BEAST_EXPECT(ps.emplaceBack(second));
+            BEAST_EXPECT(ps.size() == 2);
+
+            // ...and pushBack sees paths registered by emplaceBack.
+            BEAST_EXPECT(!ps.pushBack(second));
+            BEAST_EXPECT(ps.size() == 2);
+
+            // emplaceBack's forwarding form registers the same way.
+            BEAST_EXPECT(ps.emplaceBack(std::vector{third.front()}));
+            BEAST_EXPECT(ps.size() == 3);
+            BEAST_EXPECT(!ps.pushBack(third));
+            BEAST_EXPECT(ps.size() == 3);
+
+            // A rejected duplicate must not disturb what is already stored.
+            BEAST_EXPECT(ps[0] == first);
+            BEAST_EXPECT(ps[1] == second);
+            BEAST_EXPECT(ps[2] == third);
+        }
+
+        // Without the tag there is no index, so duplicates are appended and
+        // both methods report success every time.
+        {
+            auto plain = STPathSet{};
+            BEAST_EXPECT(plain.pushBack(first));
+            BEAST_EXPECT(plain.pushBack(first));
+            BEAST_EXPECT(plain.emplaceBack(first));
+            BEAST_EXPECT(plain.size() == 3);
+
+            auto named = STPathSet{sfPaths};
+            BEAST_EXPECT(named.pushBack(first));
+            BEAST_EXPECT(named.pushBack(first));
+            BEAST_EXPECT(named.size() == 2);
+        }
+    }
+
+    void
+    testPathHashInjectivity()
+    {
+        testcase("STPathElement hash injectivity");
+
+        auto const zeroCurrency =
+            STPathElement{AccountID{}, PathAsset{Currency{}}, AccountID{}, true};
+        auto const zeroMPT = STPathElement{AccountID{}, PathAsset{MPTID{}}, AccountID{}, true};
+
+        BEAST_EXPECT(!(zeroCurrency == zeroMPT));
+
+        auto path = [](std::vector const& elements) {
+            auto p = STPath{};
+            for (auto const& element : elements)
+                p.pushBack(element);
+            return p;
+        };
+
+        auto const currencyFirst = path({zeroCurrency, zeroMPT});
+        auto const mptFirst = path({zeroMPT, zeroCurrency});
+
+        BEAST_EXPECT(!(currencyFirst == mptFirst));
+
+        auto const hasher = HardenedHash<>{};
+        BEAST_EXPECT(hasher(currencyFirst) != hasher(mptFirst));
+
+        auto mask = std::vector{0, 0, 1, 1};
+        auto hashes = std::set{};
+        auto orderings = 0uz;
+        do
+        {
+            auto elements = std::vector{};
+            for (auto const isMPT : mask)
+            {
+                elements.push_back(isMPT != 0 ? zeroMPT : zeroCurrency);
+            }
+            hashes.insert(hasher(path(elements)));
+            ++orderings;
+        } while (std::ranges::next_permutation(mask).found);
+
+        BEAST_EXPECT(orderings == 6);
+        BEAST_EXPECT(hashes.size() == orderings);
+
+        auto seen = hardened_hash_set{};
+        for (auto const& p : {currencyFirst, mptFirst})
+        {
+            seen.emplace(p);
+        }
+        BEAST_EXPECT(seen.size() == 2);
+
+        // The other half of the invariant: equal elements must hash equally.
+        // STPathElement::operator== masks type_ down to the TypeAccount bit, so
+        // elements whose remaining type bits differ still compare equal --
+        // hashing the full type_ would give them distinct hashes and silently
+        // defeat deduplication.
+        static constexpr AccountID kAccount{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"};
+        static constexpr AccountID kIssuer{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"};
+
+        auto const equivalent = std::vector>{
+            // forceAsset toggles TypeCurrency on an XRP asset.
+            {STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, true},
+             STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}},
+            // An explicit type mask vs. one derived from the populated fields.
+            {STPathElement{STPathElement::TypeAccount, kAccount, xrpCurrency(), kIssuer},
+             STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}},
+        };
+
+        for (auto const& [lhs, rhs] : equivalent)
+        {
+            BEAST_EXPECT(lhs.getNodeType() != rhs.getNodeType());
+            BEAST_EXPECT(lhs == rhs);
+
+            auto const lhsPath = path({lhs});
+            auto const rhsPath = path({rhs});
+            BEAST_EXPECT(hasher(lhsPath) == hasher(rhsPath));
+
+            auto equal = hardened_hash_set{};
+            equal.emplace(lhsPath);
+            equal.emplace(rhsPath);
+            BEAST_EXPECT(equal.size() == 1);
+        }
+    }
+
+    void
+    testDeserializationPreservesDuplicates()
+    {
+        testcase("STPathSet deserialization preserves duplicate paths");
+
+        // The `Paths` field of a signed transaction must round-trip byte for
+        // byte.  The deduplication index exists solely for pathfinding, so the
+        // deserializing constructor must never engage it: collapsing duplicates
+        // on parse would silently change the signed content of a transaction.
+
+        static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"};
+        static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"};
+
+        auto const element =
+            STPathElement{kAccountID1, PathAsset{xrpCurrency()}, kAccountID2, true};
+
+        auto path = STPath{};
+        path.pushBack(element);
+
+        static constexpr auto kDuplicates = 64uz;
+
+        auto original = STPathSet{sfPaths};
+        for (auto i = 0uz; i < kDuplicates; ++i)
+        {
+            original.pushBack(path);
+        }
+
+        // No index was requested, so nothing is deduplicated on the way in.
+        BEAST_EXPECT(original.size() == kDuplicates);
+
+        auto s = Serializer{};
+        original.add(s);
+
+        auto sit = SerialIter{s.slice()};
+        auto const parsed = STPathSet{sit, sfPaths};
+
+        // The duplicates survive the round trip...
+        BEAST_EXPECT(parsed.size() == kDuplicates);
+        BEAST_EXPECT(parsed.isEquivalent(original));
+
+        // ...and re-serializing reproduces the original bytes exactly.
+        auto serialized = Serializer{};
+        parsed.add(serialized);
+        BEAST_EXPECT(serialized.getData() == s.getData());
+
+        // A parsed set holds no index, so appending to it stays append-only.
+        auto appended = parsed;
+        appended.pushBack(path);
+        BEAST_EXPECT(appended.size() == kDuplicates + 1);
+    }
+
     void
     run() override
     {
         sourceCurrenciesLimit();
+        sourceCurrencyIssuerSelection();
         noDirectPathNoIntermediaryNoAlternatives();
         directPathNoIntermediary();
         paymentAutoPathFind();
@@ -1878,6 +2244,10 @@ public:
         issuesPathNegativeRippleClientIssue23Smaller();
         issuesPathNegativeRippleClientIssue23Larger();
         qualityPathsQualitySetAndTest();
+        testAssembleAddDeduplication();
+        testPushBackDeduplication();
+        testPathHashInjectivity();
+        testDeserializationPreservesDuplicates();
         trustAutoClearTrustNormalClear();
         trustAutoClearTrustAutoClear();
         norippleCombinations();
diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp
index 5068472135..96fe094c62 100644
--- a/src/test/app/PayChan_test.cpp
+++ b/src/test/app/PayChan_test.cpp
@@ -13,13 +13,18 @@
 #include 
 #include 
 
+#include 
+#include 
+#include 
+
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include   // IWYU pragma: keep
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -35,10 +40,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
@@ -48,6 +57,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -62,7 +72,8 @@ struct PayChan_test : public beast::unit_test::Suite
         auto const sle = view.read(keylet::account(account));
         if (!sle)
             return {};
-        auto const k = keylet::payChannel(account, dst, (*sle)[sfSequence] - 1);
+        auto const k =
+            keylet::payChannel(account, dst, SeqProxy::rawSequence((*sle)[sfSequence] - 1));
         return {k.key, view.read(k)};
     }
 
@@ -495,7 +506,7 @@ struct PayChan_test : public beast::unit_test::Suite
         // Owner closes, will close after settleDelay
         env(claim(alice, chan), Txflags(tfClose));
         BEAST_EXPECT(channelExists(*env.current(), chan));
-        env.close(settleTimepoint - settleDelay / 2);
+        env.close(settleTimepoint - (settleDelay / 2));
         {
             // receiver can still claim
             auto const chanBal = channelBalance(*env.current(), chan);
@@ -1587,6 +1598,146 @@ struct PayChan_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testChannelVerifyLoadType(FeatureBitset features)
+    {
+        testcase("channel_verify sets kFEE_HEAVY_BURDEN_RPC load type");
+
+        using namespace jtx;
+        using namespace std::literals::chrono_literals;
+
+        Env env{*this, features};
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+
+        env.fund(XRP(10000), alice, bob);
+
+        auto const pk = alice.pk();
+        auto const settleDelay = 3600s;
+        auto const channelFunds = XRP(1000);
+        auto const chanStr = to_string(channel(alice, bob, env.seq(alice)));
+
+        env(create(alice, bob, channelFunds, settleDelay, pk));
+        env.close();
+
+        // Step 1: get a valid signature from channel_authorize
+        auto const authResult = env.rpc("channel_authorize", "alice", chanStr, "1000");
+        auto const sig = authResult[jss::result][jss::signature].asString();
+        BEAST_EXPECT(!sig.empty());
+        auto const pkHex = strHex(pk.slice());
+
+        // Step 2: build rpc::JsonContext directly so we can inspect loadType
+        auto& app = env.app();
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
+        rpc::JsonContext context{
+            {.j = env.journal,
+             .app = app,
+             .loadType = loadType,
+             .netOps = app.getOPs(),
+             .ledgerMaster = app.getLedgerMaster(),
+             .consumer = c,
+             .role = Role::USER,
+             .coro = {},
+             .infoSub = {},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
+            {},
+            {}};
+        json::Value params;
+        params[jss::public_key] = pkHex;
+        params[jss::channel_id] = chanStr;
+        params[jss::amount] = "1000";
+        params[jss::signature] = sig;
+        context.params = std::move(params);
+
+        // Confirm default before calling handler
+        BEAST_EXPECT(context.loadType == resource::kFeeReferenceRpc);
+        json::Value result;
+        Gate g;
+        app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
+            context.coro = coro;
+            result = doChannelVerify(context);
+            g.signal();
+        });
+
+        using namespace std::chrono_literals;
+        BEAST_EXPECT(g.waitFor(5s));
+        // Signature must verify correctly
+        BEAST_EXPECT(result[jss::signature_verified].asBool());
+        // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix
+        // Before fix: this will FAIL because loadType stays kFEE_REFERENCE_RPC (20)
+        // After fix:  this will PASS because loadType is kFEE_HEAVY_BURDEN_RPC (3000)
+        BEAST_EXPECT(context.loadType == resource::kFeeHeavyBurdenRpc);
+        // Confirm the charge is 150x heavier than the current (broken) default
+        BEAST_EXPECT(context.loadType.cost() == resource::kFeeHeavyBurdenRpc.cost());  // 3000
+        BEAST_EXPECT(context.loadType.cost() != resource::kFeeReferenceRpc.cost());    // not 20
+    }
+
+    void
+    testChannelAuthorizeLoadType(FeatureBitset features)
+    {
+        testcase("channel_authorize sets kFEE_HEAVY_BURDEN_RPC load type");
+
+        using namespace jtx;
+        using namespace std::literals::chrono_literals;
+
+        Env env{*this, features};
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+
+        env.fund(XRP(10000), alice, bob);
+
+        auto const pk = alice.pk();
+        auto const settleDelay = 3600s;
+        auto const chanStr = to_string(channel(alice, bob, env.seq(alice)));
+
+        env(create(alice, bob, XRP(1000), settleDelay, pk));
+        env.close();
+
+        auto& app = env.app();
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
+        rpc::JsonContext context{
+            {.j = env.journal,
+             .app = app,
+             .loadType = loadType,
+             .netOps = app.getOPs(),
+             .ledgerMaster = app.getLedgerMaster(),
+             .consumer = c,
+             .role = Role::ADMIN,  // channel_authorize requires ADMIN or canSign()
+             .coro = {},
+             .infoSub = {},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
+            {},
+            {}};
+        json::Value params;
+        params[jss::channel_id] = chanStr;
+        params[jss::amount] = "1000";
+        params[jss::secret] = alice.name();  // use account name as seed
+        context.params = std::move(params);
+
+        // Confirm default before calling handler
+        BEAST_EXPECT(context.loadType == resource::kFeeReferenceRpc);
+        json::Value result;
+        Gate g;
+        app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
+            context.coro = coro;
+            result = doChannelAuthorize(context);
+            g.signal();
+        });
+
+        using namespace std::chrono_literals;
+
+        BEAST_EXPECT(g.waitFor(5s));
+        // Must return a valid signature
+        BEAST_EXPECT(result.isMember(jss::signature));
+        BEAST_EXPECT(!result[jss::signature].asString().empty());
+        // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix
+        // Before fix: FAILS — stays at kFEE_REFERENCE_RPC (charge=20)
+        // After fix:  PASSES — set to kFEE_HEAVY_BURDEN_RPC (charge=3000)
+        BEAST_EXPECT(context.loadType == resource::kFeeHeavyBurdenRpc);
+    }
+
     void
     testMalformedPK(FeatureBitset features)
     {
@@ -1983,6 +2134,8 @@ struct PayChan_test : public beast::unit_test::Suite
         testMetaAndOwnership(features);
         testAccountDelete(features);
         testUsingTickets(features);
+        testChannelVerifyLoadType(features);
+        testChannelAuthorizeLoadType(features);
     }
 
 public:
diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp
index 67cb7602a0..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 
@@ -35,6 +36,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -59,7 +61,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
     [[nodiscard]] static bool
     offerExists(Env const& env, Account const& account, std::uint32_t offerSeq)
     {
-        return static_cast(env.le(keylet::offer(account.id(), offerSeq)));
+        return static_cast(
+            env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq))));
     }
 
     [[nodiscard]] static bool
@@ -84,11 +87,11 @@ class PermissionedDEX_test : public beast::unit_test::Suite
 
             auto const& indexes = page->getFieldV256(sfIndexes);
             return std::ranges::any_of(indexes, [&](auto const& index) {
-                return index == keylet::offer(account, offerSeq).key;
+                return index == keylet::offer(account, SeqProxy::rawSequence(offerSeq)).key;
             });
         };
 
-        auto const sle = env.le(keylet::offer(account.id(), offerSeq));
+        auto const sle = env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq)));
         if (!sle)
             return false;
         if (sle->getFieldAmount(sfTakerGets) != takerGets)
@@ -147,7 +150,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
     static std::optional
     getDefaultOfferDirKey(Env const& env, Account const& account, std::uint32_t offerSeq)
     {
-        if (auto const sle = env.le(keylet::offer(account.id(), offerSeq)))
+        if (auto const sle = env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq))))
             return Keylet(ltDIR_NODE, (*sle)[sfBookDirectory]).key;
 
         return {};
@@ -177,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
         {
@@ -271,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();
         }
 
@@ -1244,7 +1252,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
 
-            auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
+            auto const sleHybridOffer =
+                env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(hybridOfferSeq)));
             if (!BEAST_EXPECT(sleHybridOffer))
                 return;
             auto const openDir =
@@ -1277,7 +1286,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             BEAST_EXPECT(offerExists(env, bob, regularOfferSeq));
             BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
 
-            auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
+            auto const sleHybridOffer =
+                env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(hybridOfferSeq)));
             if (!BEAST_EXPECT(sleHybridOffer))
                 return;
             auto const openDir =
@@ -1506,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.
@@ -1570,7 +1582,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            auto const sleOffer = env.le(keylet::offer(bob.id(), bobOfferSeq));
+            auto const sleOffer =
+                env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq)));
             BEAST_EXPECT(sleOffer);
             BEAST_EXPECT(sleOffer->getFieldH256(sfBookDirectory) == domainDir);
             BEAST_EXPECT(sleOffer->getFieldArray(sfAdditionalBooks).size() == 1);
@@ -1630,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)
     {
@@ -1666,7 +1875,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         // Directly manipulate the offer SLE in the open ledger so that
         // sfAdditionalBooks is present but empty (size 0). This is the
         // malformed state that fixCleanup3_1_3 is designed to catch.
-        auto const offerKey = keylet::offer(bob.id(), bobOfferSeq);
+        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)
@@ -1735,7 +1944,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         env.close();
 
         // After crossing, Alice's remaining offer should be placed.
-        auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
+        auto const sle = env.le(keylet::offer(alice_.id(), SeqProxy::rawSequence(aliceOfferSeq)));
         BEAST_EXPECT(sle);
         BEAST_EXPECT(sle->isFieldPresent(sfAdditionalBooks));
         BEAST_EXPECT(sle->getFieldArray(sfAdditionalBooks).size() == 1);
@@ -1816,7 +2025,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.fund(XRP(1000), carol);
             env.close();
 
-            env(ledgerStateFix::bookExchangeRate(carol, uint256{1}), Ter(temDISABLED));
+            env(ledger_state_fix::bookExchangeRate(carol, uint256{1}), Ter(temDISABLED));
         }
 
         {
@@ -1829,13 +2038,13 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             // BookExchangeRate fixes require sfBookDirectory.
-            auto missingBookDirectory = ledgerStateFix::bookExchangeRate(carol, uint256{1});
+            auto missingBookDirectory = ledger_state_fix::bookExchangeRate(carol, uint256{1});
             missingBookDirectory.removeMember(sfBookDirectory.jsonName);
             env(missingBookDirectory, Ter(temINVALID));
 
             // BookExchangeRate fixes reject fields that belong to other
             // LedgerStateFix types.
-            auto extraOwner = ledgerStateFix::bookExchangeRate(carol, uint256{1});
+            auto extraOwner = ledger_state_fix::bookExchangeRate(carol, uint256{1});
             extraOwner[sfOwner.jsonName] = carol.human();
             env(extraOwner, Ter(temINVALID));
         }
@@ -1847,7 +2056,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
 
             {
                 // Preclaim check: the target directory must exist.
-                env(ledgerStateFix::bookExchangeRate(setup.carol, uint256{1}),
+                env(ledger_state_fix::bookExchangeRate(setup.carol, uint256{1}),
                     Fee(fixFee),
                     Ter(tecOBJECT_NOT_FOUND));
             }
@@ -1861,7 +2070,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
                 BEAST_EXPECT(ownerDirSle);
                 BEAST_EXPECT(!ownerDirSle->isFieldPresent(sfExchangeRate));
 
-                env(ledgerStateFix::bookExchangeRate(setup.carol, ownerDir.key),
+                env(ledger_state_fix::bookExchangeRate(setup.carol, ownerDir.key),
                     Fee(fixFee),
                     Ter(tecNO_PERMISSION));
             }
@@ -1873,7 +2082,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
                 env(offer(setup.bob, XRP(100), setup.usd(40)));
                 env.close();
 
-                auto const sle = env.le(keylet::offer(setup.bob.id(), bobOfferSeq));
+                auto const sle =
+                    env.le(keylet::offer(setup.bob.id(), SeqProxy::rawSequence(bobOfferSeq)));
                 BEAST_EXPECT(sle);
 
                 auto const dirKey = sle->getFieldH256(sfBookDirectory);
@@ -1885,7 +2095,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
                     BEAST_EXPECT(exchangeRate == quality);
                 }
 
-                env(ledgerStateFix::bookExchangeRate(setup.carol, dirKey),
+                env(ledger_state_fix::bookExchangeRate(setup.carol, dirKey),
                     Fee(fixFee),
                     Ter(tecNO_PERMISSION));
             }
@@ -1907,7 +2117,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID));
             env.close();
 
-            auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
+            auto const sle =
+                env.le(keylet::offer(alice_.id(), SeqProxy::rawSequence(aliceOfferSeq)));
             BEAST_EXPECT(sle);
 
             auto const openDirKey =
@@ -1932,7 +2143,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             env.close();
 
             auto const fixFee = drops(env.current()->fees().increment);
-            env(ledgerStateFix::bookExchangeRate(carol_, openDirKey), Fee(fixFee));
+            env(ledger_state_fix::bookExchangeRate(carol_, openDirKey), Fee(fixFee));
             env.close();
 
             // Confirm sfExchangeRate now matches the key quality.
@@ -1947,7 +2158,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             }
 
             // Submitting again should fail — nothing to fix.
-            env(ledgerStateFix::bookExchangeRate(carol_, openDirKey),
+            env(ledger_state_fix::bookExchangeRate(carol_, openDirKey),
                 Fee(fixFee),
                 Ter(tecNO_PERMISSION));
         }
@@ -2001,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)
     {
@@ -2023,7 +2371,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
         env.close();
 
         BEAST_EXPECT(checkOffer(env, alice, oldSeq, USD(100), XRP(1), 0, true));
-        auto const oldOffer = env.le(keylet::offer(alice.id(), oldSeq));
+        auto const oldOffer = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(oldSeq)));
         if (!BEAST_EXPECT(oldOffer))
             return;
         BEAST_EXPECT(oldOffer->getFieldH256(sfDomainID) == domainA);
@@ -2038,7 +2386,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite
 
             BEAST_EXPECT(!offerExists(env, alice, oldSeq));
             BEAST_EXPECT(checkOffer(env, alice, newSeq, USD(100), XRP(2), 0, true));
-            auto const newOffer = env.le(keylet::offer(alice.id(), newSeq));
+            auto const newOffer = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(newSeq)));
             if (!BEAST_EXPECT(newOffer))
                 return;
             BEAST_EXPECT(newOffer->getFieldH256(sfDomainID) == domainB);
@@ -2065,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);
@@ -2075,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);
@@ -2093,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/PermissionedDomains_test.cpp b/src/test/app/PermissionedDomains_test.cpp
index 784c2b4f56..1a2472b397 100644
--- a/src/test/app/PermissionedDomains_test.cpp
+++ b/src/test/app/PermissionedDomains_test.cpp
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -552,11 +553,14 @@ class PermissionedDomains_test : public beast::unit_test::Suite
             auto domain = pdomain::getNewDomain(env.meta());
             if (features[fixCleanup3_1_3])
             {
-                BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), seq).key);
+                BEAST_EXPECT(
+                    domain ==
+                    keylet::permissionedDomain(alice.id(), SeqProxy::rawSequence(seq)).key);
             }
             else
             {
-                BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), 0).key);
+                BEAST_EXPECT(
+                    domain == keylet::permissionedDomain(alice.id(), SeqProxy::rawSequence(0)).key);
             }
         }
 
diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp
index 537ee4c177..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;
     }
 
@@ -60,7 +265,7 @@ class SHAMapStore_test : public beast::unit_test::Suite
     static bool
     goodLedger(jtx::Env& env, json::Value const& json, std::string ledgerID, bool checkDB = false)
     {
-        auto good = json.isMember(jss::result) && !RPC::containsError(json[jss::result]) &&
+        auto good = json.isMember(jss::result) && !rpc::containsError(json[jss::result]) &&
             json[jss::result][jss::ledger][jss::ledger_index] == ledgerID;
         if (!good || !checkDB)
             return good;
@@ -99,7 +304,7 @@ class SHAMapStore_test : public beast::unit_test::Suite
     static bool
     bad(json::Value const& json, ErrorCodeI error = RpcLgrNotFound)
     {
-        return json.isMember(jss::result) && RPC::containsError(json[jss::result]) &&
+        return json.isMember(jss::result) && rpc::containsError(json[jss::result]) &&
             json[jss::result][jss::error_code] == error;
     }
 
@@ -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());
@@ -347,11 +1052,11 @@ public:
         BEAST_EXPECT(lastRotated != 2);
 
         auto canDelete = env.rpc("can_delete");
-        BEAST_EXPECT(!RPC::containsError(canDelete[jss::result]));
+        BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == 0);
 
         canDelete = env.rpc("can_delete", "never");
-        BEAST_EXPECT(!RPC::containsError(canDelete[jss::result]));
+        BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == 0);
 
         auto const firstBatch = kDeleteInterval + ledgerSeq;
@@ -363,17 +1068,17 @@ 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());
 
         // This does not kick off a cleanup
         canDelete = env.rpc("can_delete", std::to_string(ledgerSeq + (kDeleteInterval / 2)));
-        BEAST_EXPECT(!RPC::containsError(canDelete[jss::result]));
+        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);
 
@@ -423,7 +1128,7 @@ public:
 
         // This does not kick off a cleanup
         canDelete = env.rpc("can_delete", "always");
-        BEAST_EXPECT(!RPC::containsError(canDelete[jss::result]));
+        BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(
             canDelete[jss::result][jss::can_delete] == std::numeric_limits::max());
 
@@ -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);
 
@@ -457,7 +1162,7 @@ public:
 
         // This does not kick off a cleanup
         canDelete = env.rpc("can_delete", "now");
-        BEAST_EXPECT(!RPC::containsError(canDelete[jss::result]));
+        BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == ledgerSeq - 1);
 
         for (; ledgerSeq < lastRotated + kDeleteInterval; ++ledgerSeq)
@@ -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 f20aac68f9..22b5d0bcdc 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -49,6 +49,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -58,6 +59,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -197,10 +199,12 @@ public:
             sponsor::SponseeAcc(alice),
             Ter(temMALFORMED));
 
-        // Invalid feeAmount
-        for (auto const& amt : {XRP(-1), usd(1)})
+        // Invalid FeeAmountDelta
+        for (auto const& amt : {XRP(0), usd(1)})
         {
-            env(sponsor::set_fee(sponsor, 0, amt), sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT));
+            env(sponsor::set_fee(sponsor, 0, amt, XRP(1)),
+                sponsor::SponseeAcc(alice),
+                Ter(temBAD_AMOUNT));
         }
         // Invalid MaxFee
         for (auto const& amt : {XRP(-1), usd(1)})
@@ -209,6 +213,10 @@ public:
                 sponsor::SponseeAcc(alice),
                 Ter(temBAD_AMOUNT));
         }
+        // Invalid RemainingOwnerCountDelta
+        env(sponsor::set(sponsor, 0, 0, XRP(2), XRP(1)),
+            sponsor::SponseeAcc(alice),
+            Ter(temINVALID));
 
         // Invalid Delete operation
         env(sponsor::set_reserve(sponsor, tfDeleteObject, 1),
@@ -229,12 +237,15 @@ public:
             sponsor::CounterpartySponsor(alice),
             Ter(temMALFORMED));
 
+        // Redundant tx
+        env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(temREDUNDANT));
+
         //
         // preclaim
         //
 
         // Invalid Sponsee
-        env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST));
+        env(sponsor::set(sponsor, 0, 1), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST));
         env.close();
 
         // Invalid Sponsor
@@ -290,7 +301,7 @@ public:
 
         // Decreasing feeAmount should succeed (refund, negative delta)
         adjustAccountXRPBalance(env, sponsor, XRP(500));
-        env(sponsor::set_fee(sponsor, 0, XRP(800)),
+        env(sponsor::set_fee(sponsor, 0, XRP(-200)),
             sponsor::SponseeAcc(alice),
             Fee(XRP(1)),
             Ter(tesSUCCESS));
@@ -299,7 +310,7 @@ public:
 
         // Increasing feeAmount within delta budget should succeed
         adjustAccountXRPBalance(env, sponsor, XRP(500));
-        env(sponsor::set_fee(sponsor, 0, XRP(850)),
+        env(sponsor::set_fee(sponsor, 0, XRP(50)),
             sponsor::SponseeAcc(alice),
             Fee(XRP(1)),
             Ter(tesSUCCESS));
@@ -308,18 +319,15 @@ public:
 
         // Increasing feeAmount where delta exceeds balance should fail
         adjustAccountXRPBalance(env, sponsor, XRP(310));
-        env(sponsor::set_fee(sponsor, 0, XRP(1200)),
+        env(sponsor::set_fee(sponsor, 0, XRP(350)),
             sponsor::SponseeAcc(alice),
             Fee(XRP(1)),
             Ter(tecUNFUNDED));
         env.close();
 
         // Increasing feeAmount to reach insufficient reserve
-        auto const currentFeeAmount = env.le(keylet::sponsorship(sponsor.id(), alice.id()))
-                                          ->getFieldAmount(sfFeeAmount)
-                                          .xrp();
         adjustAccountXRPBalance(env, sponsor, XRP(310));
-        env(sponsor::set_fee(sponsor, 0, currentFeeAmount + XRP(309)),
+        env(sponsor::set_fee(sponsor, 0, XRP(309)),
             sponsor::SponseeAcc(alice),
             Fee(XRP(1)),
             Ter(tecUNFUNDED));
@@ -353,26 +361,26 @@ public:
         Account const pseudoAcc("vault", vaultSle->getAccountID(sfAccount));
         env.memoize(pseudoAcc);
 
-        // Sponsee is a pseudo account -> tecNO_PERMISSION
+        // Sponsee is a pseudo account -> tecPSEUDO_ACCOUNT
         env(sponsor::set(sp, 0, 100, XRP(100)),
             sponsor::SponseeAcc(pseudoAcc),
-            Ter(tecNO_PERMISSION));
+            Ter(tecPSEUDO_ACCOUNT));
         env.close();
 
-        // Sponsor is a pseudo account -> tecNO_PERMISSION
+        // Sponsor is a pseudo account -> tecPSEUDO_ACCOUNT
         // (submitted by bob with counterpartySponsor pointing to pseudo account)
         env(sponsor::set(bob, tfDeleteObject),
             sponsor::CounterpartySponsor(pseudoAcc),
-            Ter(tecNO_PERMISSION));
+            Ter(tecPSEUDO_ACCOUNT));
         env.close();
     }
 
     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");
@@ -407,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");
@@ -543,7 +551,7 @@ public:
             BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(1));
 
             // update sponsorship (decrement)
-            env(sponsor::set(sponsor, 0, 50, XRP(50), XRP(0.5)),
+            env(sponsor::set(sponsor, 0, -50, XRP(-50), XRP(0.5)),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)),
                 Ter(tesSUCCESS));
@@ -557,7 +565,7 @@ public:
             BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(2));
 
             // update sponsorship (increment)
-            env(sponsor::set(sponsor, 0, 200, XRP(200), XRP(2)),
+            env(sponsor::set(sponsor, 0, 150, XRP(150), XRP(2)),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)),
                 Ter(tesSUCCESS));
@@ -591,26 +599,32 @@ public:
             env.close();
             BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
 
-            // Cannot create sponsorship with no fee or reserve budget. MaxFee
-            // and flags do not make a sponsorship object useful by themselves.
-            env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION));
-            env.close();
-            BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
-
             env(sponsor::set_max_fee(sponsor, 0, XRP(1)),
                 sponsor::SponseeAcc(alice),
                 Ter(tecNO_PERMISSION));
             env.close();
             BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
 
-            env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)),
+            env(sponsor::set(sponsor, 0, std::nullopt, std::nullopt, XRP(0)),
                 sponsor::SponseeAcc(alice),
                 Ter(tecNO_PERMISSION));
             env.close();
             BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
 
-            // update sponsorship with non-zero value
-            env(sponsor::set(sponsor, 0, 100, XRP(100), XRP(1)),
+            // create sponsorship with negative values
+            env(sponsor::set_reserve(sponsor, 0, -100),
+                sponsor::SponseeAcc(alice),
+                Ter(tecNO_PERMISSION));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
+            env(sponsor::set_fee(sponsor, 0, XRP(-100)),
+                sponsor::SponseeAcc(alice),
+                Ter(tecNO_PERMISSION));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
+
+            // create sponsorship with non-zero value
+            env(sponsor::set(sponsor, 0, 100, XRP(101), XRP(1)),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)));
             env.close();
@@ -618,7 +632,7 @@ public:
             sle = env.le(keylet::sponsorship(sponsor, alice));
             BEAST_EXPECT(sle);
             BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
-            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
+            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101));
             BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1));
 
             // update sponsorship flags
@@ -648,7 +662,7 @@ public:
                 lsfSponsorshipRequireSignForReserve);
 
             // Cannot update sponsorship so both fee and reserve budgets are absent.
-            env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)),
+            env(sponsor::set(sponsor, 0, -100, XRP(-101), std::nullopt),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)),
                 Ter(tecNO_PERMISSION));
@@ -657,17 +671,17 @@ public:
             sle = env.le(keylet::sponsorship(sponsor, alice));
             BEAST_EXPECT(sle);
             BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
-            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
+            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101));
             BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1));
         }
 
         {
             // Removing one budget field while the other remains keeps the
             // Sponsorship valid. Starting state (from above):
-            // RemainingOwnerCount = 100, FeeAmount = XRP(100).
+            // RemainingOwnerCount = 100, FeeAmount = XRP(101).
 
             // Remove only FeeAmount (set to 0); RemainingOwnerCount remains.
-            env(sponsor::set_fee(sponsor, 0, XRP(0)),
+            env(sponsor::set_fee(sponsor, 0, XRP(-101)),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)),
                 Ter(tesSUCCESS));
@@ -686,12 +700,51 @@ public:
                 Ter(tesSUCCESS));
             env.close();
 
-            env(sponsor::set_reserve(sponsor, 0, 0),
+            // A negative FeeAmountDelta larger than the current FeeAmount is
+            // clamped, so only the current FeeAmount is refunded and the field
+            // is removed. RemainingOwnerCount keeps the Sponsorship valid.
+            auto const balanceBefore = env.balance(sponsor);
+            env(sponsor::set_fee(sponsor, 0, XRP(-500)),
                 sponsor::SponseeAcc(alice),
                 Fee(XRP(1)),
                 Ter(tesSUCCESS));
             env.close();
 
+            sle = env.le(keylet::sponsorship(sponsor, alice));
+            BEAST_EXPECT(sle);
+            BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount));
+            BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
+            BEAST_EXPECT(env.balance(sponsor) == balanceBefore + XRP(100) - XRP(1));
+
+            // Restore FeeAmount for the checks below.
+            env(sponsor::set_fee(sponsor, 0, XRP(100)),
+                sponsor::SponseeAcc(alice),
+                Fee(XRP(1)),
+                Ter(tesSUCCESS));
+            env.close();
+
+            env(sponsor::set_reserve(sponsor, 0, -100),
+                sponsor::SponseeAcc(alice),
+                Fee(XRP(1)),
+                Ter(tesSUCCESS));
+            env.close();
+
+            sle = env.le(keylet::sponsorship(sponsor, alice));
+            BEAST_EXPECT(sle);
+            BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
+            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
+
+            // Decreasing FeeAmount below zero must fail with tecNO_PERMISSION
+            // when there is no RemainingOwnerCount (the budget would become
+            // entirely empty). Current state: FeeAmount = XRP(100), no
+            // RemainingOwnerCount.
+            env(sponsor::set_fee(sponsor, 0, XRP(-101)),
+                sponsor::SponseeAcc(alice),
+                Fee(XRP(1)),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            // Confirm that the sponsorship is unchanged.
             sle = env.le(keylet::sponsorship(sponsor, alice));
             BEAST_EXPECT(sle);
             BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
@@ -748,6 +801,160 @@ public:
         }
     }
 
+    void
+    testRemainingOwnerCountOverflow()
+    {
+        testcase("RemainingOwnerCount overflow and underflow clamping");
+        using namespace test::jtx;
+        Env env{*this, testableAmendments()};
+        Account const alice("alice");
+        Account const sponsor("sponsor");
+        env.fund(XRP(10000), alice, sponsor);
+        env.close();
+
+        constexpr std::int32_t kInt32Max = std::numeric_limits::max();
+
+        // --- Positive overflow: delta causes count to exceed UINT32_MAX ---
+        {
+            // Create with count = INT32_MAX.
+            env(sponsor::set_reserve(sponsor, 0, kInt32Max),
+                sponsor::SponseeAcc(alice),
+                Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
+                static_cast(kInt32Max));
+
+            // Add INT32_MAX again: count = 2 * INT32_MAX = 4294967294 (<= UINT32_MAX, still ok).
+            env(sponsor::set_reserve(sponsor, 0, kInt32Max),
+                sponsor::SponseeAcc(alice),
+                Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
+                2u * static_cast(kInt32Max));
+
+            // Adding 2 more pushes count to 4294967296, exceeding UINT32_MAX: reject.
+            env(sponsor::set_reserve(sponsor, 0, 2),
+                sponsor::SponseeAcc(alice),
+                Ter(tecLIMIT_EXCEEDED));
+            env.close();
+
+            // SLE is unchanged.
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
+                2u * static_cast(kInt32Max));
+
+            env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        // --- Negative underflow: clamps to 0; fee budget survives ---
+        {
+            // Create with count=10 and a fee budget.
+            env(sponsor::set(sponsor, 0, 10, XRP(100)),
+                sponsor::SponseeAcc(alice),
+                Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
+
+            // Delta of -20 produces count = -10; clamps to 0 (field absent).
+            env(sponsor::set_reserve(sponsor, 0, -20), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env.close();
+
+            auto sle = env.le(keylet::sponsorship(sponsor, alice));
+            BEAST_EXPECT(sle);
+            BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
+            BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
+
+            env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        // --- Negative underflow: clamped count=0 with no fee budget → no budget ---
+        {
+            // Create with count=10, no fee.
+            env(sponsor::set_reserve(sponsor, 0, 10), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
+
+            // Delta of -20 would clamp count to 0 with no fee → empty budget → tecNO_PERMISSION.
+            env(sponsor::set_reserve(sponsor, 0, -20),
+                sponsor::SponseeAcc(alice),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            // SLE is unchanged.
+            BEAST_EXPECT(
+                env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
+
+            env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env.close();
+        }
+    }
+
+    void
+    testConsequences()
+    {
+        testcase("Consequences");
+        using namespace test::jtx;
+        Env env{*this, testableAmendments()};
+        auto const baseFee = env.current()->fees().base;
+
+        Account const alice("alice");
+        Account const sponsor("sponsor");
+        env.memoize(alice);
+        env.memoize(sponsor);
+
+        {
+            // A positive FeeAmountDelta is the maximum XRP the tx can spend.
+            auto const jt = env.jt(
+                sponsor::set_fee(sponsor, 0, XRP(100)),
+                sponsor::SponseeAcc(alice),
+                Seq(1),
+                Fee(baseFee));
+            auto const pf =
+                preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
+            BEAST_EXPECT(isTesSuccess(pf.ter));
+            BEAST_EXPECT(!pf.consequences.isBlocker());
+            BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
+            BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(100));
+        }
+
+        {
+            // A negative FeeAmountDelta withdraws from the sponsorship, so the
+            // transaction cannot spend anything.
+            auto const jt = env.jt(
+                sponsor::set_fee(sponsor, 0, XRP(-100)),
+                sponsor::SponseeAcc(alice),
+                Seq(1),
+                Fee(baseFee));
+            auto const pf =
+                preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
+            BEAST_EXPECT(isTesSuccess(pf.ter));
+            BEAST_EXPECT(!pf.consequences.isBlocker());
+            BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
+            BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0));
+        }
+
+        {
+            // No FeeAmountDelta at all.
+            auto const jt = env.jt(
+                sponsor::set_reserve(sponsor, 0, 10),
+                sponsor::SponseeAcc(alice),
+                Seq(1),
+                Fee(baseFee));
+            auto const pf =
+                preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
+            BEAST_EXPECT(isTesSuccess(pf.ter));
+            BEAST_EXPECT(!pf.consequences.isBlocker());
+            BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
+            BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0));
+        }
+    }
+
     void
     testPreFundAndCosign()
     {
@@ -782,7 +989,8 @@ public:
             BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 99);
             BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(99));
 
-            env(check::cancel(alice, keylet::check(alice, checkSeq).key), Ter(tesSUCCESS));
+            env(check::cancel(alice, keylet::check(alice, SeqProxy::rawSequence(checkSeq)).key),
+                Ter(tesSUCCESS));
             env.close();
 
             sle = env.le(keylet::sponsorship(sponsor, alice));
@@ -810,7 +1018,7 @@ public:
                 Ter(terINSUF_FEE_B));
             env.close();
 
-            env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
+            env(sponsor::set_reserve(sponsor, 0, -1), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
             env.close();
 
             // reserve insufficient
@@ -865,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");
@@ -952,11 +1163,30 @@ public:
                     sponsor::SponseeAcc(alice),
                     Ter(temMALFORMED));
             }
+
+            // Post-fixCleanup3_5_0, a zero ObjectID is malformed.
+            // Pre-fixCleanup3_5_0 path is unreachable so it is not testable.
+            if (features[fixCleanup3_5_0])
+            {
+                uint256 const zeroObjectID{};
+
+                env(sponsor::transfer(alice, tfSponsorshipEnd, zeroObjectID), Ter(temMALFORMED));
+
+                env(sponsor::transfer(alice, tfSponsorshipCreate, zeroObjectID),
+                    sponsor::As(sponsor, spfSponsorReserve),
+                    Sig(sfSponsorSignature, sponsor),
+                    Ter(temMALFORMED));
+
+                env(sponsor::transfer(alice, tfSponsorshipReassign, zeroObjectID),
+                    sponsor::As(sponsor, spfSponsorReserve),
+                    Sig(sfSponsorSignature, sponsor),
+                    Ter(temMALFORMED));
+            }
         }
 
         {
             // 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");
@@ -1001,7 +1231,7 @@ public:
 
         {
             // sponsor account
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1132,7 +1362,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");
@@ -1156,7 +1386,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");
@@ -1171,7 +1401,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(alice, seq).key;
+            auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr);
 
             env(sponsor::transfer(alice, tfSponsorshipCreate, checkId),
@@ -1184,7 +1414,8 @@ public:
             env.close();
 
             // Invalid ObjectID (not found)
-            env(sponsor::transfer(alice, tfSponsorshipCreate, keylet::check(alice, 0).key),
+            env(sponsor::transfer(
+                    alice, tfSponsorshipCreate, keylet::check(alice, SeqProxy::rawSequence(0)).key),
                 sponsor::As(sponsor1, spfSponsorReserve),
                 Sig(sfSponsorSignature, sponsor1),
                 Ter(tecNO_ENTRY));
@@ -1264,10 +1495,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();
 
@@ -1291,7 +1532,7 @@ public:
             auto const ticketSeq = env.seq(alice);
             env(ticket::create(alice, 1));
             env.close();
-            auto ticketId = keylet::ticket(alice, ticketSeq + 1).key;
+            auto ticketId = keylet::ticket(alice, SeqProxy::rawTicket(ticketSeq + 1)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(ticketId)));
             env(sponsor::transfer(alice, tfSponsorshipEnd, ticketId), Ter(tecNO_PERMISSION));
             env.close();
@@ -1300,7 +1541,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");
@@ -1312,7 +1553,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(alice, seq).key;
+            auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr);
 
             env(sponsor::transfer(alice, tfSponsorshipCreate, checkId),
@@ -1334,7 +1575,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");
@@ -1346,7 +1587,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(alice, seq).key;
+            auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr);
 
             // insufficient reserve count
@@ -1437,7 +1678,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");
@@ -1448,7 +1689,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(alice, seq).key;
+            auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr);
 
             env(sponsor::transfer(alice, tfSponsorshipCreate, checkId),
@@ -1477,7 +1718,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");
@@ -1488,7 +1729,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(alice, seq).key;
+            auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr);
 
             env(sponsor::transfer(alice, tfSponsorshipCreate, checkId),
@@ -1535,7 +1776,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();
 
@@ -1579,7 +1820,7 @@ public:
 
         {
             // invalid transfer
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1616,7 +1857,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);
@@ -1633,7 +1874,7 @@ public:
             auto const ticketSeq = env.seq(alice);
             env(ticket::create(alice, 1));
             env.close();
-            auto const ticketID = keylet::ticket(alice, ticketSeq + 1).key;
+            auto const ticketID = keylet::ticket(alice, SeqProxy::rawTicket(ticketSeq + 1)).key;
             BEAST_EXPECT(env.le(keylet::unchecked(ticketID)));
             checkBlocked(alice, ticketID);
 
@@ -1655,7 +1896,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();
 
@@ -1663,15 +1908,18 @@ public:
                 {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
-            env(loanBroker::set(alice, vaultKeylet.key),
-                loanBroker::kDebtMaximum(xrpAsset(1000).value()),
-                loanBroker::kManagementFeeRate(TenthBips16{0}),
-                loanBroker::kCoverRateMinimum(TenthBips32{0}),
-                loanBroker::kCoverRateLiquidation(TenthBips32{0}));
+            vault.closePastSubscription(subscriptionDate);
+
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+            env(loan_broker::set(alice, vaultKeylet.key),
+                loan_broker::kDebtMaximum(xrpAsset(1000).value()),
+                loan_broker::kManagementFeeRate(TenthBips16{0}),
+                loan_broker::kCoverRateMinimum(TenthBips32{0}),
+                loan_broker::kCoverRateLiquidation(TenthBips32{0}));
             env.close();
 
-            auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
             env(loan::set(borrower, brokerKeylet.key, xrpAsset(100).value()),
                 Sig(sfCounterpartySignature, alice),
                 Fee(env.current()->fees().base * 2));
@@ -2019,7 +2267,7 @@ public:
             env(sponsor::set_fee(sponsor, 0, fixFee), sponsor::SponseeAcc(alice));
             env.close();
 
-            env(ledgerStateFix::nftPageLinks(alice, alice),
+            env(ledger_state_fix::nftPageLinks(alice, alice),
                 Fee(fixFee),
                 sponsor::As(sponsor, spfSponsorFee),
                 Ter(tecFAILED_PROCESSING));
@@ -2043,7 +2291,7 @@ public:
 
             OpenView overlay(&*env.closed());
             auto jt = env.jt(
-                ledgerStateFix::nftPageLinks(alice, alice),
+                ledger_state_fix::nftPageLinks(alice, alice),
                 Fee(fixFee),
                 sponsor::As(sponsor, spfSponsorFee));
 
@@ -2090,7 +2338,7 @@ public:
                 XRP(10));
 
             // clear flag
-            env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)),
+            env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee),
                 sponsor::SponseeAcc(alice));
             env.close();
 
@@ -2322,7 +2570,7 @@ public:
                 XRP(10));
 
             // clear flag
-            env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)),
+            env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee),
                 sponsor::SponseeAcc(alice));
             env.close();
 
@@ -2597,7 +2845,7 @@ public:
             BEAST_EXPECT(sponsoringOwnerCount(env, alice) == 0);
             BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1);
 
-            auto const keylet = keylet::check(alice, seq);
+            auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq));
             BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id());
 
             if (cosigning)
@@ -2661,7 +2909,7 @@ public:
             BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0);
 
             // CheckCash
-            auto const checkId2 = keylet::check(alice, seq2).key;
+            auto const checkId2 = keylet::check(alice, SeqProxy::rawSequence(seq2)).key;
             env(check::cash(bob, checkId2, XRP(1)));
             env.close();
 
@@ -2701,7 +2949,7 @@ public:
             BEAST_EXPECT(ownerCount(env, bob) == 0);
             BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0);
 
-            auto const keylet = keylet::check(alice, seq2);
+            auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq2));
             BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id());
 
             // CheckCash
@@ -2767,7 +3015,7 @@ public:
                     submit(check::create(alice, bob, mpt(1)));
                 });
 
-            auto const checkKeylet = keylet::check(alice, seq2);
+            auto const checkKeylet = keylet::check(alice, SeqProxy::rawSequence(seq2));
             BEAST_EXPECT(env.le(checkKeylet)->getAccountID(sfSponsor) == sponsor.id());
             BEAST_EXPECT(ownerCount(env, bob) == 0);
 
@@ -3176,12 +3424,16 @@ public:
                         escrow::kCancelTime(env.now() + 100s));
                 });
             BEAST_EXPECT(
-                env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id());
+                env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))
+                    ->getAccountID(sfSponsor) == sponsor.id());
 
             // transfer sponsor
             if (cosigning)
             {
-                env(sponsor::transfer(alice, tfSponsorshipReassign, keylet::escrow(alice, seq).key),
+                env(sponsor::transfer(
+                        alice,
+                        tfSponsorshipReassign,
+                        keylet::escrow(alice, SeqProxy::rawSequence(seq)).key),
                     sponsor::As(sponsor2, spfSponsorReserve),
                     Sig(sfSponsorSignature, sponsor2));
                 env.close();
@@ -3191,7 +3443,10 @@ public:
                 env(sponsor::set_reserve(sponsor2, 0, 1), sponsor::SponseeAcc(alice));
                 env.close();
 
-                env(sponsor::transfer(alice, tfSponsorshipReassign, keylet::escrow(alice, seq).key),
+                env(sponsor::transfer(
+                        alice,
+                        tfSponsorshipReassign,
+                        keylet::escrow(alice, SeqProxy::rawSequence(seq)).key),
                     sponsor::As(sponsor2, spfSponsorReserve));
                 env.close();
             }
@@ -3202,7 +3457,8 @@ public:
             BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1);
 
             BEAST_EXPECT(
-                env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor2.id());
+                env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))
+                    ->getAccountID(sfSponsor) == sponsor2.id());
 
             // EscrowFinish
             env(escrow::finish(bob, alice, seq),
@@ -3256,7 +3512,8 @@ public:
                 });
 
             BEAST_EXPECT(
-                env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id());
+                env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))
+                    ->getAccountID(sfSponsor) == sponsor.id());
 
             // EscrowFinish
             testEachSponsorship(
@@ -3318,7 +3575,8 @@ public:
                 });
 
             BEAST_EXPECT(
-                env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id());
+                env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))
+                    ->getAccountID(sfSponsor) == sponsor.id());
 
             if (cosigning)
             {
@@ -3419,7 +3677,7 @@ public:
                 tecNO_LINE_INSUF_RESERVE,
                 [&](Env& env, auto const& submit) { submit(escrow::cancel(alice, alice, seq)); },
                 [&]() {
-                    BEAST_EXPECT(!env.le(keylet::escrow(alice, seq)));
+                    BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
                     auto const trustSle = env.le(keylet::trustLine(alice, gw, usd.currency));
                     BEAST_EXPECT(trustSle);
                     if (trustSle)
@@ -3522,7 +3780,8 @@ public:
                 });
 
             BEAST_EXPECT(
-                env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id());
+                env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))
+                    ->getAccountID(sfSponsor) == sponsor.id());
 
             if (cosigning)
             {
@@ -3604,7 +3863,7 @@ public:
             }
             env.close();
 
-            BEAST_EXPECT(!env.le(keylet::escrow(alice, seq)));
+            BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
             BEAST_EXPECT(ownerCount(env, alice) == 0);
             BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0);
             BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0);
@@ -4548,7 +4807,7 @@ public:
             env(check::create(alice, bob, XRP(1)));
             env.close();
 
-            auto const keylet = keylet::check(alice, seq);
+            auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq));
 
             env(sponsor::transfer(alice, tfSponsorshipCreate, keylet.key),
                 sponsor::As(bob, spfSponsorReserve),
@@ -4939,7 +5198,7 @@ public:
             env.close();
 
             // Create pre-funded sponsorship
-            env(sponsor::set(sponsor, 0, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1)));
+            env(sponsor::set_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1)));
             env.close();
 
             auto const seq = env.seq(alice);
@@ -5227,13 +5486,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");
@@ -5285,24 +5543,22 @@ public:
 
             if (expected == tesSUCCESS)
             {
-                BEAST_EXPECT(!env.le(keylet::escrow(alice, seq)));
+                BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
                 BEAST_EXPECT(env.le(keylet::trustLine(alice, gw, usd.currency)));
                 BEAST_EXPECT(env.balance(alice, usd) == usd(100));
                 BEAST_EXPECT(ownerCount(env, alice) == 1);  // the new line
             }
             else
             {
-                BEAST_EXPECT(env.le(keylet::escrow(alice, seq)));
+                BEAST_EXPECT(env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))));
                 BEAST_EXPECT(!env.le(keylet::trustLine(alice, gw, usd.currency)));
                 BEAST_EXPECT(ownerCount(env, alice) == 1);  // still the escrow
             }
         };
 
-        // 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);
     }
 
@@ -5345,9 +5601,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),
@@ -5392,7 +5648,8 @@ public:
             BEAST_EXPECT(sponsorCountBefore == 1);  // check costs 1 owner count
 
             // Cancel (delete) the check.
-            env(check::cancel(checkOwner, keylet::check(checkOwner, checkSeq).key));
+            env(check::cancel(
+                checkOwner, keylet::check(checkOwner, SeqProxy::rawSequence(checkSeq)).key));
             env.close();
 
             auto sponsorCountAfter = sponsoringOwnerCount(env, sponsor);
@@ -5437,17 +5694,24 @@ 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();
 
         testSimpleSponsorshipSet();
+        testRemainingOwnerCountOverflow();
+        testConsequences();
 
         testPreFundAndCosign();
         testSponsoredFreeTierReserve();
 
-        testTransferSponsor();
+        testTransferSponsor(jtx::testableAmendments());
+        testTransferSponsor(jtx::testableAmendments() - fixCleanup3_4_0);
         testLegacySignerListReserve();
         testSponsorFee();
         testSponsorAccount();
diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp
index 3175e742d9..5b257449e0 100644
--- a/src/test/app/TxQ_test.cpp
+++ b/src/test/app/TxQ_test.cpp
@@ -2571,7 +2571,7 @@ public:
         auto fee = env.rpc("fee");
 
         if (BEAST_EXPECT(fee.isMember(jss::result)) &&
-            BEAST_EXPECT(!RPC::containsError(fee[jss::result])))
+            BEAST_EXPECT(!rpc::containsError(fee[jss::result])))
         {
             auto const& result = fee[jss::result];
             BEAST_EXPECT(
@@ -2600,7 +2600,7 @@ public:
         fee = env.rpc("fee");
 
         if (BEAST_EXPECT(fee.isMember(jss::result)) &&
-            BEAST_EXPECT(!RPC::containsError(fee[jss::result])))
+            BEAST_EXPECT(!rpc::containsError(fee[jss::result])))
         {
             auto const& result = fee[jss::result];
             BEAST_EXPECT(
@@ -2889,7 +2889,7 @@ public:
         checkMetrics(*this, env, 5, std::nullopt, 7, 6);
         {
             auto aliceStat = txQ.getAccountTxs(alice.id());
-            SeqProxy seq = SeqProxy::sequence(aliceSeq);
+            SeqProxy seq = SeqProxy::rawSequence(aliceSeq);
             BEAST_EXPECT(aliceStat.size() == 5);
             for (auto const& tx : aliceStat)
             {
@@ -3225,7 +3225,7 @@ public:
 
         {
             auto const info = env.rpc("json", "account_info", to_string(prevLedgerWithQueue));
-            BEAST_EXPECT(info.isMember(jss::result) && RPC::containsError(info[jss::result]));
+            BEAST_EXPECT(info.isMember(jss::result) && rpc::containsError(info[jss::result]));
         }
 
         env.close();
@@ -3754,7 +3754,7 @@ public:
             checkMetrics(*this, env, 2, 24, 16, 12);
             auto const aliceQueue = env.app().getTxQ().getAccountTxs(alice.id());
             BEAST_EXPECT(aliceQueue.size() == 2);
-            SeqProxy seq = SeqProxy::sequence(aliceSeq);
+            SeqProxy seq = SeqProxy::rawSequence(aliceSeq);
             for (auto const& tx : aliceQueue)
             {
                 BEAST_EXPECT(tx.seqProxy == seq);
@@ -4630,7 +4630,7 @@ public:
             auto const fee = env.rpc("fee");
 
             if (BEAST_EXPECT(fee.isMember(jss::result)) &&
-                BEAST_EXPECT(!RPC::containsError(fee[jss::result])))
+                BEAST_EXPECT(!rpc::containsError(fee[jss::result])))
             {
                 auto const& result = fee[jss::result];
 
@@ -4688,7 +4688,7 @@ public:
             auto const fee = env.rpc("fee");
 
             if (BEAST_EXPECT(fee.isMember(jss::result)) &&
-                BEAST_EXPECT(!RPC::containsError(fee[jss::result])))
+                BEAST_EXPECT(!rpc::containsError(fee[jss::result])))
             {
                 auto const& result = fee[jss::result];
 
diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp
index 60228f6723..d2e6cb24aa 100644
--- a/src/test/app/ValidatorList_test.cpp
+++ b/src/test/app/ValidatorList_test.cpp
@@ -278,8 +278,10 @@ private:
                 trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers));
             BEAST_EXPECT(trustedKeys->listed(localSigningPublicOuter));
 
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            manifests.applyManifest(*deserializeManifest(cfgManifest));
+            // NOLINTBEGIN(bugprone-unchecked-optional-access)
+            manifests.applyManifest(
+                *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped);
+            // NOLINTEND(bugprone-unchecked-optional-access)
             BEAST_EXPECT(
                 trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers));
 
@@ -369,8 +371,10 @@ private:
                 app.config().legacy(Sections::kDatabasePath),
                 env.journal);
 
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            manifests.applyManifest(*deserializeManifest(cfgManifest));
+            // NOLINTBEGIN(bugprone-unchecked-optional-access)
+            manifests.applyManifest(
+                *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped);
+            // NOLINTEND(bugprone-unchecked-optional-access)
 
             BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers));
 
@@ -455,13 +459,16 @@ private:
             auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1);
             // make this manifest revoked (seq num = max)
             //  -- thus should not be loaded
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            pubManifests.applyManifest(*deserializeManifest(makeManifestString(
-                pubRevokedPublic,
-                pubRevokedSecret,
-                pubRevokedSigning.first,
-                pubRevokedSigning.second,
-                std::numeric_limits::max())));
+            // NOLINTBEGIN(bugprone-unchecked-optional-access)
+            pubManifests.applyManifest(
+                *deserializeManifest(makeManifestString(
+                    pubRevokedPublic,
+                    pubRevokedSecret,
+                    pubRevokedSigning.first,
+                    pubRevokedSigning.second,
+                    std::numeric_limits::max())),
+                ManifestRateLimitCapPolicy::Capped);
+            // NOLINTEND(bugprone-unchecked-optional-access)
 
             // these two are not revoked (and not in the manifest cache at all.)
             auto legitKey1 = randomMasterKey();
@@ -494,13 +501,16 @@ private:
             auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1);
             // make this manifest revoked (seq num = max)
             //  -- thus should not be loaded
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            pubManifests.applyManifest(*deserializeManifest(makeManifestString(
-                pubRevokedPublic,
-                pubRevokedSecret,
-                pubRevokedSigning.first,
-                pubRevokedSigning.second,
-                std::numeric_limits::max())));
+            // NOLINTBEGIN(bugprone-unchecked-optional-access)
+            pubManifests.applyManifest(
+                *deserializeManifest(makeManifestString(
+                    pubRevokedPublic,
+                    pubRevokedSecret,
+                    pubRevokedSigning.first,
+                    pubRevokedSigning.second,
+                    std::numeric_limits::max())),
+                ManifestRateLimitCapPolicy::Capped);
+            // NOLINTEND(bugprone-unchecked-optional-access)
 
             // this one is not revoked (and not in the manifest cache at all.)
             auto legitKey = randomMasterKey();
@@ -1218,7 +1228,8 @@ private:
 
             BEAST_EXPECT(
                 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                manifestsOuter.applyManifest(std::move(*m1)) == ManifestDisposition::Accepted);
+                manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
             BEAST_EXPECT(trustedKeysOuter->listed(masterPublic));
             BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic));
             BEAST_EXPECT(trustedKeysOuter->listed(signingPublic1));
@@ -1232,7 +1243,8 @@ private:
                 masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2));
             BEAST_EXPECT(
                 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                manifestsOuter.applyManifest(std::move(*m2)) == ManifestDisposition::Accepted);
+                manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
             BEAST_EXPECT(trustedKeysOuter->listed(masterPublic));
             BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic));
             BEAST_EXPECT(trustedKeysOuter->listed(signingPublic2));
@@ -1249,7 +1261,8 @@ private:
             // NOLINTBEGIN(bugprone-unchecked-optional-access)
             BEAST_EXPECT(max->revoked());
             BEAST_EXPECT(
-                manifestsOuter.applyManifest(std::move(*max)) == ManifestDisposition::Accepted);
+                manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCapPolicy::Capped) ==
+                ManifestDisposition::Accepted);
             // NOLINTEND(bugprone-unchecked-optional-access)
 
             BEAST_EXPECT(manifestsOuter.getSigningKey(masterPublic) == masterPublic);
@@ -2240,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";
@@ -2262,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);
@@ -2310,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))
@@ -2334,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,
@@ -2458,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());
@@ -2529,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.
 
@@ -2549,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
@@ -2668,7 +2553,9 @@ private:
             auto threshold = listThreshold > 0 ? std::optional(listThreshold) : std::nullopt;
             if (self)
             {
-                valManifests.applyManifest(*deserializeManifest(base64Decode(self->manifest)));
+                valManifests.applyManifest(
+                    *deserializeManifest(base64Decode(self->manifest)),
+                    ManifestRateLimitCapPolicy::Capped);
                 BEAST_EXPECT(
                     result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold));
             }
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 12ad7e6782..0000000000
--- a/src/test/app/Vault_test.cpp
+++ /dev/null
@@ -1,8346 +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 {
-
-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),
-                 .mutableFlags = tmfMPTCanEnableCanTransfer});
-            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,
-                 .mutableFlags = tmfMPTCanEnableCanTrade});
-            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({.mutableFlags = tmfMPTSetCanTrade});
-            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(), 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 loanBroker;
-            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(), 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 loanBroker;
-        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(), env.seq(owner));
-
-            env(set(owner, vaultKeylet.key));
-            env.close();
-
-            auto const& loanKeylet = keylet::loan(brokerKeylet.key, 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 loanBroker;
-        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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), env.seq(f.lender)).key;
-        {
-            using namespace loanBroker;
-            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, 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(), 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(), 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
-    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();
-        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..91984f2021
--- /dev/null
+++ b/src/test/app/invariants/InvariantsMPT_test.cpp
@@ -0,0 +1,1646 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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;
+                });
+        }
+
+        // LoanSet / VaultWithdraw MayAuthorizeMpt caps (fixCleanup3_4_0):
+        // LoanSet allows at most two creates and no deletes; VaultWithdraw
+        // allows at most one of each. Fabricate one extra mutation so a
+        // too-loose cap would miss these.
+        {
+            auto const insertHolderTokens =
+                [](Account const& issuer, Account const& holder, ApplyContext& ac, int n) {
+                    auto const sle = ac.view().peek(keylet::account(issuer.id()));
+                    if (!sle)
+                        return false;
+                    auto seq = sle->getFieldU32(sfSequence);
+                    for (int i = 0; i < n; ++i)
+                    {
+                        MPTIssue const mpt{makeMptID(seq + i, issuer)};
+                        auto sleNew =
+                            std::make_shared(keylet::mptoken(mpt.getMptID(), holder));
+                        (*sleNew)[sfAccount] = holder.id();
+                        (*sleNew)[sfMPTokenIssuanceID] = mpt.getMptID();
+                        ac.view().insert(sleNew);
+                    }
+                    return true;
+                };
+
+            std::array, 2> const createOverCap{
+                {{ttLOAN_SET, 3}, {ttVAULT_WITHDRAW, 2}}};
+            for (auto const& [txnType, nTokens] : createOverCap)
+            {
+                doInvariantCheck(
+                    {{"MPT authorize succeeded but created/deleted bad number mptokens"}},
+                    [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                        return insertHolderTokens(a1, a2, ac, nTokens);
+                    },
+                    XRPAmount{},
+                    STTx{txnType, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+            }
+
+            MPTID id;
+            auto const precloseTwoHolders = [&id](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}});
+                id = mpt.issuanceID();
+                return true;
+            };
+            std::array, 2> const deleteOverCap{
+                {{ttLOAN_SET, 1}, {ttVAULT_WITHDRAW, 2}}};
+            for (auto const& [txnType, nTokens] : deleteOverCap)
+            {
+                doInvariantCheck(
+                    {{"MPT authorize succeeded but created/deleted bad number mptokens"}},
+                    [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                        std::array const holders{a1, a2};
+                        for (int i = 0; i < nTokens; ++i)
+                        {
+                            auto sle = ac.view().peek(keylet::mptoken(id, holders[i]));
+                            if (!sle)
+                                return false;
+                            ac.view().erase(sle);
+                        }
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{txnType, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    precloseTwoHolders);
+            }
+        }
+
+        // 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..e264b91cb1
--- /dev/null
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -0,0 +1,3284 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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: neither PrincipalOutstanding nor
+        // TotalValueOutstanding may increase, at least one of them must
+        // strictly decrease, PaymentRemaining must strictly decrease, and
+        // NextPaymentDueDate must advance by a positive multiple of
+        // PaymentInterval. Each failing case seeds the same loan, then applies
+        // an after-image that breaks exactly one of those conditions.
+        {
+            struct Case
+            {
+                Number principal;
+                Number totalValue;
+                std::uint32_t remaining;
+                std::uint32_t dueDate;
+                std::string expected;
+            };
+            auto const cases = std::to_array({
+                {.principal = Number(100),
+                 .totalValue = Number(150),
+                 .remaining = 1,
+                 .dueDate = 110,
+                 .expected = "loan pay must decrease PrincipalOutstanding or "
+                             "TotalValueOutstanding"},
+                {.principal = Number(110),
+                 .totalValue = Number(150),
+                 .remaining = 1,
+                 .dueDate = 110,
+                 .expected = "loan pay must not increase PrincipalOutstanding"},
+                {.principal = Number(50),
+                 .totalValue = Number(160),
+                 .remaining = 1,
+                 .dueDate = 110,
+                 .expected = "loan pay must not increase TotalValueOutstanding"},
+                {.principal = Number(50),
+                 .totalValue = Number(150),
+                 .remaining = 2,
+                 .dueDate = 110,
+                 .expected = "loan pay must decrease PaymentRemaining"},
+                {.principal = Number(50),
+                 .totalValue = Number(150),
+                 .remaining = 1,
+                 .dueDate = 100,
+                 .expected = "loan pay must advance NextPaymentDueDate"},
+                // Advanced, but not by a whole number of payment intervals.
+                {.principal = Number(50),
+                 .totalValue = Number(150),
+                 .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->at(sfTotalValueOutstanding) = c.totalValue;
+                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));
+            }
+
+            // Principal may stick while TotalValueOutstanding falls. This
+            // after-image is only a Loan mutation, so other (vault) invariants
+            // still fail under Full scope; ValidLoan itself must not.
+            {
+                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)
+                {
+                    fail();
+                }
+                else
+                {
+                    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))
+                    {
+                        sleLoan->at(sfPrincipalOutstanding) = Number(100);
+                        sleLoan->at(sfTotalValueOutstanding) = Number(140);
+                        sleLoan->setFieldU32(sfPaymentRemaining, 1);
+                        sleLoan->setFieldU32(sfNextPaymentDueDate, 110);
+                        ac.view().update(sleLoan);
+
+                        auto transactor = makeTransactor(ac);
+                        if (BEAST_EXPECT(transactor))
+                        {
+                            std::ignore = transactor->checkInvariants(
+                                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+                            auto const logs = sink.messages().str();
+                            BEAST_EXPECT(!logs.contains("Invariant failed: Loan"));
+                            BEAST_EXPECT(!logs.contains("loan pay"));
+                        }
+                    }
+                }
+            }
+        }
+
+        // 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/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
similarity index 78%
rename from src/test/app/LendingHelpers_test.cpp
rename to src/test/app/lending/LendingHelpers_test.cpp
index ac8e0764fc..c509469de9 100644
--- a/src/test/app/LendingHelpers_test.cpp
+++ b/src/test/app/lending/LendingHelpers_test.cpp
@@ -2,23 +2,34 @@
 // 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 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -407,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.
@@ -1470,6 +1481,331 @@ class LendingHelpers_test : public beast::unit_test::Suite
              Number{-18304, -5}));
     }
 
+    void
+    testInstantRecognitionLoanOriginationDeltas()
+    {
+        using namespace xrpl::instant_recognition;
+
+        struct TestCase
+        {
+            std::string name;
+            Number principalRequested;
+            Number interestDue;
+        };
+
+        auto const testCases = std::vector{
+            {.name = "Zero interest",
+             .principalRequested = Number{1'000},
+             .interestDue = Number{0}},
+            {.name = "Nonzero interest",
+             .principalRequested = Number{1'000},
+             .interestDue = Number{75}},
+        };
+
+        for (auto const& tc : testCases)
+        {
+            testcase("instant_recognition::loanOriginationDeltas: " + tc.name);
+
+            auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue);
+            BEAST_EXPECTS(
+                deltas.assetsTotalDelta == tc.interestDue,
+                "assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " +
+                    to_string(deltas.assetsTotalDelta));
+            BEAST_EXPECTS(
+                deltas.debtTotalDelta == tc.principalRequested + tc.interestDue,
+                "debtTotalDelta mismatch: expected " +
+                    to_string(tc.principalRequested + tc.interestDue) + ", got " +
+                    to_string(deltas.debtTotalDelta));
+        }
+    }
+
+    void
+    testCashBasisLoanOriginationDeltas()
+    {
+        using namespace xrpl::cash_basis;
+
+        testcase("cash_basis::loanOriginationDeltas: interestDue is ignored");
+
+        Number const principalRequested{1'000};
+        Number const interestDue{75};
+
+        auto const deltas = loanOriginationDeltas(principalRequested);
+        BEAST_EXPECTS(
+            deltas.assetsTotalDelta == 0,
+            "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta));
+        BEAST_EXPECTS(
+            deltas.debtTotalDelta == principalRequested,
+            "debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " +
+                to_string(deltas.debtTotalDelta));
+    }
+
+    void
+    testInstantRecognitionLoanOriginationExceedsVaultMaximum()
+    {
+        using namespace xrpl::instant_recognition;
+
+        struct TestCase
+        {
+            std::string name;
+            Number vaultMaximum;
+            Number vaultTotal;
+            Number interestDue;
+            bool expected;
+        };
+
+        auto const testCases = std::vector{
+            {.name = "No maximum configured",
+             .vaultMaximum = Number{0},
+             .vaultTotal = Number{900},
+             .interestDue = Number{1'000},
+             .expected = false},
+            {.name = "Interest fits under headroom",
+             .vaultMaximum = Number{1'000},
+             .vaultTotal = Number{900},
+             .interestDue = Number{50},
+             .expected = false},
+            {.name = "Interest exactly fills headroom",
+             .vaultMaximum = Number{1'000},
+             .vaultTotal = Number{900},
+             .interestDue = Number{100},
+             .expected = false},
+            {.name = "Interest exceeds headroom",
+             .vaultMaximum = Number{1'000},
+             .vaultTotal = Number{900},
+             .interestDue = Number{101},
+             .expected = true},
+        };
+
+        for (auto const& tc : testCases)
+        {
+            testcase("instant_recognition::loanOriginationExceedsVaultMaximum: " + tc.name);
+            BEAST_EXPECT(
+                loanOriginationExceedsVaultMaximum(
+                    tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected);
+        }
+    }
+
+    // Constructs a minimal ltLOAN SLE with just the fields needed by
+    // loanVaultExposure. Mirrors the bare-SLE pattern used by
+    // testCanApplyToBrokerCover for ltLOAN_BROKER.
+    static std::shared_ptr
+    makeLoanSle(
+        Number const& totalValueOutstanding,
+        Number const& principalOutstanding,
+        Number const& managementFeeOutstanding)
+    {
+        auto sle = std::make_shared(ltLOAN, uint256{1u});
+        sle->at(sfTotalValueOutstanding) = totalValueOutstanding;
+        sle->at(sfPrincipalOutstanding) = principalOutstanding;
+        sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding;
+        return sle;
+    }
+
+    // Constructs a minimal ltVAULT SLE with just LEVersion set (or left
+    // absent), for exercising the dispatchers' per-Vault gating.
+    static std::shared_ptr
+    makeVaultSle(
+        std::optional leVersion = std::nullopt,
+        std::optional assetsMaximum = std::nullopt,
+        std::optional assetsTotal = std::nullopt)
+    {
+        auto sle = std::make_shared(ltVAULT, uint256{2u});
+        if (leVersion)
+            sle->at(sfLEVersion) = std::to_underlying(*leVersion);
+        if (assetsMaximum)
+            sle->at(sfAssetsMaximum) = *assetsMaximum;
+        if (assetsTotal)
+            sle->at(sfAssetsTotal) = *assetsTotal;
+        return sle;
+    }
+
+    void
+    testInstantRecognitionLoanVaultExposure()
+    {
+        testcase("instant_recognition::loanVaultExposure");
+
+        auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+        BEAST_EXPECT(xrpl::instant_recognition::loanVaultExposure(sle) == Number{950});
+    }
+
+    void
+    testCashBasisLoanVaultExposure()
+    {
+        testcase("cash_basis::loanVaultExposure");
+
+        auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+        BEAST_EXPECT(xrpl::cash_basis::loanVaultExposure(sle) == Number{800});
+    }
+
+    void
+    testLoanPaymentDeltas()
+    {
+        // principalPaid, interestPaid, feePaid, valueChange are all distinct
+        // and nonzero, with a nonzero valueChange simulating a late-payment
+        // penalty, so InstantRecognition's formula is meaningfully exercised.
+        LoanPaymentParts const parts{
+            .principalPaid = Number{100},
+            .interestPaid = Number{20},
+            .valueChange = Number{5},
+            .feePaid = Number{3}};
+
+        {
+            testcase("instant_recognition::loanPaymentDeltas: nonzero valueChange");
+            auto const deltas = xrpl::instant_recognition::loanPaymentDeltas(parts);
+            BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange);
+            BEAST_EXPECT(
+                deltas.debtTotalDelta ==
+                (parts.principalPaid + parts.interestPaid) - parts.valueChange);
+        }
+
+        {
+            testcase("cash_basis::loanPaymentDeltas: nonzero valueChange ignored");
+            auto const deltas = xrpl::cash_basis::loanPaymentDeltas(parts);
+            BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid);
+            BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid);
+        }
+    }
+
+    void
+    testLoanOriginationDeltasDispatcher()
+    {
+        using namespace jtx;
+
+        Number const principalRequested{1'000};
+        Number const interestDue{75};
+
+        auto const legacyVault = makeVaultSle();
+        auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
+
+        {
+            testcase(
+                "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks "
+                "InstantRecognition");
+            Env const env{*this};
+            auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue);
+            auto const expected =
+                xrpl::instant_recognition::loanOriginationDeltas(principalRequested, interestDue);
+            BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
+            BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
+        }
+
+        {
+            testcase(
+                "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == "
+                "VaultVersion::CashBasis picks CashBasis");
+            Env const env{*this};
+            auto const deltas =
+                loanOriginationDeltas(cashBasisVault, principalRequested, interestDue);
+            auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested);
+            BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
+            BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
+        }
+    }
+
+    void
+    testLoanOriginationExceedsVaultMaximumDispatcher()
+    {
+        using namespace jtx;
+
+        Number const vaultMaximum{1'000};
+        Number const vaultTotal{900};
+        // Exceeds InstantRecognition's headroom (100), but must never trip CashBasis.
+        Number const interestDue{101};
+
+        auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal);
+        auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal);
+
+        {
+            testcase(
+                "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault "
+                "picks InstantRecognition");
+            Env const env{*this};
+            BEAST_EXPECT(
+                loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) ==
+                xrpl::instant_recognition::loanOriginationExceedsVaultMaximum(
+                    vaultMaximum, vaultTotal, interestDue));
+        }
+
+        {
+            testcase(
+                "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == "
+                "VaultVersion::CashBasis picks CashBasis");
+            Env const env{*this};
+            BEAST_EXPECT(
+                loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) ==
+                false);
+        }
+    }
+
+    void
+    testLoanVaultExposureDispatcher()
+    {
+        using namespace jtx;
+
+        auto const legacyVault = makeVaultSle();
+        auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
+
+        {
+            testcase(
+                "loanVaultExposure dispatcher: amendment enabled, legacy vault picks "
+                "InstantRecognition");
+            Env const env{*this};
+            auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+            BEAST_EXPECT(
+                loanVaultExposure(legacyVault, sle) ==
+                xrpl::instant_recognition::loanVaultExposure(sle));
+        }
+
+        {
+            testcase(
+                "loanVaultExposure dispatcher: amendment enabled, LEVersion == "
+                "VaultVersion::CashBasis "
+                "picks CashBasis");
+            Env const env{*this};
+            auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+            BEAST_EXPECT(
+                loanVaultExposure(cashBasisVault, sle) == xrpl::cash_basis::loanVaultExposure(sle));
+        }
+    }
+
+    void
+    testLoanPaymentDeltasDispatcher()
+    {
+        using namespace jtx;
+
+        LoanPaymentParts const parts{
+            .principalPaid = Number{100},
+            .interestPaid = Number{20},
+            .valueChange = Number{5},
+            .feePaid = Number{3}};
+
+        auto const legacyVault = makeVaultSle();
+        auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
+
+        {
+            testcase(
+                "loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks "
+                "InstantRecognition");
+            Env const env{*this};
+            auto const deltas = loanPaymentDeltas(legacyVault, parts);
+            auto const expected = xrpl::instant_recognition::loanPaymentDeltas(parts);
+            BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
+            BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
+        }
+
+        {
+            testcase(
+                "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == "
+                "VaultVersion::CashBasis "
+                "picks CashBasis");
+            Env const env{*this};
+            auto const deltas = loanPaymentDeltas(cashBasisVault, parts);
+            auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts);
+            BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
+            BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
+        }
+    }
+
 public:
     void
     testCanApplyToBrokerCover()
@@ -1549,6 +1885,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
     {
@@ -1573,6 +2003,19 @@ public:
         testComputeOverpaymentComponents();
         testComputeInterestAndFeeParts();
         testCanApplyToBrokerCover();
+
+        testInstantRecognitionLoanOriginationDeltas();
+        testCashBasisLoanOriginationDeltas();
+        testInstantRecognitionLoanOriginationExceedsVaultMaximum();
+        testInstantRecognitionLoanVaultExposure();
+        testCashBasisLoanVaultExposure();
+        testLoanPaymentDeltas();
+        testLoanOriginationDeltasDispatcher();
+        testLoanOriginationExceedsVaultMaximumDispatcher();
+        testLoanVaultExposureDispatcher();
+        testLoanPaymentDeltasDispatcher();
+
+        testLoanDefaultFreezeExemptAccounts();
     }
 };
 
diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp
similarity index 87%
rename from src/test/app/LoanBroker_test.cpp
rename to src/test/app/lending/LoanBroker_test.cpp
index f6f85a0cca..3bcda42c7e 100644
--- a/src/test/app/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -6,6 +6,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -41,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -57,6 +60,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -68,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()
@@ -93,10 +103,11 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(static_cast(env.le(keylet)) == goodVault);
 
-            using namespace loanBroker;
+            using namespace loan_broker;
             // Can't create a loan broker regardless of whether the vault exists
             env(set(alice, keylet.key), Ter(temDISABLED));
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             // Other LoanBroker transactions are disabled, too.
             // 1. LoanBrokerCoverDeposit
             env(coverDeposit(alice, brokerKeylet.key, asset(1000)), Ter(temDISABLED));
@@ -168,7 +179,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         }
 
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         // Bogus assets to use in test cases
         static PrettyAsset const kBadMptAsset = [&]() {
@@ -182,7 +193,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         static PrettyAsset const kGhostIouAsset = kNonExistent["GST"];
         PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"];
 
-        auto const badKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const badKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, badVault.vaultID));
         env.close();
         auto const badBrokerPseudo = [&]() {
@@ -195,7 +207,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         }();
         PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"];
 
-        auto const keylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const keylet = keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         {
             // Start with default values
             auto jtx = env.jt(set(alice, vault.vaultID));
@@ -290,7 +302,7 @@ class LoanBroker_test : public beast::unit_test::Suite
                     {
                         auto const amount = vault.asset(n);
                         BEAST_EXPECT(broker->at(sfCoverAvailable) == amount.number());
-                        env.require(Balance(pseudoAccount, amount));
+                        env.require(jtx::Balance(pseudoAccount, amount));
                     }
                 };
 
@@ -537,8 +549,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             auto const expectedBalance = aliceBalance + coverFunds -
                 (aliceBalance.value().native() ? STAmount(env.current()->fees().base.value())
                                                : vault.asset(0));
-            env.require(Balance(alice, expectedBalance));
-            env.require(Balance(pseudoAccount, vault.asset(kNone)));
+            env.require(jtx::Balance(alice, expectedBalance));
+            env.require(jtx::Balance(pseudoAccount, vault.asset(kNone)));
         }
     }
 
@@ -645,12 +657,12 @@ class LoanBroker_test : public beast::unit_test::Suite
                 }
             }
 
-            using namespace loanBroker;
-            using namespace xrpl::Lending;
+            using namespace loan_broker;
+            using namespace xrpl::lending;
 
             TenthBips32 const tenthBipsZero{0};
 
-            auto badKeylet = keylet::vault(alice.id(), env.seq(alice));
+            auto badKeylet = keylet::vault(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             // Try some failure cases
             // not the vault owner
             env(set(evan, vault.vaultID), Ter(tecNO_PERMISSION));
@@ -741,7 +753,8 @@ class LoanBroker_test : public beast::unit_test::Suite
                     // Modifications
 
                     // Update the fields
-                    auto const nextKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+                    auto const nextKeylet =
+                        keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
 
                     // fields that can't be changed
                     // LoanBrokerID
@@ -862,10 +875,10 @@ class LoanBroker_test : public beast::unit_test::Suite
         LoanBrokerTest brokerTest)
     {
         using namespace jtx;
-        using namespace loanBroker;
+        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);
@@ -897,7 +910,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, vaultInfo.vaultID));
         env.close();
 
@@ -1040,7 +1054,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(del(alice, brokerKeylet.key), Ter(tecHAS_OBLIGATIONS));
 
             // Repay and delete the loan
-            auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
             env(loan::pay(borrower, loanKeylet.key, asset(50).value()));
             env(loan::del(alice, loanKeylet.key));
 
@@ -1093,14 +1107,14 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase("Invalid LoanBrokerCoverClawback");
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         // preflight
         {
             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();
 
@@ -1109,7 +1123,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             // holder == account
             env(jtx, Ter(temINVALID));
 
-            // holder == beast::zero
+            // holder == beast::kZero
             STAmount const bad(Issue{usd.currency, beast::kZero}, 100);
             jtx.jv[sfAmount] = bad.getJson();
             jtx.stx = env.ust(jtx);
@@ -1203,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);
@@ -1217,10 +1231,11 @@ class LoanBroker_test : public beast::unit_test::Suite
         env.close();
 
         // Predict LoanBroker key using alice's current sequence BEFORE submit
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
 
         // Create LoanBroker pointing to the vault
-        env(loanBroker::set(alice, vaultKeylet.key));
+        env(loan_broker::set(alice, vaultKeylet.key));
         env.close();
 
         // Build the CoverDeposit STTx directly
@@ -1256,11 +1271,11 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase("Require Auth - Implicit Pseudo-account authorization");
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         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);
@@ -1323,7 +1338,8 @@ class LoanBroker_test : public beast::unit_test::Suite
                 err);
         });
 
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         // Can create LoanBroker if the vault owner is not authorized
         forUnauthAuth([&](auto) { env(set(alice, vaultInfo.vaultID)); });
 
@@ -1364,10 +1380,10 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase("testLoanBrokerSetDebtMaximum");
         using namespace jtx;
-        using namespace loanBroker;
+        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);
@@ -1401,7 +1417,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, vaultInfo.vaultID));
         env.close();
 
@@ -1532,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();
@@ -1548,12 +1565,13 @@ class LoanBroker_test : public beast::unit_test::Suite
                 Ter(err));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
+            auto const brokerKeylet =
+                keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker)));
 
-            env(loanBroker::set(broker, keylet.key));
+            env(loan_broker::set(broker, keylet.key));
             env.close();
 
-            env(loanBroker::coverDeposit(broker, brokerKeylet.key, deposit), Ter(err));
+            env(loan_broker::coverDeposit(broker, brokerKeylet.key, deposit), Ter(err));
             env.close();
         };
 
@@ -1604,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);
@@ -1621,7 +1639,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         auto const vaultPseudoAcct = Account("VaultPseudo", vaultPseudo);
         env(trust(issuer, vaultPseudoAcct["IOU"](0), tfSetFreeze));
 
-        env(loanBroker::set(lender, vaultKeylet.key), Ter(tecFROZEN));
+        env(loan_broker::set(lender, vaultKeylet.key), Ter(tecFROZEN));
     }
 
     void
@@ -1629,7 +1647,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "LoanBrokerDelete - locked broker pseudo-account MPT";
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer("issuer");
         Account const alice("alice");
@@ -1662,7 +1680,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         env.close();
 
         // Create loan broker
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, vaultKeylet.key));
         env.close();
 
@@ -1749,7 +1768,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "LoanBrokerDelete - frozen broker pseudo-account IOU";
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer("issuer");
         Account const alice("alice");
@@ -1779,7 +1798,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         env.close();
 
         // Create loan broker
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, vaultKeylet.key));
         env.close();
 
@@ -1829,11 +1849,101 @@ 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()
     {
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
@@ -1841,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);
@@ -1856,7 +1966,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(set(alice, vaultKeylet.key));
             env.close();
 
@@ -1907,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);
@@ -1926,7 +2037,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)}));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(set(alice, vaultKeylet.key));
             env.close();
 
@@ -1984,12 +2096,12 @@ class LoanBroker_test : public beast::unit_test::Suite
         testcase("LoanBrokerCoverWithdraw IOU self-withdrawal while individually frozen");
 
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         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);
@@ -2006,7 +2118,8 @@ class LoanBroker_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
         env.close();
 
-        auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
         env(set(alice, vaultKeylet.key));
         env.close();
 
@@ -2046,7 +2159,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     testCoverWithdrawFreezes()
     {
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
@@ -2054,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);
@@ -2069,7 +2182,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)}));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(set(alice, vaultKeylet.key));
             env.close();
 
@@ -2165,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);
@@ -2184,7 +2298,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)}));
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(set(alice, vaultKeylet.key));
             env.close();
 
@@ -2285,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);
 
@@ -2351,7 +2466,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
 
             env(vault.withdraw({.depositor = broker, .id = keylet.key, .amount = token(1'000)}),
-                loanBroker::kDestination(dest),
+                loan_broker::kDestination(dest),
                 Ter(std::ignore));
             BEAST_EXPECT(env.ter() == tecNO_LINE);
             env.close();
@@ -2359,16 +2474,17 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(vault.withdraw({.depositor = broker, .id = keylet.key, .amount = token(1'000)}));
 
             // Test LoanBroker withdraw
-            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
+            auto const brokerKeylet =
+                keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker)));
 
-            env(loanBroker::set(broker, keylet.key));
+            env(loan_broker::set(broker, keylet.key));
             env.close();
 
-            env(loanBroker::coverDeposit(broker, brokerKeylet.key, token(1'000)));
+            env(loan_broker::coverDeposit(broker, brokerKeylet.key, token(1'000)));
             env.close();
 
-            env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)),
-                loanBroker::kDestination(dest),
+            env(loan_broker::coverWithdraw(broker, brokerKeylet.key, token(100)),
+                loan_broker::kDestination(dest),
                 Ter(std::ignore));
             BEAST_EXPECT(env.ter() == tecNO_LINE);
             env.close();
@@ -2379,8 +2495,8 @@ class LoanBroker_test : public beast::unit_test::Suite
                 env(fclear(issuer, asfRequireAuth));
                 env.close();
 
-                env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)),
-                    loanBroker::kDestination(dest),
+                env(loan_broker::coverWithdraw(broker, brokerKeylet.key, token(100)),
+                    loan_broker::kDestination(dest),
                     Ter(std::ignore));
                 BEAST_EXPECT(env.ter() == tecNO_LINE);
                 env.close();
@@ -2409,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);
 
@@ -2472,7 +2588,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             env.close();
 
             env(vault.withdraw({.depositor = broker, .id = keylet.key, .amount = token(1'000)}),
-                loanBroker::kDestination(dest),
+                loan_broker::kDestination(dest),
                 Ter(std::ignore));
 
             // Shouldn't fail if at MaximumAmount since no new tokens are issued
@@ -2487,16 +2603,17 @@ class LoanBroker_test : public beast::unit_test::Suite
             }
 
             // Test LoanBroker withdraw
-            auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker));
+            auto const brokerKeylet =
+                keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker)));
 
-            env(loanBroker::set(broker, keylet.key));
+            env(loan_broker::set(broker, keylet.key));
             env.close();
 
-            env(loanBroker::coverDeposit(broker, brokerKeylet.key, token(1'000)));
+            env(loan_broker::coverDeposit(broker, brokerKeylet.key, token(1'000)));
             env.close();
 
-            env(loanBroker::coverWithdraw(broker, brokerKeylet.key, token(100)),
-                loanBroker::kDestination(dest),
+            env(loan_broker::coverWithdraw(broker, brokerKeylet.key, token(100)),
+                loan_broker::kDestination(dest),
                 Ter(std::ignore));
             BEAST_EXPECT(env.ter() == err);
             env.close();
@@ -2514,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,
@@ -2522,7 +2765,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     testCoverPrecisionGuard()
     {
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
@@ -2551,7 +2794,8 @@ class LoanBroker_test : public beast::unit_test::Suite
             env(createTx);
             env.close();
 
-            auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(set(alice, vaultKeylet.key));
             env.close();
 
@@ -2698,7 +2942,8 @@ class LoanBroker_test : public beast::unit_test::Suite
                 env(createTx);
                 env.close();
 
-                auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice));
+                auto const brokerKeylet =
+                    keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
                 env(set(alice, vaultKeylet.key));
                 env.close();
 
@@ -2750,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
new file mode 100644
index 0000000000..f9f5948db9
--- /dev/null
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -0,0 +1,1283 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 {
+
+// LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage.
+//
+// Existing tests never enable featureLendingProtocolV1_1 (see `all_`
+// above), so these are the only tests in this file that exercise the
+// amendment. They are called once, directly, from
+// runAmendmentIndependent() -- not looped through
+// runAmendmentSensitive()/amendmentCombinations(), since doing so would
+// require re-deriving instant-recognition-specific expected values for ~15
+// unrelated regression tests.
+class LoanCashBasis_test : public LoanTestBase
+{
+private:
+    // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas,
+    // and the AssetsMaximum/DebtMaximum guards. Instant-recognition AssetsMaximum still
+    // requires headroom for interestDue; cash-basis AssetsMaximum does not,
+    // because origination does not credit interest into AssetsTotal.
+    void
+    testCashBasisLoanSetOrigination()
+    {
+        testcase("cash-basis: LoanSet origination");
+
+        using namespace jtx;
+        using namespace loan;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 100'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+
+        Number const principalRequest{10'000};
+        TenthBips32 const interestRate{percentageToTenthBips(10)};
+        std::uint32_t const paymentTotal = 2;
+        std::uint32_t const paymentInterval = 86400;
+
+        // Creates a broker/vault, submits a single LoanSet with a nonzero
+        // interest rate, and returns the observed Vault.AssetsTotal /
+        // LoanBroker.DebtTotal deltas plus the loan's own computed
+        // interestDue and principalOutstanding.
+        auto runOrigination = [&](FeatureBitset features) {
+            Env env(*this, features);
+
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(1'000'000), lender, borrower);
+            env.close();
+
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            auto const vaultBefore = env.le(broker.vaultKeylet());
+            auto const brokerBefore = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(vaultBefore && brokerBefore);
+            Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
+
+            auto const loanSequence = brokerBefore->at(sfLoanSequence);
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
+                kCounterparty(lender),
+                kInterestRate(interestRate),
+                kPaymentTotal(paymentTotal),
+                kPaymentInterval(paymentInterval),
+                Sig(sfCounterpartySignature, lender),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const loanSle = env.le(loanKeylet);
+            BEAST_EXPECT(loanSle);
+            Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
+            Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding);
+            Number const interestDue = totalValueOutstanding - principalOutstanding;
+            BEAST_EXPECT(interestDue > beast::kZero);
+            BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value());
+
+            auto const vaultAfter = env.le(broker.vaultKeylet());
+            auto const brokerAfter = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(vaultAfter && brokerAfter);
+            Number const assetsTotalDelta =
+                Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore;
+            Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore;
+
+            return std::make_tuple(
+                assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding);
+        };
+
+        Number interestDueCash{};
+        Number principalOutstandingCash{};
+        {
+            auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] =
+                runOrigination(all_ | featureLendingProtocolV1_1);
+            interestDueCash = interestDue;
+            principalOutstandingCash = principalOutstanding;
+
+            BEAST_EXPECTS(
+                assetsTotalDelta == beast::kZero,
+                "cash-basis origination must not change AssetsTotal; delta=" +
+                    to_string(assetsTotalDelta));
+            BEAST_EXPECTS(
+                debtTotalDelta == principalOutstanding,
+                "cash-basis origination must add principal-only to DebtTotal; delta=" +
+                    to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding));
+        }
+
+        {
+            auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] =
+                runOrigination(all_);
+
+            BEAST_EXPECTS(
+                assetsTotalDelta == interestDue,
+                "instant-recognition origination must add interestDue to AssetsTotal; delta=" +
+                    to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue));
+            BEAST_EXPECTS(
+                debtTotalDelta == principalOutstanding + interestDue,
+                "instant-recognition origination must add principal+interest to DebtTotal; delta=" +
+                    to_string(debtTotalDelta));
+        }
+
+        // AssetsMaximum guard checks interestDue headroom only under
+        // instant interest recognition; DebtMaximum guard also varies by model.
+        auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) {
+            Env env(*this, features);
+
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(1'000'000), lender, borrower);
+            env.close();
+
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultSle);
+            Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
+
+            Vault const vault{env};
+            auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+            tx[sfAssetsMaximum] = assetsTotalBefore + slack;
+            env(tx);
+            env.close();
+
+            env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
+                kCounterparty(lender),
+                kInterestRate(interestRate),
+                kPaymentTotal(paymentTotal),
+                kPaymentInterval(paymentInterval),
+                Sig(sfCounterpartySignature, lender),
+                Fee(env.current()->fees().base * 2),
+                Ter(expected));
+            env.close();
+        };
+
+        auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) {
+            Env env(*this, features);
+
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(1'000'000), lender, borrower);
+            env.close();
+
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            env(loan_broker::set(lender, broker.vaultID),
+                loan_broker::kLoanBrokerId(broker.brokerID),
+                loan_broker::kDebtMaximum(debtMaximum),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
+                kCounterparty(lender),
+                kInterestRate(interestRate),
+                kPaymentTotal(paymentTotal),
+                kPaymentInterval(paymentInterval),
+                Sig(sfCounterpartySignature, lender),
+                Fee(env.current()->fees().base * 2),
+                Ter(expected));
+            env.close();
+        };
+
+        Number const oneDrop = xrpAsset(1).value();
+        {
+            testcase(
+                "instant-recognition: LoanSet AssetsMaximum guard checks interestDue headroom");
+            // Guard rejects when there's not quite enough headroom for the
+            // interest.
+            runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED);
+            // Guard accepts at the exact boundary.
+            runVaultGuard(all_, interestDueCash, tesSUCCESS);
+        }
+
+        {
+            testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom");
+            // 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. Instant-recognition 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;
+        // instant recognition projects principal + interestDue.
+        for (auto const cashBasis : {true, false})
+        {
+            testcase(
+                std::string("LoanSet DebtMaximum guard (") +
+                (cashBasis ? "cash-basis)" : "instant-recognition)"));
+            auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_;
+            Number const newDebtTotal =
+                principalOutstandingCash + (cashBasis ? Number{} : interestDueCash);
+            runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED);
+            runBrokerGuard(features, newDebtTotal, tesSUCCESS);
+        }
+    }
+
+    // 2. LoanPay: regular, late, overpayment, and full-payment types.
+    // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match
+    // interestPaid/principalPaid under cash-basis, and cross-check the
+    // amendment-disabled run's deltas against the documented instant-recognition
+    // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own
+    // TotalValueOutstanding delta exactly, since instant-recognition debt recognition
+    // tracks total loan value).
+    void
+    testCashBasisLoanPay()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+        using tp = NetClock::time_point;
+
+        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}};
+
+        Number const principalRequest{12'000};
+        TenthBips32 const interestRate{percentageToTenthBips(12)};
+        std::uint32_t const paymentTotal = 4;
+        std::uint32_t const paymentInterval = 600;
+        std::uint32_t const gracePeriod = 300;
+
+        struct PaymentDeltas
+        {
+            Number principalPaid;
+            Number assetsTotalDelta;
+            Number debtTotalDelta;
+            Number totalValueDelta;
+        };
+
+        // Sets up a fresh broker + loan, advances time, submits a single
+        // payment of the given type/amount, and returns the observed deltas.
+        auto runPayment = [&](FeatureBitset features,
+                              std::uint32_t loanSetFlags,
+                              std::uint32_t payFlags,
+                              std::function const& advanceTime,
+                              std::function const& paymentAmount) {
+            Env env(*this, features);
+
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(10'000'000), lender, borrower);
+            env.close();
+
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            LoanParameters const loanParams{
+                .account = borrower,
+                .counter = lender,
+                .principalRequest = principalRequest,
+                .interest = interestRate,
+                .payTotal = paymentTotal,
+                .payInterval = paymentInterval,
+                .gracePd = gracePeriod,
+                .flags = loanSetFlags,
+            };
+
+            auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerBeforeLoan);
+            auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            env(loanParams(env, broker));
+            env.close();
+
+            LoanState const state = getCurrentState(env, broker, loanKeylet);
+
+            advanceTime(env, state.startDate);
+
+            auto const vaultBefore = env.le(broker.vaultKeylet());
+            auto const brokerBefore = env.le(broker.brokerKeylet());
+            auto const loanBefore = env.le(loanKeylet);
+            BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore);
+
+            Number const principalBefore = loanBefore->at(sfPrincipalOutstanding);
+            Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding);
+            Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
+
+            STAmount const amount = paymentAmount(state);
+            env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultAfter = env.le(broker.vaultKeylet());
+            auto const brokerAfter = env.le(broker.brokerKeylet());
+            auto const loanAfter = env.le(loanKeylet);
+            BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter);
+
+            Number const principalAfter = loanAfter->at(sfPrincipalOutstanding);
+            Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding);
+            Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal);
+            Number const debtTotalAfter = brokerAfter->at(sfDebtTotal);
+
+            return PaymentDeltas{
+                .principalPaid = principalBefore - principalAfter,
+                .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore,
+                .debtTotalDelta = debtTotalAfter - debtTotalBefore,
+                .totalValueDelta = totalValueAfter - totalValueBefore};
+        };
+
+        // Compares the disabled (instant-recognition) and enabled (cash-basis) runs
+        // of the same payment scenario, and asserts the documented
+        // relationships between them.
+        auto checkScenario = [&](std::string const& label,
+                                 PaymentDeltas const& off,
+                                 PaymentDeltas const& on) {
+            testcase("cash-basis: LoanPay " + label);
+
+            // The loan's own PrincipalOutstanding field is untouched by
+            // the amendment.
+            BEAST_EXPECTS(
+                off.principalPaid == on.principalPaid,
+                "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) +
+                    " on=" + to_string(on.principalPaid));
+
+            // Whole-life structural invariant: DebtTotal (which
+            // recognizes a loan's full remaining value as debt) must
+            // change exactly as the loan's own TotalValueOutstanding
+            // does.
+            BEAST_EXPECTS(
+                off.debtTotalDelta == off.totalValueDelta,
+                "instant-recognition DebtTotal delta must mirror TotalValueOutstanding delta; "
+                "debtTotalDelta=" +
+                    to_string(off.debtTotalDelta) +
+                    " totalValueDelta=" + to_string(off.totalValueDelta));
+
+            // Derive interestPaid from the instant-recognition run's independent
+            // ledger deltas:
+            //   assetsTotalDelta_off == valueChange
+            //   debtTotalDelta_off == valueChange - (principalPaid + interestPaid)
+            // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid
+            Number const interestPaid =
+                off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid;
+            BEAST_EXPECTS(
+                interestPaid >= beast::kZero,
+                "derived interestPaid must be non-negative: " + to_string(interestPaid));
+
+            BEAST_EXPECTS(
+                on.assetsTotalDelta == interestPaid,
+                "cash-basis AssetsTotal delta must equal interestPaid; delta=" +
+                    to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid));
+            BEAST_EXPECTS(
+                on.debtTotalDelta == -on.principalPaid,
+                "cash-basis DebtTotal delta must equal -principalPaid; delta=" +
+                    to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid));
+        };
+
+        // ---- Regular, on-time payment ----
+        {
+            auto const noAdvance = [](Env& env, tp const&) { env.close(); };
+            auto const regularAmount = [&](LoanState const& state) {
+                return STAmount{
+                    xrpAsset,
+                    roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) *
+                        Number{3, -1} * 5};  // 1.5x, so only a single period is paid
+            };
+
+            auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount);
+            auto const on =
+                runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount);
+
+            // Regular, on-time payments never change the loan's value beyond
+            // normal amortization (production asserts valueChange == 0), so
+            // AssetsTotal must be unaffected in the instant-recognition run.
+            BEAST_EXPECTS(
+                off.assetsTotalDelta == beast::kZero,
+                "regular on-time payment must not change AssetsTotal under instant recognition; "
+                "delta=" +
+                    to_string(off.assetsTotalDelta));
+
+            checkScenario("regular payment", off, on);
+        }
+
+        // ---- Late payment ----
+        {
+            auto const advancePastDue = [&](Env& env, tp const& startDate) {
+                env.close(startDate + std::chrono::seconds(paymentInterval + 1));
+            };
+            auto const lateAmount = [&](LoanState const& state) {
+                return STAmount{
+                    xrpAsset,
+                    roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) *
+                        Number{3}};  // generous; excess is not withdrawn
+            };
+
+            auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount);
+            auto const on = runPayment(
+                all_ | featureLendingProtocolV1_1,
+                0,
+                tfLoanLatePayment,
+                advancePastDue,
+                lateAmount);
+
+            checkScenario("late payment", off, on);
+        }
+
+        // ---- Overpayment ----
+        {
+            auto const noAdvance = [](Env& env, tp const&) { env.close(); };
+            auto const overpayAmount = [&](LoanState const& state) {
+                // One regular period, plus a generous extra principal
+                // paydown.
+                return STAmount{
+                    xrpAsset,
+                    roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) +
+                        xrpAsset(2'000).value()};
+            };
+
+            auto const off =
+                runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount);
+            auto const on = runPayment(
+                all_ | featureLendingProtocolV1_1,
+                tfLoanOverpayment,
+                tfLoanOverpayment,
+                noAdvance,
+                overpayAmount);
+
+            checkScenario("overpayment", off, on);
+        }
+
+        // ---- Full payment ----
+        {
+            auto const noAdvance = [](Env& env, tp const&) { env.close(); };
+            auto const fullAmount = [&](LoanState const&) {
+                // Generously large: full payment only ever consumes exactly
+                // what's due (principal + accrued interest; close fee/
+                // prepayment penalty are 0 here), excess is not withdrawn.
+                return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}};
+            };
+
+            auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount);
+            auto const on = runPayment(
+                all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount);
+
+            checkScenario("full payment", off, on);
+        }
+    }
+
+    // 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()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{percentageToTenthBips(10)},
+            .coverDeposit = 5'000,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}};
+
+        Number const principalRequest{10'000};
+        TenthBips32 const interestRate{percentageToTenthBips(12)};
+        std::uint32_t const paymentTotal = 4;
+        std::uint32_t const paymentInterval = 600;
+        std::uint32_t const gracePeriod = 60;
+
+        auto setupLoan = [&](Env& env) {
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+            env.fund(XRP(10'000'000), lender, borrower);
+            env.close();
+
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            LoanParameters const loanParams{
+                .account = borrower,
+                .counter = lender,
+                .principalRequest = principalRequest,
+                .interest = interestRate,
+                .payTotal = paymentTotal,
+                .payInterval = paymentInterval,
+                .gracePd = gracePeriod,
+            };
+
+            auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerBeforeLoan);
+            auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            env(loanParams(env, broker));
+            env.close();
+
+            return std::make_tuple(broker, loanKeylet, lender, borrower);
+        };
+
+        // ---- impair / unimpair ----
+        auto runImpairUnimpair = [&](FeatureBitset features) {
+            Env env(*this, features);
+            auto const [broker, loanKeylet, lender, borrower] = setupLoan(env);
+
+            auto const loanBefore = env.le(loanKeylet);
+            BEAST_EXPECT(loanBefore);
+            Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding);
+            Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding);
+            Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding);
+
+            Number const expectedExposure =
+                env.current()->rules().enabled(featureLendingProtocolV1_1)
+                ? principalOutstanding
+                : totalValueOutstanding - managementFeeOutstanding;
+
+            auto const vaultBeforeImpair = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultBeforeImpair);
+            Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
+
+            advancePastDueDate(env, loanKeylet);
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultAfterImpair = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultAfterImpair);
+            Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore;
+
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultAfterUnimpair = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultAfterUnimpair);
+            Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore;
+
+            return std::make_tuple(expectedExposure, impairDelta, netDelta);
+        };
+
+        for (auto const features : {all_ | featureLendingProtocolV1_1, all_})
+        {
+            testcase(
+                std::string("cash-basis: LoanManage impair/unimpair (") +
+                (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
+            auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features);
+
+            BEAST_EXPECTS(
+                impairDelta == expectedExposure,
+                "impair must add loanVaultExposure to LossUnrealized; delta=" +
+                    to_string(impairDelta) + " expected=" + to_string(expectedExposure));
+            BEAST_EXPECTS(
+                netDelta == beast::kZero,
+                "unimpair must be an exact reversal of impair; net=" + to_string(netDelta));
+        }
+
+        // ---- impair, then default ----
+        auto runDefault = [&](FeatureBitset features) {
+            Env env(*this, features);
+            auto const [broker, loanKeylet, lender, borrower] = setupLoan(env);
+
+            auto const loanBeforeImpair = env.le(loanKeylet);
+            BEAST_EXPECT(loanBeforeImpair);
+            Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding);
+            Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding);
+            Number const managementFeeOutstanding =
+                loanBeforeImpair->at(sfManagementFeeOutstanding);
+
+            Number const expectedExposure =
+                env.current()->rules().enabled(featureLendingProtocolV1_1)
+                ? principalOutstanding
+                : totalValueOutstanding - managementFeeOutstanding;
+
+            advancePastDueDate(env, loanKeylet);
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            LoanState const state = getCurrentState(env, broker, loanKeylet);
+            env.close(
+                state.startDate + std::chrono::seconds(paymentInterval) +
+                std::chrono::seconds(gracePeriod) + 60s);
+
+            auto const vaultBefore = env.le(broker.vaultKeylet());
+            auto const brokerBefore = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(vaultBefore && brokerBefore);
+            Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
+            Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
+            Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+            Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
+
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultAfter = env.le(broker.vaultKeylet());
+            auto const brokerAfter = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(vaultAfter && brokerAfter);
+            Number const assetsTotalDelta =
+                Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore;
+            Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore;
+            Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore;
+            Number const coverAvailableDelta =
+                Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore;
+
+            Number const defaultCovered = -coverAvailableDelta;
+            Number const vaultDefaultAmount = expectedExposure - defaultCovered;
+
+            return std::make_tuple(
+                expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount);
+        };
+
+        for (auto const features : {all_ | featureLendingProtocolV1_1, all_})
+        {
+            testcase(
+                std::string("cash-basis: LoanManage default (") +
+                (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
+            auto const
+                [expectedExposure,
+                 assetsTotalDelta,
+                 debtTotalDelta,
+                 lossDelta,
+                 vaultDefaultAmount] = runDefault(features);
+
+            BEAST_EXPECTS(
+                debtTotalDelta == -expectedExposure,
+                "default must reduce DebtTotal by the unified default amount; delta=" +
+                    to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure));
+            BEAST_EXPECTS(
+                lossDelta == -expectedExposure,
+                "default must reverse the earlier impair's LossUnrealized exactly; delta=" +
+                    to_string(lossDelta) + " expected=" + to_string(expectedExposure));
+            BEAST_EXPECTS(
+                assetsTotalDelta == -vaultDefaultAmount,
+                "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" +
+                    to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount));
+        }
+    }
+
+    // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1
+    // activates (LEVersion absent) must keep instant interest recognition
+    // forever, even after the amendment is later enabled -- the switch is
+    // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment
+    // flag.
+    void
+    testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled()
+    {
+        testcase(
+            "LEVersion: legacy vault keeps instant interest recognition after amendment enabled");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{percentageToTenthBips(10)},
+            .coverDeposit = 5'000,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}};
+
+        Number const principalRequest{10'000};
+        TenthBips32 const interestRate{percentageToTenthBips(12)};
+        std::uint32_t const paymentTotal = 4;
+        std::uint32_t const paymentInterval = 600;
+        std::uint32_t const gracePeriod = 60;
+
+        // Amendment disabled at Vault creation time: LEVersion stays absent.
+        Env env(*this, all_);
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultSle);
+            BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion));
+        }
+
+        // Now enable the amendment -- production dispatch must still treat
+        // this specific Vault as instant interest recognition, since its LEVersion is
+        // (and remains) absent.
+        env.enableFeature(featureLendingProtocolV1_1);
+        env.close();
+
+        LoanParameters const loanParams{
+            .account = borrower,
+            .counter = lender,
+            .principalRequest = principalRequest,
+            .interest = interestRate,
+            .payTotal = paymentTotal,
+            .payInterval = paymentInterval,
+            .gracePd = gracePeriod,
+        };
+
+        auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(brokerBeforeLoan);
+        auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        // ---- LoanSet origination: instant-recognition formulas expected ----
+        auto const vaultBeforeSet = env.le(broker.vaultKeylet());
+        auto const brokerBeforeSet = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet);
+        Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal);
+        Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal);
+
+        env(loanParams(env, broker));
+        env.close();
+
+        auto const loanAfterSet = env.le(loanKeylet);
+        BEAST_EXPECT(loanAfterSet);
+        Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding);
+        Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding);
+        Number const interestDue = totalValueOutstanding - principalOutstanding;
+        BEAST_EXPECT(interestDue > beast::kZero);
+
+        auto const vaultAfterSet = env.le(broker.vaultKeylet());
+        auto const brokerAfterSet = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(vaultAfterSet && brokerAfterSet);
+        Number const assetsTotalDeltaSet =
+            Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet;
+        Number const debtTotalDeltaSet =
+            Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet;
+
+        BEAST_EXPECTS(
+            assetsTotalDeltaSet == interestDue,
+            "legacy vault origination must still add interestDue to AssetsTotal; delta=" +
+                to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue));
+        BEAST_EXPECTS(
+            debtTotalDeltaSet == principalOutstanding + interestDue,
+            "legacy vault origination must still add principal+interest to DebtTotal; delta=" +
+                to_string(debtTotalDeltaSet));
+
+        LoanState const state = getCurrentState(env, broker, loanKeylet);
+        env.close();
+
+        // ---- LoanPay: instant-recognition formulas expected ----
+        auto const vaultBeforePay = env.le(broker.vaultKeylet());
+        auto const brokerBeforePay = env.le(broker.brokerKeylet());
+        auto const loanBeforePay = env.le(loanKeylet);
+        BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay);
+        Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding);
+        Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal);
+        Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal);
+
+        STAmount const paymentAmount{
+            xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)};
+        env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterPay = env.le(broker.vaultKeylet());
+        auto const brokerAfterPay = env.le(broker.brokerKeylet());
+        auto const loanAfterPay = env.le(loanKeylet);
+        BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay);
+        Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding);
+        Number const assetsTotalDeltaPay =
+            Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay;
+        Number const debtTotalDeltaPay =
+            Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay;
+        Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay;
+
+        // A regular, on-time payment has valueChange == 0, so instant-recognition
+        // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding.
+        BEAST_EXPECTS(
+            assetsTotalDeltaPay == beast::kZero,
+            "legacy vault regular payment must not change AssetsTotal; delta=" +
+                to_string(assetsTotalDeltaPay));
+        BEAST_EXPECTS(
+            debtTotalDeltaPay == totalValueDeltaPay,
+            "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; "
+            "debtTotalDelta=" +
+                to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay));
+
+        // ---- LoanManage: impair, then default -- instant-recognition exposure expected ----
+        auto const loanBeforeImpair = env.le(loanKeylet);
+        BEAST_EXPECT(loanBeforeImpair);
+        Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding);
+        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();
+
+        env.close(
+            NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} +
+            std::chrono::seconds(gracePeriod) + 60s);
+
+        auto const vaultBeforeDefault = env.le(broker.vaultKeylet());
+        auto const brokerBeforeDefault = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault);
+        Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal);
+        Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized);
+
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterDefault = env.le(broker.vaultKeylet());
+        auto const brokerAfterDefault = env.le(broker.brokerKeylet());
+        BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault);
+        Number const debtTotalDeltaDefault =
+            Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault;
+        Number const lossDeltaDefault =
+            Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault;
+
+        BEAST_EXPECTS(
+            debtTotalDeltaDefault == -expectedExposure,
+            "legacy vault default must reduce DebtTotal by instant-recognition exposure; delta=" +
+                to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure));
+        BEAST_EXPECTS(
+            lossDeltaDefault == -expectedExposure,
+            "legacy vault default must reverse the earlier impair's LossUnrealized exactly; "
+            "delta=" +
+                to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure));
+
+        // Confirm the Vault's LEVersion truly never got set, throughout.
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            BEAST_EXPECT(vaultSle);
+            BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion));
+            BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy);
+        }
+    }
+
+    // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default),
+    // entirely under the amendment, with independently hand-computed
+    // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at
+    // each step. 0% interest keeps the arithmetic exact and tractable; the
+    // divergence from instant interest recognition is already covered directly by
+    // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test
+    // focuses purely on an independent, from-scratch trajectory check.
+    void
+    testCashBasisEndToEndTrajectory()
+    {
+        testcase("cash-basis: end-to-end trajectory");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}};
+
+        Env env(*this, all_ | featureLendingProtocolV1_1);
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+        // Hand computation (all values in XRP, drops == 1e-6 XRP):
+        //   Vault:  AssetsTotal starts at 100'000 (the deposit).
+        //   Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000
+        //           (BrokerParameters::defaults().coverDeposit).
+        auto const vaultKeylet = broker.vaultKeylet();
+        auto const brokerKeylet = broker.brokerKeylet();
+
+        // All the "human XRP unit" constants below (e.g. `100'000`) are
+        // converted to raw native (drops) values via xrpAsset(...), since
+        // that's how the ledger fields are actually denominated.
+        auto const checkVaultBroker = [&](Number const& assetsTotalUnits,
+                                          Number const& debtTotalUnits,
+                                          Number const& lossUnrealizedUnits,
+                                          Number const& coverAvailableUnits,
+                                          char const* step) {
+            Number const assetsTotal = xrpAsset(assetsTotalUnits).value();
+            Number const debtTotal = xrpAsset(debtTotalUnits).value();
+            Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value();
+            Number const coverAvailable = xrpAsset(coverAvailableUnits).value();
+
+            auto const vaultSle = env.le(vaultKeylet);
+            auto const brokerSle = env.le(brokerKeylet);
+            BEAST_EXPECT(vaultSle && brokerSle);
+            BEAST_EXPECTS(
+                vaultSle->at(sfAssetsTotal) == assetsTotal,
+                std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " +
+                    to_string(Number(vaultSle->at(sfAssetsTotal))));
+            BEAST_EXPECTS(
+                brokerSle->at(sfDebtTotal) == debtTotal,
+                std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " +
+                    to_string(Number(brokerSle->at(sfDebtTotal))));
+            BEAST_EXPECTS(
+                vaultSle->at(sfLossUnrealized) == lossUnrealized,
+                std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) +
+                    " got " + to_string(Number(vaultSle->at(sfLossUnrealized))));
+            BEAST_EXPECTS(
+                brokerSle->at(sfCoverAvailable) == coverAvailable,
+                std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) +
+                    " got " + to_string(Number(brokerSle->at(sfCoverAvailable))));
+        };
+
+        checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet");
+
+        // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees.
+        Number const principalRequest{1'200};
+        std::uint32_t const paymentTotal = 12;
+        std::uint32_t const paymentInterval = 600;
+        std::uint32_t const gracePeriod = 60;
+
+        auto const brokerBeforeLoan = env.le(brokerKeylet);
+        BEAST_EXPECT(brokerBeforeLoan);
+        auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        LoanParameters const loanParams{
+            .account = borrower,
+            .counter = lender,
+            .principalRequest = principalRequest,
+            .interest = TenthBips32{0},
+            .payTotal = paymentTotal,
+            .payInterval = paymentInterval,
+            .gracePd = gracePeriod,
+        };
+        env(loanParams(env, broker));
+        env.close();
+
+        // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal.
+        checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet");
+
+        LoanState const state = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value());
+
+        // Payment 1: principalPaid=100, interestPaid=0.
+        //   AssetsTotal += 0; DebtTotal -= 100.
+        env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS));
+        env.close();
+        checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1");
+
+        // Payment 2: same as above.
+        env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS));
+        env.close();
+        checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2");
+
+        // Default (no impair): principalOutstanding remaining is 1'000.
+        //   totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000.
+        //   minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100.
+        //   covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount)
+        //           = min(25, 1'000) = 25.
+        //   defaultCovered = min(covered, CoverAvailable(1'000)) = 25.
+        //   vaultDefaultAmount = 1'000 - 25 = 975.
+        //   DebtTotal -= 1'000 -> 0.  CoverAvailable -= 25 -> 975.
+        //   AssetsTotal -= 975 -> 99'025.  LossUnrealized unaffected (never impaired).
+        auto const loanBeforeDefault = env.le(loanKeylet);
+        BEAST_EXPECT(loanBeforeDefault);
+        BEAST_EXPECT(
+            Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value());
+
+        env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s);
+
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        env.close();
+
+        checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)");
+    }
+
+public:
+    void
+    run() override
+    {
+        testCashBasisLoanSetOrigination();
+        testCashBasisLoanPay();
+        testVaultSetWhileAssetsTotalExceedsMaximum();
+        testCashBasisLoanSetAfterInterestExceedsCap();
+        testCashBasisLoanManage();
+        testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled();
+        testCashBasisEndToEndTrajectory();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanCashBasis, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
new file mode 100644
index 0000000000..f8bdb5a3d7
--- /dev/null
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -0,0 +1,913 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 LoanCoverFreezeAuth_test : public LoanTestBase
+{
+private:
+    void
+    testSequentialFLCDepletion(FeatureBitset features)
+    {
+        testcase << "First-Loss Capital Depletion on Sequential Defaults";
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace loan_broker;
+
+        Env env{*this, features};
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrowerA{"borrowerA"};
+        Account const borrowerB{"borrowerB"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB);
+        env.close();
+
+        PrettyAsset const asset = xrpIssue();
+        auto const vaultDepositAmount =
+            asset(200'000);  // Enough for 2 x 50k loans plus interest/fees
+
+        auto const brokerInfo = createVaultAndBroker(
+            env,
+            asset,
+            lender,
+            {
+                .vaultDeposit = vaultDepositAmount.value(),
+                .debtMax = 0,
+                .coverRateMin = TenthBips32(20000),  // 20%
+                .coverDeposit = 21'000,
+                .managementFeeRate = TenthBips16(100),  // 0.1%
+                .coverRateLiquidation = TenthBips32(100000),
+            });
+        auto const brokerKeylet = brokerInfo.brokerKeylet();
+
+        // Create two identical loans: each 50,000 XRP principal (scaled down to
+        // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal
+        // + interest) Formula will calculate cover as: 100% × (20% × 100,000) =
+        // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first
+        // default
+        auto const principalAmount = Number(50'000);
+        auto const loanPaymentInterval = 2592000;  // 30 days
+        auto const loanGracePeriod = 604800;       // 7 days
+
+        // Create Loan A
+        auto loanATx = env.jt(
+            set(borrowerA, brokerKeylet.key, principalAmount),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32(500)),  // 5%
+            kPaymentTotal(12),
+            loan::kPaymentInterval(loanPaymentInterval),
+            loan::kGracePeriod(loanGracePeriod),
+            Fee(XRP(10)));  // Sufficient fee for multi-sig transaction
+        env(loanATx);
+        env.close();
+
+        auto const loanAKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+
+        // Create Loan B
+        auto loanBTx = env.jt(
+            set(borrowerB, brokerKeylet.key, principalAmount),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32(500)),  // 5%
+            kPaymentTotal(12),
+            loan::kPaymentInterval(loanPaymentInterval),
+            loan::kGracePeriod(loanGracePeriod),
+            Fee(XRP(10)));  // Sufficient fee for multi-sig transaction
+        env(loanBTx);
+        env.close();
+
+        auto const loanBKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2));
+
+        auto loanASle = env.le(loanAKeylet);
+        if (!BEAST_EXPECT(loanASle))
+            return;
+
+        // Advance time past grace period for both loans to be defaultable
+        auto const loanANextDue = loanASle->at(sfNextPaymentDueDate);
+        auto const loanAGrace = loanASle->at(sfGracePeriod);
+        env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60});
+
+        env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        env.close();
+
+        // Verify Loan A is defaulted
+        loanASle = env.le(loanAKeylet);
+        if (!BEAST_EXPECT(loanASle))
+            return;
+        BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault));
+        BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0);
+
+        // Check broker state after first default (from committed ledger)
+        auto brokerSle = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
+        auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
+
+        // DebtTotal should have decreased by Loan A's debt
+        BEAST_EXPECT(afterFirstDebtTotal == 50'134);
+
+        // CoverAvailable should have decreased significantly
+        BEAST_EXPECT(afterFirstCoverAvailable == 946);
+
+        env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+
+        brokerSle = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal);
+        auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable);
+
+        BEAST_EXPECT(afterSecondDebtTotal == 0);
+
+        BEAST_EXPECT(afterSecondCoverAvailable == 0);
+    }
+
+    // Tests that vault withdrawals work correctly when the vault has unrealized
+    // loss from an impaired loan, ensuring the invariant check properly
+    // accounts for the loss.
+    void
+    testWithdrawReflectsUnrealizedLoss(FeatureBitset features)
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Vault withdraw reflects sfLossUnrealized");
+
+        // Test constants
+        static constexpr std::int64_t kInitialFunding = 1'000'000;
+        static constexpr std::int64_t kLenderInitialIou = 5'000'000;
+        static constexpr std::int64_t kDepositorInitialIou = 1'000'000;
+        static constexpr std::int64_t kBorrowerInitialIou = 100'000;
+        static constexpr std::int64_t kDepositAmount = 5'000;
+        static constexpr std::int64_t kPrincipalAmount = 99;
+        static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000;
+        static constexpr std::uint32_t kLocalPaymentInterval = 600;
+        static constexpr std::uint32_t kLocalPaymentTotal = 2;
+
+        Env env{*this, features};
+
+        // Setup accounts
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const depositorA{"lpA"};
+        Account const depositorB{"lpB"};
+        Account const borrower{"borrowerA"};
+
+        env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower);
+        env.close();
+
+        // Setup trust lines
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        env(trust(lender, iouAsset(10'000'000)));
+        env(trust(depositorA, iouAsset(10'000'000)));
+        env(trust(depositorB, iouAsset(10'000'000)));
+        env(trust(borrower, iouAsset(10'000'000)));
+        env.close();
+
+        // Fund accounts with IOUs
+        env(pay(issuer, lender, iouAsset(kLenderInitialIou)));
+        env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou)));
+        env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou)));
+        env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou)));
+        env.close();
+
+        // Create vault and broker, then add deposits from two depositors
+        auto const broker = createVaultAndBroker(env, iouAsset, lender);
+        Vault v{env};
+
+        env(v.deposit({
+                .depositor = depositorA,
+                .id = broker.vaultKeylet().key,
+                .amount = iouAsset(kDepositAmount),
+            }),
+            Ter(tesSUCCESS));
+        env(v.deposit({
+                .depositor = depositorB,
+                .id = broker.vaultKeylet().key,
+                .amount = iouAsset(kDepositAmount),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Create a loan
+        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, kPrincipalAmount),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(kLocalPaymentTotal),
+            kPaymentInterval(kLocalPaymentInterval),
+            Fee(env.current()->fees().base * 2),
+            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();
+
+        // Verify unrealized loss is recorded in the vault
+        auto const vaultAfterImpair = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultAfterImpair))
+            return;
+
+        BEAST_EXPECT(
+            vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value());
+
+        // Helper to get share balance for a depositor
+        auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
+        auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
+            auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
+            return token ? token->getFieldU64(sfMPTAmount) : 0;
+        };
+
+        // Verify both depositors have equal shares
+        auto const sharesLpA = getShareBalance(depositorA);
+        auto const sharesLpB = getShareBalance(depositorB);
+        BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor);
+        BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor);
+        BEAST_EXPECT(sharesLpA == sharesLpB);
+
+        // Helper to attempt withdrawal
+        auto const attemptWithdrawShares = [&](Account const& depositor,
+                                               std::uint64_t shareAmount,
+                                               TER expected) {
+            STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
+            env(v.withdraw(
+                    {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
+                Ter(expected));
+            env.close();
+        };
+
+        // Regression test: Both depositors should successfully withdraw despite
+        // unrealized loss. Previously failed with invariant violation:
+        // "withdrawal must change vault and destination balance by equal
+        // amount". This was caused by sharesToAssetsWithdraw rounding down,
+        // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
+        // when unrealized loss exists.
+        attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
+        attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
+    }
+
+    void
+    testServiceFeeOnBrokerDeepFreeze()
+    {
+        testcase << "Service Fee On Broker Deep Freeze";
+        using namespace jtx;
+        using namespace loan;
+        Account const issuer("issuer");
+        Account const borrower("borrower");
+        Account const broker("broker");
+        auto const iou = issuer["IOU"];
+
+        for (bool const deepFreeze : {true, false})
+        {
+            Env env(*this);
+
+            auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) {
+                if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID));
+                    BEAST_EXPECT(le))
+                {
+                    auto const account = le->at(accountField);
+                    if (auto const sleLine = env.le(keylet::trustLine(account, iou));
+                        BEAST_EXPECT(sleLine))
+                    {
+                        STAmount balance = sleLine->at(sfBalance);
+                        if (account > issuer.id())
+                            balance.negate();
+                        return balance;
+                    }
+                }
+                return STAmount{iou};
+            };
+
+            env.fund(XRP(20'000), issuer, broker, borrower);
+            env.close();
+
+            env(trust(broker, iou(20'000'000)));
+            env(pay(issuer, broker, iou(10'000'000)));
+            env.close();
+
+            auto const brokerInfo = createVaultAndBroker(env, iou, broker);
+
+            BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
+
+            auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+
+            env(set(borrower, brokerInfo.brokerID, 10'000),
+                Sig(sfCounterpartySignature, broker),
+                kLoanServiceFee(iou(100).value()),
+                kPaymentInterval(100),
+                Fee(XRP(100)));
+            env.close();
+
+            env(trust(borrower, iou(20'000'000)));
+            // The borrower increases their limit and acquires some IOU so
+            // they can pay interest
+            env(pay(issuer, borrower, iou(500)));
+            env.close();
+
+            if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le))
+            {
+                if (deepFreeze)
+                {
+                    env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
+                    env.close();
+                }
+
+                env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)));
+                env.close();
+
+                if (deepFreeze)
+                {
+                    // The fee goes to the broker pseudo-account
+                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100));
+                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000));
+                }
+                else
+                {
+                    // The fee goes to the broker account
+                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100));
+                    BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000));
+                }
+            }
+        };
+    }
+
+    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)
+    {
+        testcase << "LoanPay Broker Owner Missing Trustline (PoC)";
+        using namespace jtx;
+        using namespace loan;
+        Account const issuer("issuer");
+        Account const borrower("borrower");
+        Account const broker("broker");
+        auto const iou = issuer["IOU"];
+        Env env(*this, features);
+        env.fund(XRP(20'000), issuer, broker, borrower);
+        env.close();
+        // Set up trustlines and fund accounts
+        env(trust(broker, iou(20'000'000)));
+        env(trust(borrower, iou(20'000'000)));
+        env(pay(issuer, broker, iou(10'000'000)));
+        env(pay(issuer, borrower, iou(1'000)));
+        env.close();
+        // Create vault and broker
+        auto const brokerInfo = createVaultAndBroker(env, iou, broker);
+        // Create a loan first (this creates debt)
+        auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+        env(set(borrower, brokerInfo.brokerID, 10'000),
+            Sig(sfCounterpartySignature, broker),
+            kLoanServiceFee(iou(100).value()),
+            kPaymentInterval(100),
+            Fee(XRP(100)));
+        env.close();
+        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
+        // We need coverAvailable >= (debtTotal * coverRateMinimum)
+        // Deposit enough cover to ensure the fee goes to broker owner
+        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
+        // at least 1,000 cover. Default cover is 1,000, so we add more to be
+        // safe.
+        auto const additionalCover = iou(50'000).value();
+        env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover}));
+        env.close();
+        // Verify broker owner has a trustline
+        auto const brokerTrustline = keylet::trustLine(broker, iou);
+        BEAST_EXPECT(env.le(brokerTrustline) != nullptr);
+        // Broker owner deletes their trustline
+        // First, pay any positive balance to issuer to zero it out
+        auto const brokerBalance = env.balance(broker, iou);
+        env(pay(broker, issuer, brokerBalance));
+        env.close();
+        // Remove the trustline by setting limit to 0
+        env(trust(broker, iou(0)));
+        env.close();
+        // Verify trustline is deleted
+        BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
+        // Now borrower tries to make a payment
+        // We should get a tesSUCCESS instead of a tecNO_LINE.
+        env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
+        env.close();
+        // Verify trustline is still deleted
+        BEAST_EXPECT(env.le(brokerTrustline) == nullptr);
+        // Verify the service fee went to the broker pseudo-account
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
+            auto const balance = env.balance(pseudo, iou);
+            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
+            BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance)));
+        }
+    }
+
+    void
+    testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features)
+    {
+        testcase << "LoanPay Broker Owner MPT unauthorized";
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer("issuer");
+        Account const borrower("borrower");
+        Account const broker("broker");
+
+        Env env{*this, features};
+        env.fund(XRP(20'000), issuer, broker, borrower);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        // Authorize broker and borrower
+        mptt.authorize({.account = broker});
+        mptt.authorize({.account = borrower});
+
+        env.close();
+
+        // Fund accounts
+        env(pay(issuer, broker, mpt(10'000'000)));
+        env(pay(issuer, borrower, mpt(1'000)));
+        env.close();
+
+        // Create vault and broker
+        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
+        // Create a loan first (this creates debt)
+        auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+        env(set(borrower, brokerInfo.brokerID, 10'000),
+            Sig(sfCounterpartySignature, broker),
+            kLoanServiceFee(mpt(100).value()),
+            kPaymentInterval(100),
+            Fee(XRP(100)));
+        env.close();
+        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
+        // We need coverAvailable >= (debtTotal * coverRateMinimum)
+        // Deposit enough cover to ensure the fee goes to broker owner
+        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
+        // at least 1,000 cover. Default cover is 1,000, so we add more to be
+        // safe.
+        auto const additionalCover = mpt(50'000).value();
+        env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
+        env.close();
+        // Verify broker owner is authorized
+        auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
+        BEAST_EXPECT(env.le(brokerMpt) != nullptr);
+        // Broker owner unauthorizes.
+        // First, pay any positive balance to issuer to zero it out
+        auto const brokerBalance = env.balance(broker, mpt);
+        env(pay(broker, issuer, brokerBalance));
+        env.close();
+        // Then, unauthorize the MPT.
+        mptt.authorize({.account = broker, .flags = tfMPTUnauthorize});
+        env.close();
+        // Verify the MPT is unauthorized.
+        BEAST_EXPECT(env.le(brokerMpt) == nullptr);
+        // Now borrower tries to make a payment
+        // We should get a tesSUCCESS instead of a tecNO_AUTH.
+        env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
+        env.close();
+        // Verify the MPT is still unauthorized.
+        BEAST_EXPECT(env.le(brokerMpt) == nullptr);
+        // Verify the service fee went to the broker pseudo-account
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
+            auto const balance = env.balance(pseudo, mpt);
+            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
+            BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
+        }
+    }
+
+    void
+    testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
+    {
+        testcase << "LoanPay Broker Owner without permissioned domain of the MPT";
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer("issuer");
+        Account const borrower("borrower");
+        Account const broker("broker");
+
+        Env env{*this, features};
+        env.fund(XRP(20'000), issuer, broker, borrower);
+        env.close();
+
+        auto credType = "credential1";
+
+        pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}};
+        env(pdomain::setTx(issuer, credentials1));
+        env.close();
+
+        auto domainID = pdomain::getNewDomain(env.meta());
+
+        env(credentials::create(broker, issuer, credType));
+        env(credentials::accept(broker, issuer, credType));
+        env.close();
+
+        env(credentials::create(borrower, issuer, credType));
+        env(credentials::accept(borrower, issuer, credType));
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({
+            .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
+            .domainID = domainID,
+        });
+
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        // Authorize broker and borrower
+        mptt.authorize({.account = broker});
+        mptt.authorize({.account = borrower});
+
+        env.close();
+
+        // Fund accounts
+        env(pay(issuer, broker, mpt(10'000'000)));
+        env(pay(issuer, borrower, mpt(1'000)));
+        env.close();
+
+        // Create vault and broker
+        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
+        // Create a loan first (this creates debt)
+        auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
+        env(set(borrower, brokerInfo.brokerID, 10'000),
+            Sig(sfCounterpartySignature, broker),
+            kLoanServiceFee(mpt(100).value()),
+            kPaymentInterval(100),
+            Fee(XRP(100)));
+        env.close();
+        // Ensure broker has sufficient cover so brokerPayee == brokerOwner
+        // We need coverAvailable >= (debtTotal * coverRateMinimum)
+        // Deposit enough cover to ensure the fee goes to broker owner
+        // The default coverRateMinimum is 10%, so for a 10,000 loan we need
+        // at least 1,000 cover. Default cover is 1,000, so we add more to be
+        // safe.
+        auto const additionalCover = mpt(50'000).value();
+        env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover}));
+        env.close();
+        // Verify broker owner is authorized
+        auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker);
+        BEAST_EXPECT(env.le(brokerMpt) != nullptr);
+        // Remove the credentials for the Broker owner.
+        // First, pay any positive balance to issuer to zero it out
+        auto const brokerBalance = env.balance(broker, mpt);
+        env(pay(broker, issuer, brokerBalance));
+        env.close();
+
+        env(credentials::deleteCred(broker, broker, issuer, credType));
+        env.close();
+
+        // Make sure the broker is not authorized to hold the MPT after we
+        // deleted the credentials
+        env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
+
+        // Now borrower tries to make a payment
+        // We should get a tesSUCCESS instead of a tecNO_AUTH.
+        env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS));
+        env.close();
+        // Verify broker is still not authorized
+        env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH));
+        // Verify the service fee went to the broker pseudo-account
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            Account const pseudo("pseudo-account", brokerSle->at(sfAccount));
+            auto const balance = env.balance(pseudo, mpt);
+            // 1,000 default + 50,000 extra + 100 service fee from LoanPay
+            BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance)));
+        }
+    }
+
+    void
+    testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features)
+    {
+        testcase << "LoanSet Broker Owner without permissioned domain of the MPT";
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer("issuer");
+        Account const borrower("borrower");
+        Account const broker("broker");
+
+        Env env{*this, features};
+        env.fund(XRP(20'000), issuer, broker, borrower);
+        env.close();
+
+        auto credType = "credential1";
+
+        pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}};
+        env(pdomain::setTx(issuer, credentials1));
+        env.close();
+
+        auto domainID = pdomain::getNewDomain(env.meta());
+
+        // Add credentials for the broker and borrower
+        env(credentials::create(broker, issuer, credType));
+        env(credentials::accept(broker, issuer, credType));
+        env.close();
+
+        env(credentials::create(borrower, issuer, credType));
+        env(credentials::accept(borrower, issuer, credType));
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({
+            .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock,
+            .domainID = domainID,
+        });
+
+        PrettyAsset const mpt{mptt.issuanceID()};
+
+        // Authorize broker and borrower
+        mptt.authorize({.account = broker});
+        mptt.authorize({.account = borrower});
+        env.close();
+
+        // Fund accounts
+        env(pay(issuer, broker, mpt(10'000'000)));
+        env(pay(issuer, borrower, mpt(1'000)));
+        env.close();
+
+        // Create vault and broker
+        auto const brokerInfo = createVaultAndBroker(env, mpt, broker);
+
+        // Remove the credentials for the Broker owner.
+        // Clear the balance first.
+        auto const brokerBalance = env.balance(broker, mpt);
+        env(pay(broker, issuer, brokerBalance));
+        env.close();
+        // Delete the credentials
+        env(credentials::deleteCred(broker, broker, issuer, credType));
+        env.close();
+
+        // Create a loan, this should fail for tecNO_AUTH
+        env(set(borrower, brokerInfo.brokerID, 10'000),
+            Sig(sfCounterpartySignature, broker),
+            kLoanServiceFee(mpt(100).value()),
+            kPaymentInterval(100),
+            Fee(XRP(100)),
+            Ter(tecNO_AUTH));
+        env.close();
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testServiceFeeOnBrokerDeepFreeze();
+        testLoanDefaultBypassesFreeze();
+        testLoanDefaultBypassesDeepFreeze();
+        testLoanDefaultBypassesMptLockAfterImpair();
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testSequentialFLCDepletion(features);
+        testWithdrawReflectsUnrealizedLoss(features);
+        testLoanPayBrokerOwnerMissingTrustline(features);
+        testLoanPayBrokerOwnerUnauthorizedMPT(features);
+        testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
+        testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanCoverFreezeAuth, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp
new file mode 100644
index 0000000000..b3d4803aff
--- /dev/null
+++ b/src/test/app/lending/LoanInvariants_test.cpp
@@ -0,0 +1,1122 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class LoanInvariants_test : public LoanTestBase
+{
+private:
+    // Each of these regression tests reproduces a single fuzzer-found (FIND-*)
+    // scenario against xrpl::detail::computePeriodicPayment /
+    // loanComputePaymentParts. They're merged into one function, one block
+    // per finding, because each is a narrow, self-contained repro that
+    // shares little beyond the surrounding scaffold.
+    void
+    testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features)
+    {
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        using namespace lending;
+
+        // From FIND-012
+        {
+            testcase << "LoanPay xrpl::detail::computePeriodicPayment : "
+                        "valid rate";
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            BrokerParameters const brokerParams;
+            env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower);
+            env.close();
+
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+            BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+            using namespace loan;
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            Number const principalRequest{640562, -5};
+
+            Number const serviceFee{2462611968};
+            std::uint32_t const numPayments{4294967295 / 800};
+
+            auto createJson = env.json(
+                set(borrower, broker.brokerID, principalRequest),
+                Fee(loanSetFee),
+                kLoanServiceFee(serviceFee),
+                kPaymentTotal(numPayments),
+                Json(sfCounterpartySignature, json::ValueType::Object));
+
+            createJson["CloseInterestRate"] = 55374;
+            createJson["ClosePaymentFee"] = "3825205248";
+            createJson["LatePaymentFee"] = "237";
+            createJson["LoanOriginationFee"] = "0";
+            createJson["OverpaymentFee"] = 35167;
+            createJson["OverpaymentInterestRate"] = 1360;
+            createJson["PaymentInterval"] = 727;
+
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+            // Fails in preclaim because principal requested can't be
+            // represented as XRP
+            env(createJson, Ter(tecPRECISION_LOSS));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet));
+
+            Number const actualPrincipal{6};
+
+            createJson[sfPrincipalRequested] = actualPrincipal;
+            createJson.removeMember(sfSequence.jsonName);
+            createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+            // Fails in doApply because the payment is too small to be
+            // represented as XRP.
+            env(createJson, Ter(tecPRECISION_LOSS));
+            env.close();
+        }
+
+        // From FIND-010
+        {
+            testcase << "xrpl::loanComputePaymentParts : valid total interest";
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
+
+            BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+
+            using namespace loan;
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            Number const principalRequest{1, 3};
+
+            auto createJson = env.json(
+                set(borrower, broker.brokerID, principalRequest),
+                Fee(loanSetFee),
+                Json(sfCounterpartySignature, json::ValueType::Object));
+
+            createJson["CloseInterestRate"] = 47299;
+            createJson["ClosePaymentFee"] = "3985819770";
+            createJson["InterestRate"] = 92;
+            createJson["LatePaymentFee"] = "3866894865";
+            createJson["LoanOriginationFee"] = "0";
+            createJson["LoanServiceFee"] = "2348810240";
+            createJson["OverpaymentFee"] = 58545;
+            createJson["PaymentInterval"] = 60;
+            createJson["PaymentTotal"] = 1;
+            createJson["PrincipalRequested"] = "0.000763058";
+
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+            env(createJson);
+            env.close();
+
+            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
+            loanPayTx["Amount"]["value"] = "0.000281284125490196";
+            env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT));
+            env.close();
+        }
+
+        // From FIND-009
+        {
+            testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid "
+                        "rounded";
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
+
+            BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+
+            using namespace loan;
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            Number const principalRequest{1, 3};
+
+            auto createJson = env.json(
+                set(borrower, broker.brokerID, principalRequest),
+                Fee(loanSetFee),
+                Json(sfCounterpartySignature, json::ValueType::Object));
+
+            createJson["ClosePaymentFee"] = "0";
+            createJson["InterestRate"] = 24346;
+            createJson["LateInterestRate"] = 65535;
+            createJson["LatePaymentFee"] = "0";
+            createJson["LoanOriginationFee"] = "218";
+            createJson["LoanServiceFee"] = "0";
+            createJson["PaymentInterval"] = 60;
+            createJson["PaymentTotal"] = 5678;
+            createJson["PrincipalRequested"] = "9924.81";
+
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+            env(createJson, Ter(tesSUCCESS));
+            env.close();
+
+            auto const baseFee = env.current()->fees().base;
+
+            auto const stateBefore = getCurrentState(env, broker, keylet);
+
+            {
+                auto loanPayTx =
+                    env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
+                Number const amount{3074'745'058'823'529, -12};
+                BEAST_EXPECT(to_string(amount) == "3074.745058823529");
+                XRPAmount const payFee{
+                    baseFee *
+                    (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
+                loanPayTx["Amount"]["value"] = to_string(amount);
+                env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
+                env.close();
+            }
+
+            {
+                auto loanPayTx =
+                    env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
+                Number const amount{6732'118'170'944'051, -12};
+                BEAST_EXPECT(to_string(amount) == "6732.118170944051");
+                XRPAmount const payFee{
+                    baseFee *
+                    (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
+                loanPayTx["Amount"]["value"] = to_string(amount);
+                env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
+                env.close();
+            }
+
+            auto const stateAfter = getCurrentState(env, broker, keylet);
+            // Total interest outstanding is non-negative
+            BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding);
+            // Principal paid is non-negative
+            BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding);
+            // Total value change is non-negative
+            BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue);
+            // Value delta is larger or same as principal delta (meaning
+            // non-negative interest paid)
+            BEAST_EXPECT(
+                (stateBefore.totalValue - stateAfter.totalValue) >=
+                (stateBefore.principalOutstanding - stateAfter.principalOutstanding));
+        }
+
+        // From FIND-008
+        {
+            testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded";
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            PrettyAsset const iouAsset =
+                createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000);
+
+            BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+            {
+                auto const coverDepositValue =
+                    broker.asset(broker.params.coverDeposit * 10).value();
+                env(loan_broker::coverDeposit(lender, broker.brokerID, coverDepositValue));
+                env.close();
+            }
+
+            using namespace loan;
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            Number const principalRequest{1, 3};
+
+            auto createJson = env.json(
+                set(borrower, broker.brokerID, principalRequest),
+                Fee(loanSetFee),
+                Json(sfCounterpartySignature, json::ValueType::Object));
+
+            createJson["ClosePaymentFee"] = "0";
+            createJson["InterestRate"] = 12833;
+            createJson["LateInterestRate"] = 77048;
+            createJson["LatePaymentFee"] = "0";
+            createJson["LoanOriginationFee"] = "218";
+            createJson["LoanServiceFee"] = "0";
+            createJson["PaymentInterval"] = 752;
+            createJson["PaymentTotal"] = 5678;
+            createJson["PrincipalRequested"] = "9924.81";
+
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+            env(createJson, Ter(tesSUCCESS));
+            env.close();
+
+            auto const baseFee = env.current()->fees().base;
+
+            auto const stateBefore = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(stateBefore.paymentRemaining == 5678);
+            BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
+
+            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}}));
+            Number const amount{9924'81, -2};
+            BEAST_EXPECT(to_string(amount) == "9924.81");
+            XRPAmount const payFee{
+                baseFee *
+                (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)};
+            loanPayTx["Amount"]["value"] = to_string(amount);
+            env(loanPayTx, Fee(payFee), Ter(tesSUCCESS));
+            env.close();
+
+            auto const stateAfter = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(
+                stateAfter.paymentRemaining ==
+                stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction);
+        }
+    }
+
+    void
+    testLoanPayDebtDecreaseInvariant(FeatureBitset features)
+    {
+        // From FIND-007
+        testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease "
+                    "rounding good";
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        using namespace lending;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
+
+        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+
+        using namespace loan;
+
+        auto const baseFee = env.current()->fees().base;
+        auto const loanSetFee = Fee(baseFee * 2);
+        Number const principalRequest{1, 3};
+
+        auto createJson = env.json(
+            set(borrower, broker.brokerID, principalRequest),
+            Fee(loanSetFee),
+            Json(sfCounterpartySignature, json::ValueType::Object));
+
+        createJson["ClosePaymentFee"] = "0";
+        createJson["GracePeriod"] = 60;
+        createJson["InterestRate"] = 24346;
+        createJson["LateInterestRate"] = 65535;
+        createJson["LatePaymentFee"] = "0";
+        createJson["LoanOriginationFee"] = "218";
+        createJson["LoanServiceFee"] = "0";
+        createJson["PaymentInterval"] = 60;
+        createJson["PaymentTotal"] = 5678;
+        createJson["PrincipalRequested"] = "9924.81";
+
+        auto const keylet = nextLoanKeylet(env, broker);
+
+        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+        env(createJson, Ter(tesSUCCESS));
+        env.close();
+
+        auto const pseudoAcct = brokerPseudoAccount(env, broker, lender);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
+        auto const originalState = getCurrentState(env, broker, keylet);
+        verifyLoanStatus(originalState);
+
+        Number const payment{3'269'349'176'470'588, -12};
+        XRPAmount const payFee{
+            baseFee *
+            ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)};
+        auto loanPayTx =
+            env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee));
+        BEAST_EXPECT(to_string(payment) == "3269.349176470588");
+        env(loanPayTx, Ter(tesSUCCESS));
+        env.close();
+
+        auto const newState = getCurrentState(env, broker, keylet);
+        BEAST_EXPECT(
+            isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale));
+        BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding);
+        BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale));
+        BEAST_EXPECT(
+            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)
+    {
+        // (From FIND-006)
+        testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount "
+                    "and MPT";
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+        PrettyAsset const mptAsset = mptt.issuanceID();
+        mptt.authorize({.account = lender});
+        mptt.authorize({.account = borrower});
+        env(pay(issuer, lender, mptAsset(2'000'000)));
+        env(pay(issuer, borrower, mptAsset(1'000)));
+        env.close();
+
+        BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{1, 3};
+
+        auto createJson = env.json(
+            set(borrower, broker.brokerID, principalRequest),
+            Fee(loanSetFee),
+            Json(sfCounterpartySignature, json::ValueType::Object));
+
+        createJson["CloseInterestRate"] = 76671;
+        createJson["ClosePaymentFee"] = "2061925410";
+        createJson["GracePeriod"] = 434;
+        createJson["InterestRate"] = 50302;
+        createJson["LateInterestRate"] = 30322;
+        createJson["LatePaymentFee"] = "294427911";
+        createJson["LoanOriginationFee"] = "3250635102";
+        createJson["LoanServiceFee"] = "9557386";
+        createJson["OverpaymentFee"] = 51249;
+        createJson["OverpaymentInterestRate"] = 14304;
+        createJson["PaymentInterval"] = 434;
+        createJson["PaymentTotal"] = "2891743748";
+        createJson["PrincipalRequested"] = "8516.98";
+
+        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+        env(createJson, Ter(temINVALID));
+        env.close();
+    }
+
+    // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the
+    // same vault-scale minimum cover when fixCleanup3_2_0 is enabled.
+    // Before the amendment, each transactor computed its minimum cover at a
+    // different precision (loanScale, debtScale, or the raw unrounded
+    // tenthBipsOfValue), which could lead to inconsistent decisions for the
+    // same broker state.  After the amendment all three use
+    // minimumBrokerCover at vaultScale.
+    void
+    testMinimumBrokerCoverConsistency(FeatureBitset features)
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace loan_broker;
+
+        bool const withAmendment = features[fixCleanup3_2_0];
+
+        struct Ctx
+        {
+            jtx::Account issuer;
+            jtx::Account lender;
+            jtx::Account borrower;
+            jtx::PrettyAsset iou;
+            BrokerInfo broker;
+            BrokerParameters brokerParams;
+        };
+
+        // Shared setup, parametrized by vaultDeposit (the only varying setup
+        // field across the three scenarios).  Each call runs in its own Env
+        // so multiple invocations within one scenario cannot interfere.
+        // The caller is responsible for invoking testcase(...) before the
+        // first runTest call of each scenario.
+        auto runTest = [&](Number vaultDeposit, auto&& body) {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
+            env.close();
+
+            // Enable clawback on the issuer *before* any trust lines exist
+            // (asfAllowTrustLineClawback requires an empty owner directory).
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const iou = issuer[iouCurrency_];
+            env(trust(lender, iou(1'000'000'000)));
+            env(trust(borrower, iou(1'000'000'000)));
+            env.close();
+            env(pay(issuer, lender, iou(100'000'000)));
+            env(pay(issuer, borrower, iou(100'000'000)));
+            env.close();
+
+            // 13.37% — non-round rate produces a messier minimum.
+            BrokerParameters const brokerParams{
+                .vaultDeposit = vaultDeposit,
+                .debtMax = 0,
+                .coverRateMin = TenthBips32{13'370},
+                .coverDeposit = 5'000,
+                .managementFeeRate = TenthBips16{500}};
+
+            BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
+
+            body(
+                env,
+                Ctx{.issuer = issuer,
+                    .lender = lender,
+                    .borrower = borrower,
+                    .iou = iou,
+                    .broker = broker,
+                    .brokerParams = brokerParams});
+        };
+
+        // Scenario 1 — LoanPay
+        //
+        // Verify that LoanPay's minimum cover check uses vault scale (not
+        // loan scale).  Before the amendment, different loans could produce
+        // different fee routing decisions for the same broker-level state.
+        // Small vault deposit => vaultScale = -12.
+        testcase("LoanPay minimum cover scale consistency");
+        {
+            struct LoanKeylets
+            {
+                Keylet tiny;
+                Keylet big;
+            };
+
+            // Create the tiny + big loans and reduce cover via clawback so
+            // that subsequent LoanPay calls hit the minimum-cover boundary.
+            // Used by the two pay-and-check sub-tests below so each can run
+            // in its own Env.
+            auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional {
+                Asset const asset{c.iou};
+
+                // Create the TINY loan first (while vaultScale is still
+                // small).  principal 0.01, 0% interest, 1 payment =>
+                // loanScale = vaultScale.
+                auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle1))
+                    return std::nullopt;
+                auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
+                auto const tinyLoanKeylet =
+                    keylet::loan(c.broker.brokerID, SeqProxy::rawSequence(tinyLoanSeq));
+
+                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Create the BIG loan second.  100% annual interest over 20
+                // payments pushes totalValueOutstanding high enough that
+                // loanScale > vaultScale.
+                auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle2))
+                    return std::nullopt;
+                auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
+                auto const bigLoanKeylet =
+                    keylet::loan(c.broker.brokerID, SeqProxy::rawSequence(bigLoanSeq));
+
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // The tiny loan's scale is frozen at the vault's pre-big-loan
+                // scale, so it is strictly smaller than the big loan's.
+                // After the big loan is created the vault absorbs its value,
+                // pushing vaultScale up to match bigLoanScale.
+                auto const tinyLoanSle = env.le(tinyLoanKeylet);
+                auto const bigLoanSle = env.le(bigLoanKeylet);
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) ||
+                    !BEAST_EXPECT(vaultSle))
+                    return std::nullopt;
+                if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
+                    !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
+                    !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
+                    return std::nullopt;
+
+                // Use issuer clawback to reduce cover to the minimum the
+                // clawback transactor allows.  Compute the amount as
+                // initialCover - expectedCoverAfter so we exercise the exact
+                // clawback rather than relying on the transactor to clip
+                // down.
+                //
+                // Before the amendment the clawback minimum is the
+                // *unrounded* tenthBipsOfValue — strictly less than the
+                // rounded-at-vaultScale minimum LoanPay uses for the big
+                // loan.  After the amendment both clawback and LoanPay use
+                // the same rounded minimum (via minimumBrokerCover), so
+                // cover lands exactly at that threshold.
+                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
+                                                                : Number{1330651855688458000, -15};
+                Number const clawbackAmount =
+                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
+
+                env(coverClawback(c.issuer),
+                    kLoanBrokerId(c.broker.brokerID),
+                    kAmount(STAmount{asset, clawbackAmount}));
+                env.close();
+
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle) ||
+                    !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter))
+                    return std::nullopt;
+
+                return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet};
+            };
+
+            // Pay one loan and report whether the fee went to the broker's
+            // pseudo account (the fallback when cover < minimum) rather
+            // than to the owner.
+            auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool {
+                Asset const asset{c.iou};
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                if (!BEAST_EXPECT(brokerSle))
+                    return false;
+                auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
+                auto const pseudoBefore = env.balance(pseudoAcct, c.iou);
+
+                auto const payLoan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(payLoan))
+                    return false;
+                auto const periodicPayment = payLoan->at(sfPeriodicPayment);
+                auto const serviceFee = payLoan->at(sfLoanServiceFee);
+                std::int32_t const loanScale = payLoan->at(sfLoanScale);
+
+                auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale);
+                auto const payAmt = STAmount{asset, payment + serviceFee};
+
+                env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
+                env.close();
+
+                auto const pseudoAfter = env.balance(pseudoAcct, c.iou);
+                return pseudoAfter.number() > pseudoBefore.number();
+            };
+
+            // Pay the BIG loan in its own Env so its outcome cannot affect
+            // the TINY-loan check.  With the fix, LoanPay and clawback use
+            // the same vaultScale minimum (cover == minAtVaultScale =>
+            // fee to owner).  Without the fix, LoanPay uses bigLoanScale=-11,
+            // rounds up to a larger minimum than what clawback used =>
+            // cover < min => fee to pseudo.
+            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                auto const loans = setupLoansAndClawback(env, c);
+                if (!loans)
+                    return;
+                BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment);
+            });
+
+            // Pay the TINY loan in its own Env.  Fee goes to the owner
+            // either way:
+            //  - With the fix: LoanPay uses vaultScale=-11 (same as
+            //    clawback) => owner.
+            //  - Without the fix: LoanPay uses tinyLoanScale=-12, rounds
+            //    up at -12 (a no-op) => min == cover => owner.
+            runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                auto const loans = setupLoansAndClawback(env, c);
+                if (!loans)
+                    return;
+                BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny));
+            });
+        }
+
+        // Scenario 2 — LoanBrokerCoverWithdraw
+        //
+        // Verify that CoverWithdraw's minimum cover check uses vault scale
+        // (not scale(debtTotal, asset)).  Before the amendment, CoverWithdraw
+        // used:
+        //   roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
+        // which could disagree with LoanPay's minimum (which used loanScale).
+        //
+        // Use a large vault deposit so that vaultScale (from AssetsTotal) is
+        // strictly larger than debtScale (from DebtTotal).  With
+        // vaultDeposit = 100,000: after the big loan
+        //   AssetsTotal ≈ 109,500 → vaultScale = -10
+        //   DebtTotal   ≈  10,000 → debtScale  = -11
+        // The one-order-of-magnitude gap makes roundToAsset at -10 truncate
+        // more aggressively than at -11, exposing the bug.
+        testcase("CoverWithdraw minimum cover scale consistency");
+        runTest(
+            /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) {
+                Asset const asset{c.iou};
+
+                // Create only the big loan to push DebtTotal up to ~10,000
+                // while AssetsTotal stays around 109,500 (dominated by the
+                // large vault deposit).
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Read broker state and compute both old and new minimums.
+                auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID));
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle))
+                    return;
+
+                auto const coverAvail = brokerSle->at(sfCoverAvailable);
+                auto const debtTotal = brokerSle->at(sfDebtTotal);
+                auto const vaultScale = getAssetsTotalScale(vaultSle);
+                auto const debtScale = scale(debtTotal, asset);
+
+                // Sanity: debt scale differs from vault scale for this setup.
+                BEAST_EXPECT(debtScale < vaultScale);
+
+                auto const oldMin = [&]() {
+                    NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+                    return roundToAsset(
+                        asset,
+                        tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}),
+                        debtScale);
+                }();
+                auto const newMin = minimumBrokerCover(
+                    debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle);
+
+                // The new (vaultScale) minimum must be strictly larger than
+                // the old (debtScale) minimum — that is the gap the amendment
+                // closes.
+                Number const expectedNewMin{1330650518688500000, -15};
+                Number const expectedOldMin{1330650518688472000, -15};
+                BEAST_EXPECT(newMin == expectedNewMin);
+                BEAST_EXPECT(oldMin == expectedOldMin);
+
+                // Try to withdraw so that remaining cover lands between the
+                // two minimums:  oldMin < target < newMin.
+                auto const target = oldMin + (newMin - oldMin) / 2;
+                auto const withdrawAmount = STAmount{asset, coverAvail - target};
+
+                if (withAmendment)
+                {
+                    // CoverWithdraw now uses vaultScale: target < newMin
+                    // => FAILS.
+                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount),
+                        Ter(tecINSUFFICIENT_FUNDS));
+                }
+                else
+                {
+                    // Old CoverWithdraw uses debtScale: target > oldMin
+                    // => SUCCEEDS.
+                    env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount));
+                }
+                env.close();
+            });
+
+        // Scenario 3 — LoanSet
+        //
+        // Verify that LoanSet's minimum cover check uses vault scale (not the
+        // raw unrounded tenthBipsOfValue).  Before the amendment, LoanSet
+        // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no
+        // roundToAsset), while clawback/withdraw used different formulas.
+        // After the amendment all use minimumBrokerCover at vaultScale, and
+        // rounding at a coarser scale can absorb a tiny debt increase —
+        // allowing a loan that would otherwise be rejected.
+        testcase("LoanSet minimum cover scale consistency");
+        runTest(
+            /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
+                // Create the tiny loan (scale -12) AND the big loan (scale
+                // -11).  Both loans are needed so that DebtTotal has a full
+                // 16-digit mantissa — a "messy" value where roundToAsset at
+                // vaultScale actually truncates digits and produces a
+                // different result from the raw tenthBipsOfValue.  With only
+                // the big loan, DebtTotal has ~4 significant digits and
+                // rounding at scale -11 is a no-op, masking the amendment's
+                // effect.
+                env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{0}),
+                    kPaymentTotal(1),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                env(set(c.borrower, c.broker.brokerID, Number{500}),
+                    Sig(sfCounterpartySignature, c.lender),
+                    kInterestRate(TenthBips32{100'000}),
+                    kPaymentTotal(20),
+                    kPaymentInterval(86400 * 365),
+                    Fee(XRP(10)));
+                env.close();
+
+                // Clawback to reduce cover to the clawback transactor's
+                // minimum.  Pass the exact amount rather than relying on the
+                // transactor to clip down; the setup matches Scenario 1 so
+                // the same residual-cover values apply.
+                Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
+                                                                : Number{1330651855688458000, -15};
+                Number const clawbackAmount =
+                    Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
+                env(coverClawback(c.issuer),
+                    kLoanBrokerId(c.broker.brokerID),
+                    kAmount(c.iou(clawbackAmount)));
+                env.close();
+
+                // Verify scales.
+                auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                auto const vaultScale = getAssetsTotalScale(vaultSle);
+                BEAST_EXPECT(vaultScale == -11);
+
+                // Now try to create a tiny additional loan.  Principal is
+                // 1e-11 (the smallest value that survives the precision
+                // check at loanScale = vaultScale = -11), with 0% interest
+                // and 1 payment.
+                //
+                // The tiny debt increase adds ~1.337e-12 to the unrounded
+                // minimum.
+                // - Without the amendment: the old LoanSet formula rounds
+                //   up during tenthBipsOfValue (16-digit Number
+                //   normalisation), pushing the minimum past the cover left
+                //   by clawback => tecINSUFFICIENT_FUNDS.
+                // - With the amendment: minimumBrokerCover rounds at
+                //   vaultScale=-11, which absorbs the tiny increase — the
+                //   rounded minimum stays the same => tesSUCCESS.
+                auto const tinyPrincipal = Number{1, -11};
+
+                if (withAmendment)
+                {
+                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
+                        Sig(sfCounterpartySignature, c.lender),
+                        kInterestRate(TenthBips32{0}),
+                        kPaymentTotal(1),
+                        kPaymentInterval(86400 * 365),
+                        Fee(XRP(10)));
+                }
+                else
+                {
+                    env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
+                        Sig(sfCounterpartySignature, c.lender),
+                        kInterestRate(TenthBips32{0}),
+                        kPaymentTotal(1),
+                        kPaymentInterval(86400 * 365),
+                        Fee(XRP(10)),
+                        Ter(tecINSUFFICIENT_FUNDS));
+                }
+                env.close();
+            });
+    }
+
+    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);
+    }
+
+public:
+    void
+    run() override
+    {
+        testLoanSetRecipientScaleInvariant();
+        testLoanPayInterestDueNonNegativeInvariant();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanInvariants, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp
new file mode 100644
index 0000000000..45420529c6
--- /dev/null
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -0,0 +1,704 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#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 LoanLifecycle_test : public LoanTestBase
+{
+private:
+    void
+    testLifecycle(FeatureBitset features)
+    {
+        testcase("Lifecycle");
+        using namespace jtx;
+
+        // Create 3 loan brokers: one for XRP, one for an IOU, and one for
+        // an MPT. That'll require three corresponding SAVs.
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        // For simplicity, lender will be the sole actor for the vault &
+        // brokers.
+        Account const lender{"lender"};
+        // Borrower only wants to borrow
+        Account const borrower{"borrower"};
+        // Evan will attempt to be naughty
+        Account const evan{"evan"};
+        // Do not fund alice
+        Account const alice{"alice"};
+
+        // Fund the accounts and trust lines with the same amount so that
+        // tests can use the same values regardless of the asset.
+        env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan));
+        env.close();
+
+        // Create assets
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        env(trust(lender, iouAsset(10'000'000)));
+        env(trust(borrower, iouAsset(10'000'000)));
+        env(trust(evan, iouAsset(10'000'000)));
+        env(pay(issuer, evan, iouAsset(1'000'000)));
+        env(pay(issuer, lender, iouAsset(10'000'000)));
+        // Fund the borrower with enough to cover interest and fees
+        env(pay(issuer, borrower, iouAsset(10'000)));
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+        // Scale the MPT asset a little bit so we can get some interest
+        PrettyAsset const mptAsset{mptt.issuanceID(), 100};
+        mptt.authorize({.account = lender});
+        mptt.authorize({.account = borrower});
+        mptt.authorize({.account = evan});
+        env(pay(issuer, lender, mptAsset(10'000'000)));
+        env(pay(issuer, evan, mptAsset(1'000'000)));
+        // Fund the borrower with enough to cover interest and fees
+        env(pay(issuer, borrower, mptAsset(10'000)));
+        env.close();
+
+        std::array const assets{iouAsset, xrpAsset, mptAsset};
+
+        // Create vaults and loan brokers
+        std::vector brokers;
+        brokers.reserve(assets.size());
+        for (auto const& asset : assets)
+        {
+            brokers.emplace_back(createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.data = "spam spam spam spam"}));
+        }
+
+        // Create and update Loans
+        for (auto const& broker : brokers)
+        {
+            for (int amountExponent = 3; amountExponent >= 3; --amountExponent)
+            {
+                Number const loanAmount{1, amountExponent};
+                for (int interestExponent = 0; interestExponent >= 0; --interestExponent)
+                {
+                    testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent);
+                }
+            }
+
+            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+                BEAST_EXPECT(brokerSle))
+            {
+                BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
+                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0);
+
+                auto const coverAvailable = brokerSle->at(sfCoverAvailable);
+                env(loan_broker::coverWithdraw(
+                    lender, broker.brokerID, STAmount(broker.asset, coverAvailable)));
+                env.close();
+
+                brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+                BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0);
+            }
+            // Verify we can delete the loan broker
+            env(loan_broker::del(lender, broker.brokerID));
+            env.close();
+        }
+    }
+
+    void
+    testSelfLoan(FeatureBitset features)
+    {
+        testcase << "Self Loan";
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        // Create 3 loan brokers: one for XRP, one for an IOU, and one for
+        // an MPT. That'll require three corresponding SAVs.
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        // For simplicity, lender will be the sole actor for the vault &
+        // brokers.
+        Account const lender{"lender"};
+
+        // Fund the accounts and trust lines with the same amount so that
+        // tests can use the same values regardless of the asset.
+        env.fund(XRP(100'000'000), issuer, noripple(lender));
+        env.close();
+
+        // Use an XRP asset for simplicity
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+        // Create vaults and loan brokers
+        BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{1, 3};
+
+        // The LoanSet json can be created without a counterparty signature,
+        // but it will not pass preflight
+        auto createJson = env.json(
+            set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee));
+        env(createJson, Ter(temBAD_SIGNER));
+
+        // Adding an empty counterparty signature object also fails, but
+        // at the RPC level.
+        createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object));
+        env(createJson, Ter(telENV_RPC_FAILED));
+
+        if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx))
+        {
+            Serializer s;
+            jt.stx->add(s);
+            auto const jr = env.rpc("submit", strHex(s.slice()));
+
+            BEAST_EXPECT(jr.isMember(jss::result));
+            auto const jResult = jr[jss::result];
+            BEAST_EXPECT(jResult[jss::error] == "invalidTransaction");
+            BEAST_EXPECT(
+                jResult[jss::error_exception] ==
+                "fails local checks: Transaction has bad signature.");
+        }
+
+        // Copy the transaction signature into the counterparty signature.
+        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];
+
+        // 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();
+
+        auto const startDate = env.current()->header().parentCloseTime;
+
+        // Loan is successfully created
+        {
+            auto const res = env.rpc("account_objects", lender.human());
+            auto const objects = res[jss::result][jss::account_objects];
+
+            std::map types;
+            BEAST_EXPECT(objects.size() == 4);
+            for (auto const& object : objects)
+            {
+                ++types[object[sfLedgerEntryType].asString()];
+            }
+            BEAST_EXPECT(types.size() == 4);
+            for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"})
+            {
+                BEAST_EXPECT(types[type] == 1);
+            }
+        }
+        auto const loanID = [&]() {
+            json::Value params(json::ValueType::Object);
+            params[jss::account] = lender.human();
+            params[jss::type] = "Loan";
+            auto const res = env.rpc("json", "account_objects", to_string(params));
+            auto const objects = res[jss::result][jss::account_objects];
+
+            BEAST_EXPECT(objects.size() == 1);
+
+            auto const loan = objects[0u];
+            BEAST_EXPECT(loan[sfBorrower] == lender.human());
+            // soeDEFAULT fields are not returned if they're in the default
+            // state
+            BEAST_EXPECT(!loan.isMember(sfCloseInterestRate));
+            BEAST_EXPECT(!loan.isMember(sfClosePaymentFee));
+            BEAST_EXPECT(loan[sfFlags] == 0);
+            BEAST_EXPECT(loan[sfGracePeriod] == 60);
+            BEAST_EXPECT(!loan.isMember(sfInterestRate));
+            BEAST_EXPECT(!loan.isMember(sfLateInterestRate));
+            BEAST_EXPECT(!loan.isMember(sfLatePaymentFee));
+            BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID));
+            BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee));
+            BEAST_EXPECT(loan[sfLoanSequence] == 1);
+            BEAST_EXPECT(!loan.isMember(sfLoanServiceFee));
+            BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60);
+            BEAST_EXPECT(!loan.isMember(sfOverpaymentFee));
+            BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate));
+            BEAST_EXPECT(loan[sfPaymentInterval] == 60);
+            BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000");
+            BEAST_EXPECT(loan[sfPaymentRemaining] == 1);
+            BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate));
+            BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000");
+            BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000");
+            BEAST_EXPECT(!loan.isMember(sfLoanScale));
+            BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count());
+
+            return loan["index"].asString();
+        }();
+        auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})};
+
+        env.close(startDate);
+
+        // Make a payment
+        env(pay(lender, loanKeylet.key, broker.asset(1000)));
+    }
+
+    void
+    testIssuerLoan()
+    {
+        testcase << "Issuer Loan";
+
+        using namespace jtx;
+        using namespace loan;
+        Account const issuer("issuer");
+        Account const borrower = issuer;
+        Account const lender("lender");
+        Env env(*this);
+
+        env.fund(XRP(1'000), issuer, lender);
+
+        static constexpr std::int64_t kIssuerBalance = 10'000'000;
+        MPTTester const asset(
+            {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance});
+
+        BrokerParameters const brokerParams{
+            .debtMax = 200,
+        };
+        auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        // Create Loan
+        env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee);
+        env.close();
+        // Issuer should not create MPToken
+        BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer)));
+        // Issuer "borrowed" 200, OutstandingAmount decreased by 200
+        BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200));
+        // Pay Loan
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(1));
+        env(pay(borrower, loanKeylet.key, asset(200)));
+        env.close();
+        // Issuer "re-payed" 200, OutstandingAmount increased by 200
+        BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance));
+    }
+
+    void
+    testBorrowerIsBroker()
+    {
+        testcase("Test Borrower is Broker");
+        using namespace jtx;
+        using namespace loan;
+        Account const broker{"broker"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        Account const depositor{"depositor"};
+
+        auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) {
+            Env env(*this);
+            Vault const vault(env);
+
+            if (borrower == broker)
+            {
+                env.fund(XRP(10'000), broker, issuer, depositor);
+            }
+            else
+            {
+                env.fund(XRP(10'000), broker, borrower, issuer, depositor);
+            }
+            env.close();
+
+            auto const xrpFee = XRP(100);
+            auto const txFee = Fee(xrpFee);
+
+            STAmount const debtMaximumRequest = getMaxDebt(env);
+
+            auto const& asset = debtMaximumRequest.asset();
+            auto const initialVault = asset(debtMaximumRequest * 100);
+
+            // 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();
+
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}),
+                txFee);
+            env.close();
+
+            vault.closePastSubscription(subscriptionDate);
+
+            auto const brokerKeylet =
+                keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
+
+            env(loan_broker::set(broker, vaultKeylet.key), txFee);
+            env.close();
+
+            auto const serviceFee = 101;
+
+            env(set(broker, brokerKeylet.key, debtMaximumRequest),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                kLoanServiceFee(serviceFee),
+                kPaymentTotal(10),
+                txFee);
+            env.close();
+
+            std::uint32_t const loanSequence = 1;
+            auto const loanKeylet =
+                keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence));
+
+            auto const brokerBalanceBefore = env.balance(broker, asset);
+
+            if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle))
+            {
+                auto const payment = loanSle->at(sfPeriodicPayment);
+                auto const totalPayment = payment + serviceFee;
+                env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee);
+                env.close();
+                if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle))
+                {
+                    auto const expected = [&]() {
+                        // The service fee is transferred to the broker if
+                        // a borrower is not the broker
+                        if (borrower != broker)
+                            return brokerBalanceBefore.number() + serviceFee;
+                        // Since a borrower is the broker, the payment is
+                        // transferred to the Vault from the broker but not
+                        // the service fee.
+                        // If the asset is XRP then the broker pays the txFee.
+                        if (asset.native())
+                            return brokerBalanceBefore.number() - payment - xrpFee.number();
+                        return brokerBalanceBefore.number() - payment;
+                    }();
+                    BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value());
+                }
+            }
+        };
+        // Test when a borrower is the broker and is not to verify correct
+        // service fee transfer in both cases.
+        for (auto const& borrowerAcct : {broker, borrower})
+        {
+            testLoanAsset(
+                [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct);
+            testLoanAsset(
+                [&](Env& env) -> STAmount {
+                    auto const iou = issuer["USD"];
+                    env(trust(broker, iou(1'000'000'000)));
+                    env(trust(depositor, iou(1'000'000'000)));
+                    env(pay(issuer, broker, iou(100'000'000)));
+                    env(pay(issuer, depositor, iou(100'000'000)));
+                    env.close();
+                    return iou(200'000);
+                },
+                borrowerAcct);
+            testLoanAsset(
+                [&](Env& env) -> STAmount {
+                    MPTTester const mpt(
+                        {.env = env,
+                         .issuer = issuer,
+                         .holders = {broker, depositor},
+                         .pay = 100'000'000});
+                    return mpt(200'000);
+                },
+                borrowerAcct);
+        }
+    }
+
+    void
+    testIssuerIsBorrower(FeatureBitset features)
+    {
+        testcase("RIPD-4096 - Issuer as borrower");
+
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 100'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender, .counter = issuer, .principalRequest = Number{10000}};
+
+        auto const assetType = AssetType::IOU;
+
+        Env env{*this, features};
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer);
+
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+        auto pseudoAcct = std::get(*loanResult);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
+
+        makeLoanPayments(
+            env,
+            broker,
+            loanParams,
+            loanKeylet,
+            verifyLoanStatus,
+            issuer,
+            lender,
+            issuer,
+            PaymentParameters{.showStepBalances = true});
+    }
+
+    void
+    testBatchBypassCounterparty(FeatureBitset features)
+    {
+        // From FIND-001
+        testcase << "Batch Bypass Counterparty";
+
+        bool const lendingBatchEnabled = !std::ranges::any_of(
+            Batch::kDisabledTxTypes, [](auto const& disabled) { return disabled == ttLOAN_SET; });
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        Env env(*this, features);
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        BrokerParameters const brokerParams;
+        env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{1, 3};
+
+        auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0);
+
+        json::Value randomData{json::ValueType::Object};
+        randomData[jss::SigningPubKey] = json::StaticString{"2600"};
+        json::Value sigObject{json::ValueType::Object};
+        sigObject[jss::SigningPubKey] = strHex(lender.pk().slice());
+        Serializer ss;
+        ss.add32(HashPrefix::TxSign);
+        parse(randomData).addWithoutSigningFields(ss);
+        auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice());
+        sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()});
+
+        forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject;
+
+        // ? Fails because the lender hasn't signed the tx
+        env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED));
+
+        auto const seq = env.seq(borrower);
+        auto const batchFee = batch::calcBatchFee(env, 1, 2);
+        // ! Should fail because the lender hasn't signed the tx
+        env(batch::outer(borrower, seq, batchFee, tfAllOrNothing),
+            batch::Inner(forgedLoanSet, seq + 1),
+            batch::Inner(pay(borrower, lender, XRP(1)), seq + 2),
+            Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH));
+        env.close();
+
+        // ? Check that the loan was NOT created
+        {
+            json::Value params(json::ValueType::Object);
+            params[jss::account] = borrower.human();
+            params[jss::type] = "Loan";
+            auto const res = env.rpc("json", "account_objects", to_string(params));
+            auto const objects = res[jss::result][jss::account_objects];
+            BEAST_EXPECT(objects.size() == 0);
+        }
+    }
+
+    // Integration test: full lifecycle of a $1B loan in the bug regime.
+    // Verifies that the vault collects the economically-correct interest
+    // income and that conservation holds at the trust-line level.
+    //
+    // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only
+    // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10.
+    // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B,
+    // matching the value computed independently with arbitrary-precision
+    // Decimal arithmetic.
+    void
+    testFullLifecycleVaultPnLNearZeroRate()
+    {
+        testcase("integration: full loan lifecycle, vault interest at near-zero rate");
+
+        using namespace jtx;
+        using namespace jtx::loan;
+        using namespace std::chrono_literals;
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const iouAsset = issuer["USD"];
+        STAmount const trustLimit{iouAsset.raw(), Number{1, 17}};
+        env(trust(lender, trustLimit));
+        env(trust(borrower, trustLimit));
+        env.close();
+        env(pay(issuer, lender, iouAsset(5'000'000'000LL)));
+        env(pay(issuer, borrower, iouAsset(5'000'000'000LL)));
+        env.close();
+
+        auto usdBalance = [&](Account const& a) {
+            return env.balance(a, iouAsset.raw().get()).value();
+        };
+        STAmount const borrowerStartBal = usdBalance(borrower);
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = Number{2, 9},
+            .debtMax = Number{0},
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
+
+        auto const vaultBefore = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable);
+
+        // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32.
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{1, 9};
+        auto createJson = env.json(
+            set(borrower, broker.brokerID, principalRequest),
+            Fee(loanSetFee),
+            Json(sfCounterpartySignature, json::ValueType::Object));
+        createJson["InterestRate"] = 1;
+        createJson["PaymentTotal"] = 3;
+        createJson["PaymentInterval"] = 600;
+
+        auto const loanKeylet = nextLoanKeylet(env, broker);
+        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+        env(createJson, Ter(tesSUCCESS));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const expectedTotalInterest =
+            loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding);
+
+        env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfter = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable);
+        Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore;
+
+        STAmount const borrowerEndBal = usdBalance(borrower);
+        STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal;
+
+        // Self-consistency: vault gained exactly the expected interest
+        // computed at LoanSet, and the borrower's outflow matches.
+        BEAST_EXPECT(vaultGain == expectedTotalInterest);
+        BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest);
+
+        // Mathematical correctness: the total interest for this loan
+        // configuration is 0.38051750382930729983, calculated
+        // independently using 50-digit Decimal arithmetic (no
+        // cancellation possible at that precision). At Number's 19-digit
+        // mantissa this rounds to 0.38051750382930729 — the literal
+        // below. The vault's actual gain must agree to within
+        // sub-microcent precision.
+        Number const decimalReference{38051750382930729LL, -17};
+        Number const tolerance{1, -6};  // 1e-6 USD = sub-microcent
+        Number const error = abs(vaultGain - decimalReference);
+        BEAST_EXPECTS(
+            error < tolerance,
+            "vault gain " + to_string(vaultGain) + " differs from Decimal reference " +
+                to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " +
+                to_string(tolerance));
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testIssuerLoan();
+        testBorrowerIsBroker();
+        testFullLifecycleVaultPnLNearZeroRate();
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testLifecycle(features);
+        testSelfLoan(features);
+        testIssuerIsBorrower(features);
+        testBatchBypassCounterparty(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp
new file mode 100644
index 0000000000..7c4db3e2bf
--- /dev/null
+++ b/src/test/app/lending/LoanMisc_test.cpp
@@ -0,0 +1,573 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 LoanMisc_test : public LoanTestBase
+{
+private:
+    void
+    testRPC(FeatureBitset features)
+    {
+        // This will expand as more test cases are added. Some functionality
+        // is tested in other test functions.
+        testcase("RPC");
+
+        using namespace jtx;
+
+        Env env(*this, features);
+
+        auto lowerFee = [&]() {
+            // Run the local fee back down.
+            while (env.app().getFeeTrack().lowerLocalFee())
+                ;
+        };
+
+        auto const baseFee = env.current()->fees().base;
+
+        Account const alice{"alice"};
+        std::string const borrowerPass = "borrower";
+        Account const borrower{borrowerPass, KeyType::Ed25519};
+        auto const lenderPass = "lender";
+        Account const lender{lenderPass, KeyType::Ed25519};
+
+        env.fund(XRP(1'000'000), alice, lender, borrower);
+        env.close();
+        env(noop(lender));
+        env(noop(lender));
+        env(noop(lender));
+        env(noop(lender));
+        env(noop(lender));
+        env.close();
+
+        {
+            testcase("RPC AccountSet");
+            json::Value txJson{json::ValueType::Object};
+            txJson[sfTransactionType] = "AccountSet";
+            txJson[sfAccount] = borrower.human();
+
+            auto const signParams = [&]() {
+                json::Value signParams{json::ValueType::Object};
+                signParams[jss::passphrase] = borrowerPass;
+                signParams[jss::key_type] = "ed25519";
+                signParams[jss::tx_json] = txJson;
+                return signParams;
+            }();
+            auto const jSign = env.rpc("json", "sign", to_string(signParams));
+            BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json));
+            auto txSignResult = jSign[jss::result][jss::tx_json];
+            auto txSignBlob = jSign[jss::result][jss::tx_blob].asString();
+            txSignResult.removeMember(jss::hash);
+
+            auto const jtx = env.jt(txJson, Sig(borrower));
+            BEAST_EXPECT(txSignResult == jtx.jv);
+
+            lowerFee();
+            auto const jSubmit = env.rpc("submit", txSignBlob);
+            BEAST_EXPECT(
+                jSubmit.isMember(jss::result) &&
+                jSubmit[jss::result].isMember(jss::engine_result) &&
+                jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS");
+
+            lowerFee();
+            env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ));
+        }
+
+        {
+            testcase("RPC LoanSet - illegal signature_target");
+
+            json::Value txJson{json::ValueType::Object};
+            txJson[sfTransactionType] = "AccountSet";
+            txJson[sfAccount] = borrower.human();
+
+            // "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");
+            // 1. Borrower creates the transaction
+            json::Value txJson{json::ValueType::Object};
+            txJson[sfTransactionType] = "LoanSet";
+            txJson[sfAccount] = borrower.human();
+            txJson[sfCounterparty] = lender.human();
+            txJson[sfLoanBrokerID] =
+                "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
+                "F83F"
+                "5C";
+            txJson[sfPrincipalRequested] = "100000000";
+            txJson[sfPaymentTotal] = 10000;
+            txJson[sfPaymentInterval] = 3600;
+            txJson[sfGracePeriod] = 300;
+            txJson[sfFlags] = 65536;  // tfLoanOverpayment
+            txJson[sfFee] = to_string(24 * baseFee / 10);
+
+            // 2. Borrower signs the transaction
+            auto const borrowerSignParams = [&]() {
+                json::Value params{json::ValueType::Object};
+                params[jss::passphrase] = borrowerPass;
+                params[jss::key_type] = "ed25519";
+                params[jss::tx_json] = txJson;
+                return params;
+            }();
+            auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
+            BEAST_EXPECTS(
+                jSignBorrower.isMember(jss::result) &&
+                    jSignBorrower[jss::result].isMember(jss::tx_json),
+                to_string(jSignBorrower));
+            auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
+            auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
+
+            // 2a. Borrower attempts to submit the transaction. It doesn't
+            // work
+            {
+                lowerFee();
+                auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
+                BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
+                auto const jSubmitBlobResult = jSubmitBlob[jss::result];
+                BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
+                // Transaction fails because the CounterpartySignature is
+                // missing
+                BEAST_EXPECT(
+                    jSubmitBlobResult.isMember(jss::engine_result) &&
+                    jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
+            }
+
+            // 3. Borrower sends the signed transaction to the lender
+            // 4. Lender signs the transaction
+            auto const lenderSignParams = [&]() {
+                json::Value params{json::ValueType::Object};
+                params[jss::passphrase] = lenderPass;
+                params[jss::key_type] = "ed25519";
+                params[jss::signature_target] = "CounterpartySignature";
+                params[jss::tx_json] = txBorrowerSignResult;
+                return params;
+            }();
+            auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
+            BEAST_EXPECT(
+                jSignLender.isMember(jss::result) &&
+                jSignLender[jss::result].isMember(jss::tx_json));
+            auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
+            auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
+
+            // 5. Lender submits the signed transaction blob
+            lowerFee();
+            auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
+            BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
+            auto const jSubmitBlobResult = jSubmitBlob[jss::result];
+            BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
+            auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
+            // To get far enough to return tecNO_ENTRY means that the
+            // signatures all validated. Of course the transaction won't
+            // succeed because no Vault or Broker were created.
+            BEAST_EXPECTS(
+                jSubmitBlobResult.isMember(jss::engine_result) &&
+                    jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
+                to_string(jSubmitBlobResult));
+
+            BEAST_EXPECT(
+                !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
+
+            // 4-alt. Lender submits the transaction json originally
+            // received from the Borrower. It gets signed, but is now a
+            // duplicate, so fails. Borrower could done this instead of
+            // steps 4 and 5.
+            lowerFee();
+            auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams));
+            BEAST_EXPECT(jSubmitJson.isMember(jss::result));
+            auto const jSubmitJsonResult = jSubmitJson[jss::result];
+            BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
+            auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
+            // Since the previous tx claimed a fee, this duplicate is not
+            // going anywhere
+            BEAST_EXPECTS(
+                jSubmitJsonResult.isMember(jss::engine_result) &&
+                    jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
+                to_string(jSubmitJsonResult));
+
+            BEAST_EXPECT(
+                !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
+
+            BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
+        }
+
+        {
+            testcase("RPC LoanSet - sign and submit lender initiated");
+            // 1. Lender creates the transaction
+            json::Value txJson{json::ValueType::Object};
+            txJson[sfTransactionType] = "LoanSet";
+            txJson[sfAccount] = lender.human();
+            txJson[sfCounterparty] = borrower.human();
+            txJson[sfLoanBrokerID] =
+                "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC"
+                "F83F"
+                "5C";
+            txJson[sfPrincipalRequested] = "100000000";
+            txJson[sfPaymentTotal] = 10000;
+            txJson[sfPaymentInterval] = 3600;
+            txJson[sfGracePeriod] = 300;
+            txJson[sfFlags] = 65536;  // tfLoanOverpayment
+            txJson[sfFee] = to_string(24 * baseFee / 10);
+
+            // 2. Lender signs the transaction
+            auto const lenderSignParams = [&]() {
+                json::Value params{json::ValueType::Object};
+                params[jss::passphrase] = lenderPass;
+                params[jss::key_type] = "ed25519";
+                params[jss::tx_json] = txJson;
+                return params;
+            }();
+            auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams));
+            BEAST_EXPECT(
+                jSignLender.isMember(jss::result) &&
+                jSignLender[jss::result].isMember(jss::tx_json));
+            auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json];
+            auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString();
+
+            // 2a. Lender attempts to submit the transaction. It doesn't
+            // work
+            {
+                lowerFee();
+                auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob);
+                BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
+                auto const jSubmitBlobResult = jSubmitBlob[jss::result];
+                BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
+                // Transaction fails because the CounterpartySignature is
+                // missing
+                BEAST_EXPECT(
+                    jSubmitBlobResult.isMember(jss::engine_result) &&
+                    jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER");
+            }
+
+            // 3. Lender sends the signed transaction to the Borrower
+            // 4. Borrower signs the transaction
+            auto const borrowerSignParams = [&]() {
+                json::Value params{json::ValueType::Object};
+                params[jss::passphrase] = borrowerPass;
+                params[jss::key_type] = "ed25519";
+                params[jss::signature_target] = "CounterpartySignature";
+                params[jss::tx_json] = txLenderSignResult;
+                return params;
+            }();
+            auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams));
+            BEAST_EXPECT(
+                jSignBorrower.isMember(jss::result) &&
+                jSignBorrower[jss::result].isMember(jss::tx_json));
+            auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json];
+            auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString();
+
+            // 5. Borrower submits the signed transaction blob
+            lowerFee();
+            auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob);
+            BEAST_EXPECT(jSubmitBlob.isMember(jss::result));
+            auto const jSubmitBlobResult = jSubmitBlob[jss::result];
+            BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json));
+            auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json];
+            // To get far enough to return tecNO_ENTRY means that the
+            // signatures all validated. Of course the transaction won't
+            // succeed because no Vault or Broker were created.
+            BEAST_EXPECTS(
+                jSubmitBlobResult.isMember(jss::engine_result) &&
+                    jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY",
+                to_string(jSubmitBlobResult));
+
+            BEAST_EXPECT(
+                !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error));
+
+            // 4-alt. Borrower submits the transaction json originally
+            // received from the Lender. It gets signed, but is now a
+            // duplicate, so fails. Lender could done this instead of steps
+            // 4 and 5.
+            lowerFee();
+            auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams));
+            BEAST_EXPECT(jSubmitJson.isMember(jss::result));
+            auto const jSubmitJsonResult = jSubmitJson[jss::result];
+            BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json));
+            auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json];
+            // Since the previous tx claimed a fee, this duplicate is not
+            // going anywhere
+            BEAST_EXPECTS(
+                jSubmitJsonResult.isMember(jss::engine_result) &&
+                    jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ",
+                to_string(jSubmitJsonResult));
+
+            BEAST_EXPECT(
+                !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error));
+
+            BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx);
+        }
+    }
+
+    void
+    testLendingCanTradeDisabledNoImpact()
+    {
+        testcase("Lending: CanTrade disabled has no impact");
+        using namespace jtx;
+        using namespace loan;
+        using namespace loan_broker;
+
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mpt(
+            {.env = env,
+             .issuer = issuer,
+             .holders = {lender, borrower},
+             .flags = tfMPTCanTransfer | tfMPTCanLock});
+        PrettyAsset const asset = mpt.issuanceID();
+        env(pay(issuer, lender, asset(10'000'000)));
+        env(pay(issuer, borrower, asset(100'000)));
+        env.close();
+
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        // CanTrade is not set
+        env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
+        env.close();
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        // New cover deposits still work.
+        env(coverDeposit(lender, broker.brokerID, asset(100)));
+        env.close();
+
+        // New loan issuance still works.
+        env(loan::set(borrower, broker.brokerID, 1'000),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee);
+        env.close();
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(1));
+        BEAST_EXPECT(env.le(loanKeylet));
+
+        // Repayment still works.
+        env(pay(borrower, loanKeylet.key, asset(1'000)));
+        env.close();
+
+        // Cover withdrawal still works.
+        env(coverWithdraw(lender, broker.brokerID, asset(100)));
+        env.close();
+
+        // Enable CanTrade and verify the DEX path is restored.
+        mpt.set({.flags = tfMPTSetCanTrade});
+        env.close();
+
+        env(offer(lender, XRP(1), asset(10)));
+        env.close();
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testLendingCanTradeDisabledNoImpact();
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testRPC(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+class LoanBatch_test : public LoanTestBase
+{
+protected:
+    beast::xor_shift_engine engine_;
+
+    std::uniform_int_distribution<> assetDist_{0, 2};
+    std::uniform_int_distribution principalDist_{100'000, 1'000'000'000};
+    std::uniform_int_distribution interestRateDist_{0, 10000};
+    std::uniform_int_distribution<> paymentTotalDist_{12, 10000};
+    std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30};
+    std::uniform_int_distribution managementFeeRateDist_{0, 10'000};
+    std::uniform_int_distribution<> serviceFeeDist_{0, 20};
+    /*
+        # Generate parameters that are more likely to be valid
+    principal = Decimal(str(rand.randint(100000,
+   100'000'000))).quantize(ROUND_TARGET)
+
+    interest_rate = Decimal(rand.randint(1, 10000)) /
+   Decimal(100000)
+
+    payment_total = rand.randint(12, 10000)
+
+    payment_interval = Decimal(str(rand.randint(60, 2629746)))
+
+    interest_fee = Decimal(rand.randint(0, 100000)) /
+   Decimal(100000)
+*/
+
+    void
+    testRandomLoan()
+    {
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        // Determine all the random parameters at once
+        auto const assetType = static_cast(assetDist_(engine_));
+        auto const principalRequest = principalDist_(engine_);
+        TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)};
+        auto const serviceFee = serviceFeeDist_(engine_);
+        TenthBips32 interest{interestRateDist_(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,
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = principalRequest,
+            .serviceFee = serviceFee,
+            .interest = interest,
+            .payTotal = payTotal,
+            .payInterval = payInterval,
+        };
+
+        runLoan(assetType, brokerParams, loanParams, all_);
+    }
+
+public:
+    void
+    run() override
+    {
+        auto const numIterations = [s = arg()]() -> int {
+            int const defaultNum = 5;
+            if (s.empty())
+                return defaultNum;
+            try
+            {
+                std::size_t pos = 0;
+                auto const r = stoi(s, &pos);
+                if (pos != s.size())
+                    return defaultNum;
+                return r;
+            }
+            catch (...)
+            {
+                return defaultNum;
+            }
+        }();
+
+        using namespace jtx;
+
+        auto const updateInterval = std::max(std::min(numIterations / 5, 100), 1);
+
+        for (int i = 0; i < numIterations; ++i)
+        {
+            if (i % updateInterval == 0)
+                testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations;
+            testRandomLoan();
+        }
+    }
+};
+
+class LoanArbitrary_test : public LoanBatch_test
+{
+    void
+    run() override
+    {
+        using namespace jtx;
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 10000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = Account("lender"),
+            .counter = Account("borrower"),
+            .principalRequest = Number{200000, -6},
+            .interest = TenthBips32{50000},
+            .payTotal = 2,
+            .payInterval = 200};
+
+        runLoan(AssetType::XRP, brokerParams, loanParams, all_);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanMisc, tx, xrpl);
+BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl);
+BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp
new file mode 100644
index 0000000000..7cd31b7988
--- /dev/null
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -0,0 +1,1525 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#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 LoanPay_test : public LoanTestBase
+{
+private:
+#if LOAN_TODO
+    void
+    testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features)
+    {
+        testcase("LoanPay full payment skips late penalties");
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        PrettyAsset const asset = issuer[iouCurrency];
+        env(trust(lender, asset(100'000'000)));
+        env(trust(borrower, asset(100'000'000)));
+        env(pay(issuer, lender, asset(50'000'000)));
+        env(pay(issuer, borrower, asset(5'000'000)));
+        env.close();
+
+        BrokerInfo broker{createVaultAndBroker(env, asset, lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID));
+        if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value())
+            return;
+
+        auto const loanSequence = brokerPreLoan->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        Number const principal = asset(1'000).value();
+        Number const serviceFee = asset(2).value();
+        Number const lateFee = asset(5).value();
+        Number const closeFee = asset(4).value();
+
+        env(set(borrower, broker.brokerID, principal),
+            Sig(sfCounterpartySignature, lender),
+            kLoanServiceFee(serviceFee),
+            kLatePaymentFee(lateFee),
+            kClosePaymentFee(closeFee),
+            kInterestRate(percentageToTenthBips(12)),
+            kLateInterestRate(percentageToTenthBips(24) / 10),
+            kCloseInterestRate(percentageToTenthBips(5)),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(0),
+            Fee(loanSetFee));
+        env.close();
+
+        auto state1 = getCurrentState(env, broker, loanKeylet);
+        if (!BEAST_EXPECT(state1.paymentRemaining > 1))
+            return;
+
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+        auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}};
+        env.close(overdueClose);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(brokerSle && loanSle))
+            return;
+
+        auto state = getCurrentState(env, broker, loanKeylet);
+
+        TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
+        TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)};
+        TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)};
+        TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)};
+
+        Number const closePaymentFeeRounded =
+            roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale);
+        Number const latePaymentFeeRounded =
+            roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale);
+
+        auto const roundedLoanState = constructLoanState(
+            state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
+        Number const totalInterestOutstanding = roundedLoanState.interestDue;
+
+        auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval);
+        auto const rawLoanState = computeTheoreticalLoanState(
+            env.current()->rules(),
+            state.periodicPayment,
+            periodicRate,
+            state.paymentRemaining,
+            managementFeeRate);
+
+        auto const parentCloseTime = env.current()->parentCloseTime();
+        auto const startDateSeconds =
+            static_cast(state.startDate.time_since_epoch().count());
+
+        Number const fullPaymentInterest = computeFullPaymentInterest(
+            rawLoanState.principalOutstanding,
+            periodicRate,
+            parentCloseTime,
+            state.paymentInterval,
+            state.previousPaymentDate,
+            startDateSeconds,
+            closeInterestRateValue);
+
+        Number const roundedFullInterestAmount =
+            roundToAsset(broker.asset, fullPaymentInterest, state.loanScale);
+        Number const roundedFullManagementFee = computeManagementFee(
+            broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale);
+        Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee;
+
+        Number const trackedValueDelta =
+            state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding;
+        Number const untrackedManagementFee =
+            closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding;
+        Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding;
+
+        Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee;
+        BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale));
+
+        auto const overdueSeconds =
+            parentCloseTime.time_since_epoch().count() - state.nextPaymentDate;
+        if (!BEAST_EXPECT(overdueSeconds > 0))
+            return;
+
+        Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds);
+        Number const lateInterestRaw = state.principalOutstanding * overdueRate;
+        Number const lateInterestRounded =
+            roundToAsset(broker.asset, lateInterestRaw, state.loanScale);
+        Number const lateManagementFeeRounded = computeManagementFee(
+            broker.asset, lateInterestRounded, managementFeeRate, state.loanScale);
+        Number const penaltyDue =
+            lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded;
+        BEAST_EXPECT(penaltyDue > Number{});
+
+        auto const balanceBefore = env.balance(borrower, broker.asset).number();
+
+        STAmount const paymentAmount{broker.asset.raw(), baseFullDue};
+        env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment));
+        env.close();
+
+        if (auto const meta = env.meta(); BEAST_EXPECT(meta))
+            BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
+
+        auto const balanceAfter = env.balance(borrower, broker.asset).number();
+        Number const actualPaid = balanceBefore - balanceAfter;
+        BEAST_EXPECT(actualPaid == baseFullDue);
+
+        Number const expectedWithPenalty = baseFullDue + penaltyDue;
+        BEAST_EXPECT(expectedWithPenalty > actualPaid);
+        BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue);
+    }
+#endif
+
+    void
+    testOverpaymentManagementFee(FeatureBitset features)
+    {
+        testcase("testOverpaymentManagementFee");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env{*this, features};
+
+        Account const lender{"lender"}, borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+
+        auto const result = createVaultAndBroker(
+            env,
+            asset,
+            lender,
+            {
+                .vaultDeposit = asset(100'000).value(),
+                .managementFeeRate = TenthBips16(10'000),
+            });
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        auto const brokerSle = env.le(result.brokerKeylet());
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet = keylet::loan(
+            result.brokerKeylet().key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+        env(loan::set(
+                borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment),
+            Sig(sfCounterpartySignature, lender),
+            loan::kPaymentInterval(86400 * 30),
+            loan::kPaymentTotal(3),
+            loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
+            loanSetFee);
+
+        // From calculator
+        auto const expectedOverpaymentManagementFee = Number{33333, 0};
+        auto const loanBrokerBalanceBefore = env.balance(lender);
+
+        auto const loanPayFee = Fee(env.current()->fees().base * 2);
+        env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee);
+        env.close();
+
+        BEAST_EXPECTS(
+            env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee,
+            "overpayment management fee mismatch; expected:" +
+                to_string(expectedOverpaymentManagementFee) +
+                " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore));
+    }
+
+    void
+    testDosLoanPay(FeatureBitset features)
+    {
+        bool const feeCapped = features[fixCleanup3_1_3];
+
+        // From FIND-005
+        testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped");
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        using namespace lending;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3));
+
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        env(trust(lender, iouAsset(100'000'000)));
+        env(trust(borrower, iouAsset(100'000'000)));
+        env(pay(issuer, lender, iouAsset(10'000'000)));
+        env(pay(issuer, borrower, iouAsset(1'000)));
+        env.close();
+
+        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{3959'37, -2};
+        auto const baseFee = env.current()->fees().base;
+
+        auto const createJson = env.json(
+            set(borrower, broker.brokerID, principalRequest),
+            Fee(loanSetFee),
+            Json(sfCounterpartySignature, json::ValueType::Object),
+            kClosePaymentFee(0),
+            kGracePeriod(60),
+            kInterestRate(TenthBips32(20930)),
+            kLateInterestRate(TenthBips32(77049)),
+            kLatePaymentFee(0),
+            kLoanServiceFee(0),
+            kOverpaymentFee(TenthBips32(7)),
+            kOverpaymentInterestRate(TenthBips32(66653)),
+            kPaymentInterval(60),
+            kPaymentTotal(3239184));
+
+        // There are enough payments due on this loan that it only needs to be
+        // created once, and can be paid on multiple times. Just don't create a
+        // gazillion test cases.
+        auto const keylet = nextLoanKeylet(env, broker);
+
+        env(createJson, Sig(sfCounterpartySignature, lender));
+        env.close();
+
+        auto const roundedPayment = [&]() {
+            auto const stateBefore = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(stateBefore.paymentRemaining == 3239184);
+            BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
+
+            return roundToAsset(
+                iouAsset,
+                stateBefore.periodicPayment,
+                stateBefore.loanScale,
+                Number::RoundingMode::Upward);
+        }();
+
+        auto test = [&](int const payFactor,
+                        int const feeFactor,
+                        TER const expectedTer = tesSUCCESS) {
+            auto const stateBefore = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184);
+            BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction);
+
+            Number const amount = roundedPayment * payFactor;
+            auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount}));
+            XRPAmount const payFee{baseFee * feeFactor};
+            env(loanPayTx, Ter(expectedTer), Fee(payFee));
+            env.close();
+            auto const expectedChange = isTesSuccess(expectedTer)
+                ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor)
+                : 0;
+
+            auto const stateAfter = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(
+                stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange);
+        };
+
+        static constexpr std::int64_t kMaxFeeIncrements =
+            kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement;
+
+        TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P;
+
+        // * Amount well above threshold -> capped fee
+        // The original test case - way over the limit - more fee is always ok
+        test(1819878, 363976);
+        // The capped fee is only sufficient if the amendment is enabled.
+        test(1819878, kMaxFeeIncrements, failWithoutFix);
+
+        // * Amount exactly at threshold -> capped fee
+        test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements);
+        // More fee is always ok
+        test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10);
+
+        // * Amount below threshold -> normal calculation
+        test(1, 1);
+        test(kLoanPaymentsPerFeeIncrement * 2, 2);
+        test(0, 0, temBAD_AMOUNT);
+        test(0, 1, temBAD_AMOUNT);
+        // Fee difference rounds evenly
+        test(
+            kLoanMaximumPaymentsPerTransaction - 10,
+            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1,
+            telINSUF_FEE_P);
+        test(
+            kLoanMaximumPaymentsPerTransaction - 10,
+            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement));
+        // More fee is always ok
+        test(
+            kLoanMaximumPaymentsPerTransaction - 10,
+            ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3);
+        // Fee rounds up
+        for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under)
+        {
+            test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P);
+            test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements);
+        }
+        // Only when you get one less fee increment can you pay less
+        test(
+            kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement,
+            kMaxFeeIncrements - 1);
+        // And again, more fee is always ok.
+        test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements);
+    }
+
+    // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero
+    // rate). At such a near-zero rate the closed-form payment factor
+    // (1 + r)^n - 1 cancels catastrophically.
+    //
+    // Without fixCleanup3_2_0 the resulting amortization is degenerate and the
+    // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the
+    // amendment, computePowerMinusOneHybrid uses a numerically-stable series
+    // expansion, so the loan is created and the scheduled payments
+    // (2 * periodicPayment) cover the principal — no economic underpayment
+    // (yield theft).
+    //
+    // The test runs the same LoanSet under both amendment settings and pins the
+    // exact outcome for each.
+    void
+    testLoanSetNearZeroInterestRateSucceeds()
+    {
+        testcase("LoanSet near-zero interest rate covers principal");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Number const principalRequested{1000};
+
+        struct Result
+        {
+            TER ter = tesSUCCESS;
+            bool created = false;
+            std::int32_t loanScale = 0;
+            Number principal;
+            Number totalValue;
+            Number managementFee;
+            Number periodicPayment;
+        };
+
+        auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"vaultOwner"};
+            Account const borrower{"borrower"};
+
+            PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
+
+            auto const broker = createVaultAndBroker(
+                env,
+                iouAsset,
+                lender,
+                {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}});
+
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerSle);
+            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            env(set(borrower, broker.brokerID, principalRequested),
+                Sig(sfCounterpartySignature, lender),
+                kInterestRate(TenthBips32{1}),
+                kPaymentTotal(2),
+                kPaymentInterval(400),
+                Fee(env.current()->fees().base * 2),
+                Ter(expectedTer));
+            env.close();
+
+            Result r;
+            r.ter = env.ter();
+            if (auto const loanSle = env.le(loanKeylet))
+            {
+                r.created = true;
+                r.loanScale = loanSle->at(sfLoanScale);
+                r.principal = loanSle->at(sfPrincipalOutstanding);
+                r.totalValue = loanSle->at(sfTotalValueOutstanding);
+                r.managementFee = loanSle->at(sfManagementFeeOutstanding);
+                r.periodicPayment = loanSle->at(sfPeriodicPayment);
+            }
+            return r;
+        };
+
+        Result const fixed = runScenario(all_, tesSUCCESS);
+        Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS);
+
+        // Without the amendment, the catastrophically-cancelling closed-form
+        // payment factor produces a degenerate amortization that fails
+        // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no
+        // loan is created.
+        BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS);
+        BEAST_EXPECT(!legacy.created);
+
+        // With the amendment the stable series expansion produces a valid loan
+        // at loanScale -10.
+        BEAST_EXPECT(fixed.ter == tesSUCCESS);
+        BEAST_EXPECT(fixed.created);
+        BEAST_EXPECT(fixed.loanScale == -10);
+        BEAST_EXPECT(fixed.principal == principalRequested);
+        BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10}));
+        BEAST_EXPECT(fixed.managementFee == beast::kZero);
+
+        // Periodic payment from the numerically-stable series expansion, and the
+        // scheduled total (2 * periodicPayment) which exceeds the 1000 principal
+        // — no economic underpayment / yield theft.
+        BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16}));
+        BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15}));
+        BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested);
+    }
+
+    void
+    testLoanNextPaymentDueDateOverflow(FeatureBitset features)
+    {
+        // For FIND-013
+        testcase << "Prevent nextPaymentDueDate overflow";
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        using namespace lending;
+        Env env{*this, features};
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        PrettyAsset const iouAsset =
+            createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000);
+
+        BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}};
+        BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        using timeType = decltype(sfNextPaymentDueDate)::type::value_type;
+        static_assert(std::is_same_v);
+        constexpr timeType kMaxTime = std::numeric_limits::max();
+        static_assert(kMaxTime == 4'294'967'295);
+
+        auto const baseJson = [&]() {
+            auto createJson = env.json(
+                set(borrower, broker.brokerID, Number{55524'81, -2}),
+                Fee(loanSetFee),
+                kClosePaymentFee(0),
+                kGracePeriod(LoanSet::kDefaultGracePeriod),
+                kInterestRate(TenthBips32(12833)),
+                kLateInterestRate(TenthBips32(77048)),
+                kLatePaymentFee(0),
+                kLoanOriginationFee(218),
+                Json(sfCounterpartySignature, json::ValueType::Object));
+
+            createJson.removeMember(sfSequence.getJsonName());
+
+            return createJson;
+        }();
+
+        auto const baseFee = env.current()->fees().base;
+
+        auto parentCloseTime = [&]() {
+            return env.current()->parentCloseTime().time_since_epoch().count();
+        };
+        auto maxLoanTime = [&]() {
+            auto const startDate = parentCloseTime();
+
+            BEAST_EXPECT(startDate >= 50);
+
+            return kMaxTime - startDate;
+        };
+
+        {
+            // straight-up overflow: interval
+            auto const interval = maxLoanTime() + 1;
+            auto const total = 1;
+            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // straight-up overflow: total
+            // min interval is 60
+            auto const interval = 60;
+            auto const total = maxLoanTime() + 1;
+            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // straight-up overflow: grace period
+            // min interval is 60
+            auto const interval = maxLoanTime() + 1;
+            auto const total = 1;
+            auto const grace = interval;
+            auto createJson = env.json(
+                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
+
+            // The grace period can't be larger than the interval.
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // Overflow with multiplication of a few large intervals
+            auto const interval = 1'000'000'000;
+            auto const total = 10;
+            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // Overflow with multiplication of many small payments
+            // min interval is 60
+            auto const interval = 60;
+            auto const total = 1'000'000'000;
+            auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // Overflow with an absurdly large grace period
+            // min interval is 60
+            auto const total = 60;
+            auto const interval = (maxLoanTime() - total) / total;
+            auto const grace = interval;
+            auto createJson = env.json(
+                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED));
+            env.close();
+        }
+        {
+            // Start date when the ledger is closed will be larger
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            auto const grace = 100;
+            auto const interval = maxLoanTime() - grace;
+            auto const total = 1;
+            auto createJson = env.json(
+                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
+            env.close();
+
+            // The transaction is killed in the closed ledger
+            auto const meta = env.meta();
+            if (BEAST_EXPECT(meta))
+            {
+                BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED);
+            }
+
+            // If the transaction had succeeded, the loan would exist
+            auto const loanSle = env.le(keylet);
+            // but it doesn't
+            BEAST_EXPECT(!loanSle);
+        }
+        {
+            // Start date when the ledger is closed will be larger
+            auto const keylet = nextLoanKeylet(env, broker);
+
+            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
+            auto const grace = 5'000;
+            auto const interval = kMaxTime - closeStartDate - grace;
+            auto const total = 1;
+            auto createJson = env.json(
+                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
+            env.close();
+
+            // The transaction succeeds in the closed ledger
+            auto const meta = env.meta();
+            if (BEAST_EXPECT(meta))
+            {
+                BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS);
+            }
+
+            // This loan exists
+            auto const afterState = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
+            BEAST_EXPECT(afterState.previousPaymentDate == 0);
+            BEAST_EXPECT(afterState.paymentRemaining == 1);
+        }
+
+        {
+            // Ensure the borrower has funds to pay back the loan
+            env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2})));
+
+            // Start date when the ledger is closed will be larger
+            auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10;
+            auto const grace = 5'000;
+            auto const maxLoanTime = kMaxTime - closeStartDate - grace;
+            auto const total = [&]() {
+                if (maxLoanTime % 5 == 0)
+                    return 5;
+                if (maxLoanTime % 3 == 0)
+                    return 3;
+                if (maxLoanTime % 2 == 0)
+                    return 2;
+                return 0;
+            }();
+            if (!BEAST_EXPECT(total != 0))
+                return;
+
+            auto const brokerState = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerState))
+                return;
+            // Intentionally shadow the outer values
+            auto const loanSequence = brokerState->at(sfLoanSequence);
+            auto const keylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            auto const interval = maxLoanTime / total;
+            auto createJson = env.json(
+                baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace));
+
+            env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS));
+            env.close();
+
+            // This loan exists
+            auto const beforeState = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval);
+            BEAST_EXPECT(beforeState.previousPaymentDate == 0);
+            BEAST_EXPECT(beforeState.paymentRemaining == total);
+            BEAST_EXPECT(beforeState.periodicPayment > 0);
+
+            // pay all but the last payment
+            {
+                NumberRoundModeGuard const mg{Number::RoundingMode::Upward};
+                Number const payment = beforeState.periodicPayment * (total - 1);
+                XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)};
+                STAmount const paymentAmount =
+                    roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale);
+                auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee));
+                env(loanPayTx, Ter(tesSUCCESS));
+                env.close();
+            }
+
+            // The loan is on the last payment
+            auto const afterState = getCurrentState(env, broker, keylet);
+            BEAST_EXPECT(afterState.paymentRemaining == 1);
+            BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace);
+            BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval);
+        }
+    }
+
+    // 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().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+#if LOAN_TODO
+        testLoanPayLateFullPaymentBypassesPenalties(features);
+#endif
+        testLoanPayFundsConservedPayeeBelowReserve(features);
+        testOverpaymentManagementFee(features);
+        testDosLoanPay(features);
+        testLoanNextPaymentDueDateOverflow(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanPay, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
new file mode 100644
index 0000000000..4a2063ed77
--- /dev/null
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -0,0 +1,1255 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#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 LoanRounding_test : public LoanTestBase
+{
+private:
+    void
+    testDustManipulation(FeatureBitset features)
+    {
+        testcase("Dust manipulation");
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        Env env{*this, features};
+
+        // Setup: Create accounts
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+        Account const victim{"victim"};
+
+        env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim);
+        env.close();
+
+        // Step 1: Create vault with IOU asset
+        auto asset = issuer["USD"];
+        env(trust(lender, asset(100000)));
+        env(trust(borrower, asset(100000)));
+        env(trust(victim, asset(100000)));
+        env(pay(issuer, lender, asset(50000)));
+        env(pay(issuer, borrower, asset(50000)));
+        env(pay(issuer, victim, asset(50000)));
+        env.close();
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 10000,
+            .debtMax = Number{0},
+            .coverRateMin = TenthBips32{1000},
+            .coverRateLiquidation = TenthBips32{2500}};
+
+        auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
+
+        auto const loanKeyletOpt = [&]() -> std::optional {
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return std::nullopt;
+
+            // Broker has no loans
+            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
+
+            // The loan keylet is based on the LoanSequence of the
+            // _LOAN_BROKER_ object.
+            auto const loanSequence = brokerSle->at(sfLoanSequence);
+            return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+        }();
+        if (!loanKeyletOpt)
+            return;
+
+        auto const& vaultKeylet = broker.vaultKeylet();
+
+        {
+            auto const vaultSle = env.le(vaultKeylet);
+            Number const assetsTotal = vaultSle->at(sfAssetsTotal);
+            Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
+
+            log << "Before loan creation:" << std::endl;
+            log << "  AssetsTotal: " << assetsTotal << std::endl;
+            log << "  AssetsAvailable: " << assetsAvail << std::endl;
+            log << "  Difference: " << (assetsTotal - assetsAvail) << std::endl;
+
+            // before the loan the assets total and available should be equal
+            BEAST_EXPECT(assetsAvail == assetsTotal);
+            BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number());
+        }
+
+        Keylet const& loanKeylet = *loanKeyletOpt;
+
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = Number{100},
+            .interest = TenthBips32{1922},
+            .payTotal = 5816,
+            .payInterval = 86400 * 6,
+            .gracePd = 86400 * 5,
+        };
+
+        env(loanParams(env, broker));
+        env.close();
+
+        // Wait for loan to be late enough to default
+        env.close(std::chrono::seconds(86400 * 40));  // 40 days
+
+        {
+            auto const vaultSle = env.le(vaultKeylet);
+            Number const assetsTotal = vaultSle->at(sfAssetsTotal);
+            Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
+
+            log << "After loan creation:" << std::endl;
+            log << "  AssetsTotal: " << assetsTotal << std::endl;
+            log << "  AssetsAvailable: " << assetsAvail << std::endl;
+            log << "  Difference: " << (assetsTotal - assetsAvail) << std::endl;
+
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            auto const state = constructLoanState(loanSle);
+
+            log << "Loan state:" << std::endl;
+            log << "  ValueOutstanding: " << state.valueOutstanding << std::endl;
+            log << "  PrincipalOutstanding: " << state.principalOutstanding << std::endl;
+            log << "  InterestOutstanding: " << state.interestOutstanding() << std::endl;
+            log << "  InterestDue: " << state.interestDue << std::endl;
+            log << "  FeeDue: " << state.managementFeeDue << std::endl;
+
+            // after loan creation the assets total and available should
+            // reflect the value of the loan
+            BEAST_EXPECT(assetsAvail < assetsTotal);
+            BEAST_EXPECT(
+                assetsAvail ==
+                broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number());
+            BEAST_EXPECT(
+                assetsTotal ==
+                broker.asset(brokerParams.vaultDeposit + state.interestDue).number());
+        }
+
+        // Step 7: Trigger default (dust adjustment will occur)
+        env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault));
+        env.close();
+
+        // Step 8: Verify phantom assets created
+        {
+            auto const vaultSle2 = env.le(vaultKeylet);
+            Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal);
+            Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable);
+
+            log << "After default:" << std::endl;
+            log << "  AssetsTotal: " << assetsTotal2 << std::endl;
+            log << "  AssetsAvailable: " << assetsAvail2 << std::endl;
+            log << "  Difference: " << (assetsTotal2 - assetsAvail2) << std::endl;
+
+            // after a default the assets total and available should be equal
+            BEAST_EXPECT(assetsAvail2 == assetsTotal2);
+        }
+    }
+
+    void
+    testRoundingAllowsUndercoverage(FeatureBitset features)
+    {
+        testcase("Minimum cover rounding allows undercoverage (XRP)");
+
+        using namespace jtx;
+        using namespace loan_broker;
+
+        Env env{*this, features};
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(200'000), lender, borrower);
+        env.close();
+
+        // Vault with XRP asset
+        Vault const vault{env};
+        auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        env(vaultCreate);
+        env.close();
+        BEAST_EXPECT(env.le(vaultKeylet));
+
+        // Seed the vault with XRP so it can fund the loan principal
+        PrettyAsset const xrpAsset{xrpIssue(), 1};
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000,
+            .debtMax = Number{0},
+            .coverRateMin = TenthBips32{10'000},
+            .coverDeposit = 82,
+        };
+
+        auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams);
+        // Create a loan with principal 804 XRP and 0% interest (so
+        // DebtTotal increases by exactly 804)
+        env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 2));
+        BEAST_EXPECT(env.ter() == tesSUCCESS);
+        env.close();
+
+        // Verify DebtTotal is exactly 804
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            log << *brokerSle << std::endl;
+            BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
+        }
+
+        // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
+        // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
+        env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
+            Ter(tecINSUFFICIENT_FUNDS));
+        BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
+        env.close();
+
+        // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
+        // because that leaves sufficient cover, this succeeds
+        env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
+        BEAST_EXPECT(env.ter() == tesSUCCESS);
+        env.close();
+
+        // Validate CoverAvailable == 81 XRP and DebtTotal remains 804
+        if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            log << *brokerSle << std::endl;
+            BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
+            BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
+
+            // Also demonstrate that the true minimum (804 * 10%) exceeds 80
+            auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000));
+            log << "Theoretical min cover: " << theoreticalMin << std::endl;
+            BEAST_EXPECT(Number(804, -1) == theoreticalMin);
+        }
+    }
+
+    void
+    testYieldTheftRounding(std::uint32_t flags)
+    {
+        testcase("Rounding manipulation does not permit yield theft");
+        using namespace jtx;
+        using namespace loan;
+
+        // 1. Setup Environment
+        Env env(*this, all_);
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1000), issuer, lender, borrower);
+        env.close();
+
+        // 2. Asset Selection
+        PrettyAsset const iou = issuer["USD"];
+        env(trust(lender, iou(100'000'000)));
+        env(trust(borrower, iou(100'000'000)));
+        env(pay(issuer, lender, iou(100'000'000)));
+        env(pay(issuer, borrower, iou(100'000'000)));
+        env.close();
+
+        // 3. Create Vault and Broker with High Debt Limit (100M)
+        auto const brokerInfo = createVaultAndBroker(
+            env,
+            iou,
+            lender,
+            {
+                .vaultDeposit = 5'000'000,
+                .debtMax = Number{100'000'000},
+                .coverDeposit = 500'000,
+            });
+        auto const [currentSeq, vaultKeylet] = [&]() {
+            auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return std::make_tuple(0u, keylet::unchecked(beast::kZero));
+            auto const currentSeq = brokerSle->at(sfLoanSequence);
+            auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
+            return std::make_tuple(currentSeq, vaultKeylet);
+        }();
+
+        // 4. Loan Parameters (Attack Vector)
+        Number const principal = 1'000'000;
+        TenthBips32 const interestRate = TenthBips32{1};  // 0.001%
+        std::uint32_t const paymentInterval = 86400;
+        std::uint32_t const paymentTotal = 3650;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags),
+            Sig(sfCounterpartySignature, lender),
+            loan::kInterestRate(interestRate),
+            loan::kPaymentInterval(paymentInterval),
+            loan::kPaymentTotal(paymentTotal),
+            Fee(loanSetFee));
+        env.close();
+
+        // --- RETRIEVE OBJECTS & SETUP ATTACK ---
+
+        auto borrowerBalance = [&]() { return env.balance(borrower, iou); };
+        auto const borrowerScale = static_cast(borrowerBalance()).exponent();
+
+        auto const loanKeylet =
+            keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(currentSeq));
+        auto const maybePeriodicPayment = [&]() -> std::optional {
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return std::nullopt;
+            // Construct Payment
+            return STAmount{iou, loanSle->at(sfPeriodicPayment)};
+        }();
+        if (!maybePeriodicPayment)
+            return;
+        auto const periodicPayment = *maybePeriodicPayment;
+        auto const roundedPayment =
+            roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward);
+
+        // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution
+        STAmount const paymentBuffer{iou, Number(1, -9)};
+        STAmount const attackPayment = periodicPayment + paymentBuffer;
+
+        auto const maybeInitialVaultAssets = [&]() -> std::optional {
+            auto const vault = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vault))
+                return std::nullopt;
+            return vault->at(sfAssetsTotal);
+        }();
+        if (!maybeInitialVaultAssets)
+            return;
+        auto const initialVaultAssets = *maybeInitialVaultAssets;
+
+        // 5. Execution Loop
+        int yieldTheftCount = 0;
+        auto previousAssetsTotal = initialVaultAssets;
+
+        for (int i = 0; i < 100; ++i)
+        {
+            auto const balanceBefore = borrowerBalance();
+            env(pay(borrower, loanKeylet.key, attackPayment, flags));
+            env.close();
+            auto const borrowerDelta = balanceBefore - borrowerBalance();
+            BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum());
+
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                break;
+            auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)};
+            BEAST_EXPECT(
+                (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) ==
+                 roundedPayment));
+            BEAST_EXPECT(
+                (updatedPayment == periodicPayment) ||
+                (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment));
+
+            auto const currentVaultSle = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(currentVaultSle))
+                break;
+
+            auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal);
+            auto const delta = currentAssetsTotal - previousAssetsTotal;
+
+            BEAST_EXPECT(
+                (delta == beast::kZero && borrowerDelta <= roundedPayment) ||
+                (delta > beast::kZero && borrowerDelta > roundedPayment));
+
+            // If tx succeeded but Assets Total didn't change, interest was
+            // stolen.
+            if (delta == beast::kZero && borrowerDelta > roundedPayment)
+            {
+                yieldTheftCount++;
+            }
+
+            previousAssetsTotal = currentAssetsTotal;
+        }
+
+        BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount));
+    }
+
+    // Regression for the dual-rounding fix at coarse (integer-MPT) scale.
+    //
+    // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
+    // amortization schedule produces a fractional principal
+    // (~0.47) which under round-to-nearest collapses to 0 in a single
+    // step, causing `doPayment`'s strict `>` assertion on principal to
+    // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
+    // upward (sticks at 1 across the first two periods) and only clears
+    // in the final payment.
+    //
+    // The test pays one period at a time across three LoanPay
+    // transactions and verifies the loan completes (paymentRemaining=0)
+    // with totals matching the loan's economics (1 principal + 2 interest).
+    // Also run under featureLendingProtocolV1_1: ValidLoan must allow the
+    // two sticking pays (TVO falls, PO does not) and the final clear
+    // (PaymentRemaining 0, NextPaymentDueDate 0).
+    void
+    testIntegerScalePrincipalSticks(FeatureBitset features)
+    {
+        // Without fixCleanup3_2_0, this behavior will abort the server, so
+        // don't run without it.
+        if (!features[fixCleanup3_2_0])
+            return;
+
+        testcase("edge: integer MPT principal stuck mid-loan completes via final");
+
+        using namespace jtx;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
+        PrettyAsset const asset{mptt.issuanceID()};
+
+        mptt.authorize({.account = lender});
+        mptt.authorize({.account = borrower});
+
+        env(pay(issuer, lender, asset(10'000)));
+        env(pay(issuer, borrower, asset(10'000)));
+        env.close();
+
+        // createVaultAndBroker promotes the vault to ClosedEnded under
+        // featureLendingProtocolV1_1 (LoanBrokerSet rejects open-ended).
+        BrokerParameters const params{
+            .vaultDeposit = Number{5'000},
+            .debtMax = Number{100},
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        BrokerInfo const broker = createVaultAndBroker(env, asset, lender, params);
+
+        auto const loanKeylet = nextLoanKeylet(env, broker);
+        env(loan::set(borrower, broker.brokerID, Number{1}),
+            Sig(sfCounterpartySignature, lender),
+            loan::kInterestRate(TenthBips32{50'000}),
+            loan::kPaymentTotal(3),
+            loan::kPaymentInterval(31'536'000),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const borrowerStart = env.balance(borrower, asset).value();
+
+        // Three separate periodic payments of 1 each. Expected per-period
+        // evolution at integer MPT scale (TVO = PO + interestDue +
+        // managementFeeDue):
+        //   start:        PO=1, TVO=3, paymentRemaining=3
+        //   after pay #1: PO=1, TVO=2, paymentRemaining=2  (principal sticks)
+        //   after pay #2: PO=1, TVO=1, paymentRemaining=1  (principal sticks)
+        //   after pay #3: PO=0, TVO=0, paymentRemaining=0  (final clears)
+        std::array const expectedPO{Number{1}, Number{1}, Number{0}};
+        std::array const expectedTVO{Number{2}, Number{1}, Number{0}};
+        std::array const expectedRemaining{2, 1, 0};
+
+        for (int i = 0; i < 3; ++i)
+        {
+            env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
+            env.close();
+
+            auto const sle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
+            BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
+            BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
+            if (expectedRemaining[i] == 0)
+                BEAST_EXPECT(sle->at(~sfNextPaymentDueDate).value_or(0) == 0);
+        }
+
+        // Borrower paid 3 total regardless of fee split (1 principal + 2
+        // interest+fee, matching loan economics).
+        auto const borrowerEnd = env.balance(borrower, asset).value();
+        BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
+    }
+
+#if LOAN_TODO
+    void
+    testLoanCoverMinimumRoundingExploit(FeatureBitset features)
+    {
+        auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
+            testcase << "LoanBrokerCoverClawback drains cover via rounding"
+                     << " principalRequested=" << to_string(principalRequest);
+
+            using namespace jtx;
+            using namespace loan;
+            using namespace loan_broker;
+
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"lender"};
+            Account const borrower{"borrower"};
+
+            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer[iouCurrency];
+            env(trust(lender, asset(2'000'0000)));
+            env(trust(borrower, asset(2'000'0000)));
+            env.close();
+
+            env(pay(issuer, lender, asset(2'000'0000)));
+            env.close();
+
+            BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
+            BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            auto createTx = env.jt(
+                set(borrower, broker.brokerID, principalRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee,
+                kPaymentInterval(600),
+                kPaymentTotal(1),
+                kGracePeriod(60));
+            env(createTx);
+            env.close();
+
+            auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerBefore);
+            if (!brokerBefore)
+                return;
+
+            Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
+            Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
+
+            BEAST_EXPECT(debtOutstanding > Number{});
+            BEAST_EXPECT(coverAvailableBefore > Number{});
+
+            log << "debt=" << to_string(debtOutstanding)
+                << " cover_available=" << to_string(coverAvailableBefore);
+
+            env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
+            env.close();
+
+            auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerAfter);
+            if (!brokerAfter)
+                return;
+
+            Number const debtAfter = brokerAfter->at(sfDebtTotal);
+            // the debt has not changed
+            BEAST_EXPECT(debtAfter == debtOutstanding);
+
+            Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
+
+            // since the cover rate min != 0, the cover available should not
+            // be zero
+            BEAST_EXPECT(coverAvailableAfter != Number{});
+        };
+
+        // Call the lambda with different principal values
+        testLoanCoverMinimumRoundingExploit(Number{1, -30});  // 1e-30 units
+        testLoanCoverMinimumRoundingExploit(Number{1, -20});  // 1e-20 units
+        testLoanCoverMinimumRoundingExploit(Number{1, -10});  // 1e-10 units
+        testLoanCoverMinimumRoundingExploit(Number{1, 1});    // 1e-10 units
+    }
+#endif
+
+    // A residual overpayment can reduce the stored principal by one scale-unit
+    // *less* than computeOverpaymentComponents predicts, firing the
+    // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
+    //
+    //   trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
+    //
+    // tryOverpayment re-amortizes the loan at the reduced principal, then
+    // re-derives the theoretical principal from the new periodic payment via
+    // (P * paymentFactor) / paymentFactor. That round-trip is not exact in
+    // Number's 19-digit arithmetic; a positive residual pushes the recomputed
+    // principal a hair above the exact grid point `oldPrincipal - delta`, and
+    // the Upward rounding in tryOverpayment then bumps it a full scale-unit
+    // higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
+    //
+    // Concrete case (isolated, at the tryOverpayment level):
+    // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
+    // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
+    // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
+    // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
+    //
+    // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
+    // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
+    // lossy (P*factor)/factor round-trip, so the assertion holds and the
+    // overpayment applies cleanly. The three "principal change agrees" /
+    // "interest paid agrees" / "principal payment matches" assertions are
+    // gated behind the same amendment, so without it they are disabled (the
+    // server does not abort) and the loan keeps the pre-amendment computation.
+    //
+    // The test runs the same scenario under both amendment settings and checks
+    // the stored principal against a ground-truth value derived independently of
+    // the loan-state computation under test.
+    void
+    testBugOverpaymentPrincipalChange()
+    {
+        testcase("bug: doOverpayment asserts 'principal change agrees'");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace xrpl::detail;
+
+        struct Params
+        {
+            TenthBips32 interestRate;
+            TenthBips16 managementFeeRate;
+            std::uint32_t paymentTotal;
+            std::uint32_t paymentInterval;
+            std::int64_t principal;
+            Number overpayment;
+            TenthBips32 overpaymentInterestRate;
+            TenthBips32 overpaymentFeeRate;
+            std::optional vaultScale;
+        };
+
+        struct Result
+        {
+            Number principalOutstanding;  // stored principal after the LoanPay
+            Number expectedNewPrincipal;  // ground truth, independent of the fix
+            Number managementFeeChange;   // managementFeeOutstanding after - before
+            Number unit;                  // one scale-unit at the loan scale
+        };
+
+        auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
+            Env env(*this, features);
+
+            Account const issuer{"issuer"};
+            Account const lender{"vaultOwner"};
+            Account const borrower{"borrower"};
+
+            PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
+            Asset const asset = iouAsset.raw();
+
+            auto const broker = createVaultAndBroker(
+                env,
+                iouAsset,
+                lender,
+                {.vaultDeposit = 900'000,
+                 .debtMax = 0,
+                 .managementFeeRate = p.managementFeeRate,
+                 .vaultScale = p.vaultScale});
+
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            BEAST_EXPECT(brokerSle);
+            auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
+            auto const loanKeylet =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+            env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
+                Sig(sfCounterpartySignature, lender),
+                kInterestRate(p.interestRate),
+                kPaymentTotal(p.paymentTotal),
+                kPaymentInterval(p.paymentInterval),
+                kGracePeriod(p.paymentInterval),
+                kOverpaymentFee(p.overpaymentFeeRate),
+                kOverpaymentInterestRate(p.overpaymentInterestRate),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // The single LoanPay below makes one regular payment (the overpayment
+            // is smaller than one period) and leaves the residual as an
+            // overpayment.
+            auto const s = getCurrentState(env, broker, loanKeylet);
+            auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
+            auto const onePeriod = computePaymentComponents(
+                env.current()->rules(),
+                asset,
+                s.loanScale,
+                s.totalValue,
+                s.principalOutstanding,
+                s.managementFeeOutstanding,
+                s.periodicPayment,
+                periodicRate,
+                s.paymentRemaining,
+                p.managementFeeRate);
+
+            // Ground truth: the stored principal must drop by exactly the regular
+            // payment's principal portion plus the overpayment's principal
+            // portion. computeOverpaymentComponents depends only on the
+            // overpayment amount and rates (not on the loan-state computation
+            // under test), so it is an independent oracle. Both components are
+            // computed under the same rules as the env so the payment factor
+            // matches.
+            auto const overpaymentComponents = computeOverpaymentComponents(
+                env.current()->rules(),
+                asset,
+                s.loanScale,
+                p.overpayment,
+                p.overpaymentInterestRate,
+                p.overpaymentFeeRate,
+                p.managementFeeRate);
+            Number const expectedNewPrincipal = s.principalOutstanding -
+                onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
+
+            Number const managementFeeBefore = s.managementFeeOutstanding;
+
+            STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
+            env(pay(borrower, loanKeylet.key, payAmount),
+                Txflags(tfLoanOverpayment),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const loanSle = env.le(loanKeylet);
+            BEAST_EXPECT(loanSle);
+
+            return Result{
+                .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
+                .expectedNewPrincipal = expectedNewPrincipal,
+                .managementFeeChange =
+                    (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
+                    managementFeeBefore,
+                .unit = Number{1, s.loanScale}};
+        };
+
+        // Scenario 1: the original near-zero-rate principal reproduction
+        // (loanScale -10, no management fee). 0.049999998 is smaller than one
+        // period, so it stays a residual overpayment.
+        Params const principalCase{
+            .interestRate = TenthBips32{1},
+            .managementFeeRate = TenthBips16{0},
+            .paymentTotal = 3,
+            .paymentInterval = 60,
+            .principal = 100,
+            .overpayment = Number{49999998, -9},
+            .overpaymentInterestRate = TenthBips32{1000},
+            .overpaymentFeeRate = TenthBips32{1000},
+            .vaultScale = 1};
+
+        // With fixCleanup3_2_0 the stored principal lands exactly on the
+        // ground-truth grid point: it is reduced by exactly the overpayment's
+        // principal portion. This is the key correctness check: if the principal
+        // pin were removed (even with the assertions still gated off), the lossy
+        // (P * factor) / factor round-trip would leave the principal one
+        // scale-unit high and this would fail.
+        Result const fixed = runScenario(all_, principalCase);
+        BEAST_EXPECTS(
+            fixed.principalOutstanding == fixed.expectedNewPrincipal,
+            "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
+                to_string(fixed.expectedNewPrincipal));
+
+        // Without the amendment the loan amortizes with the catastrophically
+        // cancelling near-zero payment factor, so its schedule (and ground truth)
+        // differ from the fixed case; the gated assertions keep the server from
+        // aborting and the overpayment still lands exactly on that schedule.
+        Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
+        BEAST_EXPECTS(
+            legacy.principalOutstanding == legacy.expectedNewPrincipal,
+            "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
+                to_string(legacy.expectedNewPrincipal));
+
+        // Scenario 2: a normal-rate loan with a 10% management fee. At a normal
+        // rate the payment factor is identical across the amendment, so toggling
+        // fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
+        // lands on a state where both the principal and the management fee differ
+        // by one scale-unit between the fixed and legacy paths.
+        Params const feeCase{
+            .interestRate = TenthBips32{10000},
+            .managementFeeRate = TenthBips16{10000},
+            .paymentTotal = 6,
+            .paymentInterval = 30u * 24 * 60 * 60,
+            .principal = 1000,
+            .overpayment = Number{214367363, -10},
+            .overpaymentInterestRate = TenthBips32{0},
+            .overpaymentFeeRate = TenthBips32{0},
+            .vaultScale = std::nullopt};
+
+        Result const feeFixed = runScenario(all_, feeCase);
+        Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
+
+        // With the fix the principal is the exact reduction; without it the lossy
+        // (P * factor) / factor round-trip leaves it one scale-unit high.
+        BEAST_EXPECTS(
+            feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
+            "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
+                " != expected " + to_string(feeFixed.expectedNewPrincipal));
+        BEAST_EXPECTS(
+            feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
+            "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
+                " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
+
+        // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
+        // outstanding drops.
+        //
+        // Unlike the principal that is already at the correct precision, the re-amortized
+        // management fee  is tenthBipsOfValue of the new schedule's gross interest, which depends
+        // on the recomputed periodic payment. So the expected change below is a pinned constant
+        // captured from a passing run a magic value only because there is nothing simpler to
+        // compare against.
+        //
+        // At the integration level, toggling the amendment also changes the regular payment's
+        // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
+        // fix.
+        BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
+        BEAST_EXPECTS(
+            (feeFixed.managementFeeChange == Number{-8219709543, -10}),
+            "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
+    }
+
+    // An overpayment whose residual amount has more precision than loanScale
+    // fires the isRounded(asset, overpayment, loanScale) assertion in
+    // computeOverpaymentComponents (and a downstream "interest paid agrees"
+    // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
+    // to loanScale before passing it in. The pre-amendment path can't be
+    // tested here because the assertion fires in Debug builds and aborts
+    // the test process — see the PR description for context.
+    void
+    testBugOverpayUnroundedAmount()
+    {
+        testcase("bug: computeOverpaymentComponents isRounded assertion");
+
+        using namespace jtx;
+        using namespace loan;
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const lender{"vaultOwner"};
+        Account const borrower{"borrower"};
+
+        PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
+
+        auto const broker = createVaultAndBroker(
+            env,
+            iouAsset,
+            lender,
+            {.vaultDeposit = 100'000,
+             .debtMax = 5000,
+             .managementFeeRate = TenthBips16{1000},
+             .vaultScale = 1});
+
+        auto const sleBroker = env.le(broker.brokerKeylet());
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanSequence = sleBroker->at(sfLoanSequence);
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        using namespace loan;
+        env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32{10000}),
+            kPaymentTotal(12),
+            kPaymentInterval(60),
+            kGracePeriod(60),
+            kOverpaymentFee(TenthBips32{1000}),
+            kOverpaymentInterestRate(TenthBips32{1000}),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
+        // has too many digits to round cleanly to loanScale=-10, so the
+        // overpayment residual fails the isRounded check.
+        STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
+        env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
+        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
+    // the theoretical value, producing a negative theoretical interest.
+    // The payment delta then exceeds the actual outstanding interest,
+    // violating XRPL_ASSERT_PARTS in computePaymentComponents.
+    void
+    testBugInterestDueDeltaCrash()
+    {
+        testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        Env env(*this, all_);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const iouAsset = issuer["USD"];
+        env(trust(lender, iouAsset(1'000'000'000)));
+        env(trust(borrower, iouAsset(1'000'000'000)));
+        env(pay(issuer, lender, iouAsset(5'000'000)));
+        env(pay(issuer, borrower, iouAsset(5'000'000)));
+        env.close();
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000'000,
+            .debtMax = 1'000'000,
+            .coverRateMin = TenthBips32{0},
+            .coverDeposit = 0,
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+
+        BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{100};
+
+        auto createJson = env.json(
+            set(borrower, broker.brokerID, principalRequest),
+            Fee(loanSetFee),
+            Json(sfCounterpartySignature, json::ValueType::Object));
+
+        createJson["InterestRate"] = 1;  // minimum non-zero rate
+        createJson["PaymentTotal"] = 3;
+        createJson["PaymentInterval"] = 600;
+
+        auto const keylet = nextLoanKeylet(env, broker);
+
+        createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
+        env(createJson, Ter(tesSUCCESS));
+        env.close();
+
+        // For principal=100, n=3 the amortization schedule produces a
+        // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
+        // one period's worth — enough for the LoanPay path to enter
+        // computePaymentComponents and reach the assertion that fires
+        // when the bug is present. With the fix, the tx applies cleanly.
+        env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
+        env.close();
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        for (auto const flags : {0u, tfLoanOverpayment})
+            testYieldTheftRounding(flags);
+        testBugOverpaymentPrincipalChange();
+        testBugOverpayUnroundedAmount();
+        testBugVaultWithdrawFixedSharesRoundsToZero(all_ - fixCleanup3_4_0);
+        testBugVaultWithdrawFixedSharesRoundsToZero(all_);
+        testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0);
+        testBugVaultWithdrawDustVsAssetsTotal(all_);
+        testBugInterestDueDeltaCrash();
+        // all_ excludes V1.1; amendmentCombinations never pairs it with the
+        // sticking schedule. Run that combination explicitly.
+        testIntegerScalePrincipalSticks(all_ | featureLendingProtocolV1_1);
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testDustManipulation(features);
+        testRoundingAllowsUndercoverage(features);
+        testIntegerScalePrincipalSticks(features);
+#if LOAN_TODO
+        testLoanCoverMinimumRoundingExploit(features);
+#endif
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
new file mode 100644
index 0000000000..54c972b505
--- /dev/null
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -0,0 +1,1194 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#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 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)
+    {
+        // --- PoC Summary ----------------------------------------------------
+        // Scenario: Borrower makes one periodic payment early (before next due)
+        // so doPayment sets sfPreviousPaymentDueDate to the (future)
+        // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one
+        // interval. Borrower then immediately performs a full-payment
+        // (tfLoanFullPayment). Why it matters: Full-payment interest accrual
+        // uses
+        //   delta = now - max(prevPaymentDate, startDate)
+        // with an unsigned clock representation (uint32). If prevPaymentDate is
+        // in the future, the subtraction underflows to a very large positive
+        // number. This inflates roundedFullInterest and total full-close due,
+        // and LoanPay applies the inflated valueChange to the vault
+        // (sfAssetsTotal), increasing NAV.
+        // --------------------------------------------------------------------
+        testcase("PoC: Unsigned-underflow full-pay accrual after early periodic");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env{*this, features};
+
+        Account const lender{"poc_lender4"};
+        Account const borrower{"poc_borrower4"};
+        env.fund(XRP(3'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1'000'000};
+        BrokerParameters const brokerParams{};
+        auto const broker = createVaultAndBroker(env, asset, lender, brokerParams);
+
+        // Create a 3-payment loan so full-payment path is enabled after 1
+        // periodic payment.
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest = asset(1000).value();
+        auto const originationFee = asset(0).value();
+        auto const serviceFee = asset(1).value();
+        auto const serviceFeePA = asset(1);
+        auto const lateFee = asset(0).value();
+        auto const closeFee = asset(0).value();
+        auto const interest = percentageToTenthBips(12);
+        auto const lateInterest = percentageToTenthBips(12) / 10;
+        auto const closeInterest = percentageToTenthBips(12) / 10;
+        auto const overpaymentInterest = percentageToTenthBips(12) / 10;
+        auto const total = 3u;
+        auto const interval = 600u;
+        auto const grace = 60u;
+
+        auto createJtx = env.jt(
+            set(borrower, broker.brokerID, principalRequest, 0),
+            Sig(sfCounterpartySignature, lender),
+            kLoanOriginationFee(originationFee),
+            kLoanServiceFee(serviceFee),
+            kLatePaymentFee(lateFee),
+            kClosePaymentFee(closeFee),
+            kOverpaymentFee(percentageToTenthBips(5) / 10),
+            kInterestRate(interest),
+            kLateInterestRate(lateInterest),
+            kCloseInterestRate(closeInterest),
+            kOverpaymentInterestRate(overpaymentInterest),
+            kPaymentTotal(total),
+            kPaymentInterval(interval),
+            kGracePeriod(grace),
+            Fee(loanSetFee));
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        BEAST_EXPECT(brokerSle);
+        auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        env(createJtx);
+        env.close();
+
+        // Compute a regular periodic due and pay it early (before next due).
+        auto state = getCurrentState(env, broker, loanKeylet);
+        Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
+        auto const components = xrpl::detail::computePaymentComponents(
+            env.current()->rules(),
+            asset.raw(),
+            state.loanScale,
+            state.totalValue,
+            state.principalOutstanding,
+            state.managementFeeOutstanding,
+            state.periodicPayment,
+            periodicRate,
+            state.paymentRemaining,
+            brokerParams.managementFeeRate);
+        STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()};
+        // now < nextDue immediately after creation, so this is an early pay.
+        env(pay(borrower, loanKeylet.key, regularDue));
+        env.close();
+
+        // Immediately attempt a full payoff. Compute the exact full-payment
+        // due to ensure the tx applies.
+        auto after = getCurrentState(env, broker, loanKeylet);
+        auto const loanSle = env.le(loanKeylet);
+        BEAST_EXPECT(loanSle);
+        auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID));
+        BEAST_EXPECT(brokerSle2);
+
+        auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{};
+        auto const closeInterestRate =
+            loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{};
+        auto const managementFeeRate =
+            brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{};
+
+        Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval);
+        // Accrued + prepayment-penalty interest based on current periodic
+        // schedule
+        auto const fullPaymentInterest = computeFullPaymentInterest(
+            xrpl::detail::loanPrincipalFromPeriodicPayment(
+                env.current()->rules(),
+                after.periodicPayment,
+                periodicRate2,
+                after.paymentRemaining),
+            periodicRate2,
+            env.current()->parentCloseTime(),
+            after.paymentInterval,
+            after.previousPaymentDate,
+            static_cast(after.startDate.time_since_epoch().count()),
+            closeInterestRate);
+
+        // Round to asset scale and split interest/fee parts
+        auto const roundedInterest =
+            roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale);
+        Number const roundedFullMgmtFee =
+            computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale);
+        Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee;
+
+        // Show both signed and unsigned deltas to highlight the underflow.
+        auto const nowSecs =
+            static_cast(env.current()->parentCloseTime().time_since_epoch().count());
+        auto const startSecs =
+            static_cast(after.startDate.time_since_epoch().count());
+        auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs);
+        auto const signedDelta =
+            static_cast(nowSecs) - static_cast(lastPaymentDate);
+        auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate);
+        log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs
+            << " now=" << nowSecs << " signedDelta=" << signedDelta
+            << " unsignedDelta=" << unsignedDelta << std::endl;
+
+        // Reference (clamped) computation: emulate a non-negative accrual
+        // window by clamping prevPaymentDate to 'now' for the full-pay path.
+        auto const prevClamped = std::min(after.previousPaymentDate, nowSecs);
+        auto const fullPaymentInterestClamped = computeFullPaymentInterest(
+            xrpl::detail::loanPrincipalFromPeriodicPayment(
+                env.current()->rules(),
+                after.periodicPayment,
+                periodicRate2,
+                after.paymentRemaining),
+            periodicRate2,
+            env.current()->parentCloseTime(),
+            after.paymentInterval,
+            prevClamped,
+            startSecs,
+            closeInterestRate);
+        auto const roundedInterestClamped =
+            roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale);
+        Number const roundedFullMgmtFeeClamped = computeManagementFee(
+            asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale);
+        Number const roundedFullInterestClamped =
+            roundedInterestClamped - roundedFullMgmtFeeClamped;
+        STAmount const fullDueClamped{
+            asset,
+            after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped +
+                closePaymentFee};
+
+        // Collect vault NAV before closing payment
+        auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{};
+        auto const vaultKey2 = keylet::vault(vaultId2);
+        auto const vaultBefore = env.le(vaultKey2);
+        BEAST_EXPECT(vaultBefore);
+        Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{};
+
+        STAmount const fullDue{
+            asset,
+            after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee +
+                closePaymentFee};
+
+        log << "PoC payoff: principalOutstanding=" << after.principalOutstanding
+            << " roundedFullInterest=" << roundedFullInterest
+            << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee
+            << " fullDue=" << to_string(fullDue.getJson()) << std::endl;
+        log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped
+            << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped
+            << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl;
+
+        env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment));
+        env.close();
+
+        // Sanity: underflow present (unsigned delta very large relative to
+        // interval)
+        BEAST_EXPECT(unsignedDelta > after.paymentInterval);
+
+        // Compare vault NAV before/after the full close
+        auto const vaultAfter = env.le(vaultKey2);
+        BEAST_EXPECT(vaultAfter);
+        if (vaultAfter)
+        {
+            auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal);
+            log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore
+                << " assetsTotalAfter=" << assetsTotalAfter
+                << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl;
+
+            // Regression check: the underflowed window must be clamped so the
+            // payoff matches the non-underflow reference, i.e. no overcharge.
+            BEAST_EXPECT(fullDue == fullDueClamped);
+            if (fullDue != fullDueClamped)
+                log << "PoC delta: overcharge (fullDue > clamped)" << std::endl;
+        }
+
+        // Loan should be paid off
+        auto const finalLoan = env.le(loanKeylet);
+        BEAST_EXPECT(finalLoan);
+        if (finalLoan)
+        {
+            BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0);
+            BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0);
+        }
+    }
+
+    void
+    testRIPD3831(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        testcase("RIPD-3831");
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 100000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            // .managementFeeRate = TenthBips16{5919},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = Number{200'000, -6},
+            .lateFee = Number{200, -6},
+            .interest = TenthBips32{50'000},
+            .payTotal = 10,
+            .payInterval = 150};
+
+        auto const assetType = AssetType::XRP;
+
+        Env env{*this, features};
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
+
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+
+        using tp = NetClock::time_point;
+        using d = NetClock::duration;
+
+        auto state = getCurrentState(env, broker, loanKeylet);
+        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
+        {
+            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
+        }
+
+        topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
+
+        using namespace jtx::loan;
+
+        auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue)));
+
+        {
+            auto const submitParam = to_string(jv);
+            auto const jr = env.rpc("submit", borrower.name(), submitParam);
+
+            BEAST_EXPECT(jr.isMember(jss::result));
+        }
+
+        env.close();
+
+        // Make sure the system keeps responding
+        env(noop(borrower));
+        env.close();
+        env(noop(issuer));
+        env.close();
+        env(noop(lender));
+        env.close();
+    }
+
+    void
+    testRIPD3459(FeatureBitset features)
+    {
+        testcase("RIPD-3459 - LoanBroker incorrect debt total");
+
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 200'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .managementFeeRate = TenthBips16{500},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = Number{100'000, -4},
+            .interest = TenthBips32{100'000},
+            .payTotal = 10};
+
+        auto const assetType = AssetType::MPT;
+
+        Env env{*this, features};
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
+
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+        auto pseudoAcct = std::get(*loanResult);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
+
+        if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
+        {
+            if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
+            {
+                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
+            }
+        }
+
+        makeLoanPayments(
+            env,
+            broker,
+            loanParams,
+            loanKeylet,
+            verifyLoanStatus,
+            issuer,
+            lender,
+            borrower,
+            PaymentParameters{.showStepBalances = true});
+
+        if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle))
+        {
+            if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle))
+            {
+                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding));
+                BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero);
+            }
+        }
+    }
+
+    void
+    testRIPD3901()
+    {
+        testcase("Crash with tfLoanOverpayment");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        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, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
+        env(tx, txFee);
+        env.close();
+
+        env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}),
+            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)));
+
+        env(loan_broker::set(lender, vaultKeyLet.key), txFee);
+        env.close();
+
+        STAmount const debtMaximumRequest = XRPAmount(200'000);
+
+        env(set(borrower, brokerKeyLet.key, debtMaximumRequest),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32(50'000)),
+            kPaymentTotal(2),
+            kPaymentInterval(150),
+            Txflags(tfLoanOverpayment),
+            txFee);
+        env.close();
+
+        std::uint32_t const loanSequence = 1;
+        auto const loanKeylet = keylet::loan(brokerKeyLet.key, SeqProxy::rawSequence(loanSequence));
+
+        if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
+        {
+            env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)),
+                Txflags(tfLoanOverpayment),
+                txFee);
+            env.close();
+        }
+    }
+
+    void
+    testRIPD3902(FeatureBitset features)
+    {
+        testcase("RIPD-3902 - 1 IOU loan payments");
+
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 10,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = Number{1, 0},
+            .interest = TenthBips32{100'000},
+            .payTotal = 5,
+            .payInterval = 150,
+            .gracePd = 60};
+
+        auto const assetType = AssetType::IOU;
+
+        Env env{*this, features};
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
+
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+        auto pseudoAcct = std::get(*loanResult);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
+
+        makeLoanPayments(
+            env,
+            broker,
+            loanParams,
+            loanKeylet,
+            verifyLoanStatus,
+            issuer,
+            lender,
+            borrower,
+            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().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features);
+        testRIPD3831(features);
+        testRIPD3459(features);
+        testRIPD3902(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanSecurity, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp
new file mode 100644
index 0000000000..469c1662ad
--- /dev/null
+++ b/src/test/app/lending/LoanSet_test.cpp
@@ -0,0 +1,948 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 LoanSet_test : public LoanTestBase
+{
+private:
+    void
+    testLoanSet(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        struct CaseArgs
+        {
+            bool requireAuth = false;
+            bool authorizeBorrower = false;
+            int initialXRP = 1'000'000;
+        };
+
+        auto const testCase = [&, this](
+                                  std::function mptTest,
+                                  std::function iouTest,
+                                  CaseArgs args = {}) {
+            Env env(*this, features);
+            env.fund(XRP(args.initialXRP), issuer, lender, borrower);
+            env.close();
+            if (args.requireAuth)
+            {
+                env(fset(issuer, asfRequireAuth));
+                env.close();
+            }
+
+            // We need two different asset types, MPT and IOU. Prepare MPT
+            // first
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+
+            auto const kNone = LedgerSpecificFlags(0);
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock |
+                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
+            env.close();
+            PrettyAsset const mptAsset = mptt.issuanceID();
+            mptt.authorize({.account = lender});
+            mptt.authorize({.account = borrower});
+            env.close();
+            if (args.requireAuth)
+            {
+                mptt.authorize({.account = issuer, .holder = lender});
+                if (args.authorizeBorrower)
+                    mptt.authorize({.account = issuer, .holder = borrower});
+                env.close();
+            }
+
+            env(pay(issuer, lender, mptAsset(10'000'000)));
+            env.close();
+
+            // Prepare IOU
+            PrettyAsset const iouAsset = issuer[iouCurrency_];
+            env(trust(lender, iouAsset(10'000'000)));
+            env(trust(borrower, iouAsset(10'000'000)));
+            env.close();
+            if (args.requireAuth)
+            {
+                env(trust(issuer, iouAsset(0), lender, tfSetfAuth));
+                env(pay(issuer, lender, iouAsset(10'000'000)));
+                if (args.authorizeBorrower)
+                {
+                    env(trust(issuer, iouAsset(0), borrower, tfSetfAuth));
+                    env(pay(issuer, borrower, iouAsset(10'000)));
+                }
+            }
+            else
+            {
+                env(pay(issuer, lender, iouAsset(10'000'000)));
+                env(pay(issuer, borrower, iouAsset(10'000)));
+            }
+            env.close();
+
+            // Create vaults and loan brokers
+            std::array const assets{mptAsset, iouAsset};
+            std::vector brokers;
+            brokers.reserve(assets.size());
+            for (auto const& asset : assets)
+            {
+                brokers.emplace_back(createVaultAndBroker(env, asset, lender));
+            }
+
+            if (mptTest)
+                mptTest(env, brokers[0], mptt);
+            if (iouTest)
+                iouTest(env, brokers[1]);
+        };
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("MPT issuer is borrower, issuer submits");
+                env(set(issuer, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+
+                testcase("MPT issuer is borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(issuer),
+                    Sig(sfCounterpartySignature, issuer),
+                    Fee(env.current()->fees().base * 5));
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("IOU issuer is borrower, issuer submits");
+                env(set(issuer, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+
+                testcase("IOU issuer is borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(issuer),
+                    Sig(sfCounterpartySignature, issuer),
+                    Fee(env.current()->fees().base * 5));
+            },
+            CaseArgs{.requireAuth = true});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("MPT unauthorized borrower, borrower submits");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+
+                testcase("MPT unauthorized borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Sig(sfCounterpartySignature, borrower),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("IOU unauthorized borrower, borrower submits");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+
+                testcase("IOU unauthorized borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Sig(sfCounterpartySignature, borrower),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+            },
+            CaseArgs{.requireAuth = true});
+
+        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, BrokerInfo const& broker, MPTTester& mptt) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "MPT authorized borrower, borrower submits, borrower has "
+                    "no reserve");
+                mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize});
+                env.close();
+
+                auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower);
+                auto const sleMPT1 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT1 == nullptr);
+
+                // Burn some XRP
+                env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
+                env.close();
+
+                // Cannot create loan, not enough reserve to create MPToken
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecINSUFFICIENT_RESERVE});
+                env.close();
+
+                // Can create loan now, will implicitly create MPToken
+                env(pay(issuer, borrower, XRP(incReserve)));
+                env.close();
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+                env.close();
+
+                auto const sleMPT2 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT2 != nullptr);
+            },
+            {},
+            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
+
+        testCase(
+            {},
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "IOU authorized borrower, borrower submits, borrower has "
+                    "no reserve");
+                // Remove trust line from borrower to issuer
+                env.trust(broker.asset(0), borrower);
+                env.close();
+
+                env(pay(borrower, issuer, broker.asset(10'000)));
+                env.close();
+                auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get());
+                auto const sleLine1 = env.le(trustline);
+                BEAST_EXPECT(sleLine1 == nullptr);
+
+                // Burn some XRP
+                env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2))));
+                env.close();
+
+                // Cannot create loan, not enough reserve to create trust line
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_LINE_INSUF_RESERVE});
+                env.close();
+
+                // Can create loan now, will implicitly create trust line
+                env(pay(issuer, borrower, XRP(incReserve)));
+                env.close();
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+                env.close();
+
+                auto const sleLine2 = env.le(trustline);
+                BEAST_EXPECT(sleLine2 != nullptr);
+            },
+            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "MPT authorized borrower, borrower submits, lender has "
+                    "no reserve");
+                auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
+                auto const sleMPT1 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT1 != nullptr);
+
+                env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
+                env.close();
+
+                mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
+                env.close();
+
+                auto const sleMPT2 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT2 == nullptr);
+
+                // Burn some XRP
+                env(noop(lender), Fee(XRP(incReserve)));
+                env.close();
+
+                // Cannot create loan, not enough reserve to create MPToken
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kLoanOriginationFee(broker.asset(1).value()),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecINSUFFICIENT_RESERVE});
+                env.close();
+
+                // Can create loan now, will implicitly create MPToken
+                env(pay(issuer, lender, XRP(incReserve)));
+                env.close();
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kLoanOriginationFee(broker.asset(1).value()),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+                env.close();
+
+                auto const sleMPT3 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT3 != nullptr);
+            },
+            {},
+            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
+
+        testCase(
+            {},
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "IOU authorized borrower, borrower submits, lender has no "
+                    "reserve");
+                // Remove trust line from lender to issuer
+                env.trust(broker.asset(0), lender);
+                env.close();
+
+                auto const trustline = keylet::trustLine(lender, broker.asset.raw().get());
+                auto const sleLine1 = env.le(trustline);
+                BEAST_EXPECT(sleLine1 != nullptr);
+
+                env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value()))));
+                env.close();
+                auto const sleLine2 = env.le(trustline);
+                BEAST_EXPECT(sleLine2 == nullptr);
+
+                // Burn some XRP
+                env(noop(lender), Fee(XRP(incReserve)));
+                env.close();
+
+                // Cannot create loan, not enough reserve to create trust line
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kLoanOriginationFee(broker.asset(1).value()),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_LINE_INSUF_RESERVE});
+                env.close();
+
+                // Can create loan now, will implicitly create trust line
+                env(pay(issuer, lender, XRP(incReserve)));
+                env.close();
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kLoanOriginationFee(broker.asset(1).value()),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+                env.close();
+
+                auto const sleLine3 = env.le(trustline);
+                BEAST_EXPECT(sleLine3 != nullptr);
+            },
+            CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("MPT authorized borrower, unauthorized lender");
+                auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender);
+                auto const sleMPT1 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT1 != nullptr);
+
+                env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount))));
+                env.close();
+
+                mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
+                env.close();
+
+                auto const sleMPT2 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT2 == nullptr);
+
+                // Cannot create loan, lender not authorized to receive fee
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kLoanOriginationFee(broker.asset(1).value()),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+                env.close();
+
+                // Cannot create loan, even without an origination fee
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter{tecNO_AUTH});
+                env.close();
+
+                // No MPToken for lender - no authorization and no payment
+                auto const sleMPT3 = env.le(mptoken);
+                BEAST_EXPECT(sleMPT3 == nullptr);
+            },
+            {},
+            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("MPT authorized borrower, borrower submits");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("IOU authorized borrower, borrower submits");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5));
+            },
+            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("MPT authorized borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Sig(sfCounterpartySignature, borrower),
+                    Fee(env.current()->fees().base * 5));
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase("IOU authorized borrower, lender submits");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Sig(sfCounterpartySignature, borrower),
+                    Fee(env.current()->fees().base * 5));
+            },
+            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
+
+        jtx::Account const alice{"alice"};
+        jtx::Account const bella{"bella"};
+        auto const msigSetup = [&](Env& env, Account const& account) {
+            json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}});
+            env(tx1);
+            env.close();
+        };
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                msigSetup(env, lender);
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "MPT authorized borrower, borrower submits, lender "
+                    "multisign");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Msig(sfCounterpartySignature, alice, bella),
+                    Fee(env.current()->fees().base * 5));
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                msigSetup(env, lender);
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "IOU authorized borrower, borrower submits, lender "
+                    "multisign");
+                env(set(borrower, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    Msig(sfCounterpartySignature, alice, bella),
+                    Fee(env.current()->fees().base * 5));
+            },
+            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                msigSetup(env, borrower);
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "MPT authorized borrower, lender submits, borrower "
+                    "multisign");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Msig(sfCounterpartySignature, alice, bella),
+                    Fee(env.current()->fees().base * 5));
+            },
+            [&, this](Env& env, BrokerInfo const& broker) {
+                using namespace loan;
+                msigSetup(env, borrower);
+                Number const principalRequest = broker.asset(1'000).value();
+
+                testcase(
+                    "IOU authorized borrower, lender submits, borrower "
+                    "multisign");
+                env(set(lender, broker.brokerID, principalRequest),
+                    kCounterparty(borrower),
+                    Msig(sfCounterpartySignature, alice, bella),
+                    Fee(env.current()->fees().base * 5));
+            },
+            CaseArgs{.requireAuth = true, .authorizeBorrower = true});
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+                Vault const vault{env};
+                auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit;
+                env(tx);
+                env.close();
+
+                testcase("Vault at maximum value");
+                env(set(issuer, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    kInterestRate(TenthBips32(10'000)),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    Ter(tecLIMIT_EXCEEDED));
+            },
+            nullptr);
+
+        testCase(
+            [&, this](Env& env, BrokerInfo const& broker, auto&) {
+                using namespace loan;
+                Number const principalRequest = broker.asset(1'000).value();
+                Vault const vault{env};
+                auto tx = vault.set({.owner = lender, .id = broker.vaultID});
+                tx[sfAssetsMaximum] =
+                    BrokerParameters::defaults().vaultDeposit + broker.asset(1).number();
+                env(tx);
+                env.close();
+
+                testcase("Vault maximum value exceeded");
+                env(set(issuer, broker.brokerID, principalRequest),
+                    kCounterparty(lender),
+                    kInterestRate(TenthBips32(100'000)),
+                    Sig(sfCounterpartySignature, lender),
+                    Fee(env.current()->fees().base * 5),
+                    kPaymentTotal(2),
+                    kPaymentInterval(3600 * 24),
+                    Ter(tecLIMIT_EXCEEDED));
+            },
+            nullptr);
+    }
+
+    void
+    testLoanSetOriginationFeeTwoMptCreates(FeatureBitset features)
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        bool const fix340Enabled = features[fixCleanup3_4_0];
+        testcase << "LoanSet: borrower and broker owner missing MPToken"
+                 << (fix340Enabled ? "" : " pre-fixCleanup3_4_0");
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        Env env(*this, features);
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+        env.close();
+        PrettyAsset const asset = mptt.issuanceID();
+        mptt.authorize({.account = lender});
+        mptt.authorize({.account = borrower});
+        env.close();
+
+        env(pay(issuer, lender, asset(10'000'000)));
+        env.close();
+
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        // Delete borrower's asset MPToken.
+        mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize});
+        env.close();
+
+        // Pay out and delete the broker owner's asset MPToken.
+        auto const lenderMPToken = keylet::mptoken(mptt.issuanceID(), lender);
+        auto const sleLenderMPT = env.le(lenderMPToken);
+        if (!BEAST_EXPECT(sleLenderMPT))
+            return;
+        env(pay(lender, issuer, asset(sleLenderMPT->at(sfMPTAmount))));
+        env.close();
+        mptt.authorize({.account = lender, .flags = tfMPTUnauthorize});
+        env.close();
+
+        auto const borrowerMPToken = keylet::mptoken(mptt.issuanceID(), borrower);
+        auto const brokerKeylet = keylet::loanBroker(broker.brokerID);
+        auto const sleBrokerBefore = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(sleBrokerBefore))
+            return;
+        auto const loanSequence = sleBrokerBefore->at(sfLoanSequence);
+        auto const debtTotalBefore = sleBrokerBefore->at(sfDebtTotal);
+        auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        auto const sleVaultBefore = env.le(keylet::vault(broker.vaultID));
+        if (!BEAST_EXPECT(sleVaultBefore))
+            return;
+        auto const assetsAvailableBefore = sleVaultBefore->at(sfAssetsAvailable);
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            kLoanOriginationFee(asset(1).value()),
+            kCounterparty(lender),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 5),
+            Ter{fix340Enabled ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}});
+        env.close();
+
+        auto const sleBorrowerAfter = env.le(borrowerMPToken);
+        auto const sleLenderAfter = env.le(lenderMPToken);
+        auto const sleLoanAfter = env.le(loanKeylet);
+        auto const sleBrokerAfter = env.le(brokerKeylet);
+        auto const sleVaultAfter = env.le(keylet::vault(broker.vaultID));
+        if (!BEAST_EXPECT(sleVaultAfter))
+            return;
+        if (fix340Enabled)
+        {
+            if (!BEAST_EXPECT(sleBorrowerAfter && sleLenderAfter && sleLoanAfter && sleBrokerAfter))
+                return;
+            BEAST_EXPECT(sleBorrowerAfter->at(sfMPTAmount) == 999);
+            BEAST_EXPECT(sleLenderAfter->at(sfMPTAmount) == 1);
+            BEAST_EXPECT(sleLoanAfter->at(sfPrincipalOutstanding) == Number{1'000});
+            BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence + 1);
+            BEAST_EXPECT(
+                sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore - Number{1'000});
+        }
+        else
+        {
+            // The whole transaction must roll back.
+            BEAST_EXPECT(!sleBorrowerAfter);
+            BEAST_EXPECT(!sleLenderAfter);
+            BEAST_EXPECT(!sleLoanAfter);
+            if (!BEAST_EXPECT(sleBrokerAfter))
+                return;
+            BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence);
+            BEAST_EXPECT(sleBrokerAfter->at(sfDebtTotal) == debtTotalBefore);
+            BEAST_EXPECT(sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore);
+        }
+    }
+
+    // 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
+    {
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            testLoanSet(features);
+
+        testLoanSetClosedEnded();
+        testLoanSetExistingLineAfterIssuerClearsDefaultRipple();
+        testLoanSetOriginationFeeTwoMptCreates(all_);
+        testLoanSetOriginationFeeTwoMptCreates(all_ - fixCleanup3_4_0);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
new file mode 100644
index 0000000000..ab74ab6811
--- /dev/null
+++ b/src/test/app/lending/LoanTestBase.h
@@ -0,0 +1,3088 @@
+#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 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 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:
+    // Ensure that all the features needed for Lending Protocol are included,
+    // even if they are set to unsupported.
+    //
+    // featureLendingProtocolV1_1 is excluded from the default set: it changes
+    // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and
+    // most of this file's tests assert instant-interest-recognition-specific expected values
+    // for those fields. Tests that specifically exercise the amendment opt
+    // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`).
+    FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
+    std::string const iouCurrency_{"IOU"};
+
+    struct BrokerParameters
+    {
+        Number vaultDeposit = 1'000'000;
+        Number debtMax = 25'000;
+        TenthBips32 coverRateMin = percentageToTenthBips(10);
+        int coverDeposit = 1000;
+        TenthBips16 managementFeeRate{100};
+        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
+        {
+            NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
+            auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value();
+
+            return debtLimit - currentDebt;
+        }
+
+        static BrokerParameters const&
+        defaults()
+        {
+            static BrokerParameters const kResult{};
+            return kResult;
+        }
+
+        // TODO: create an operator() which returns a transaction similar to
+        // LoanParameters
+    };
+
+    struct BrokerInfo
+    {
+        jtx::PrettyAsset asset;
+        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,
+            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)
+        {
+        }
+
+        [[nodiscard]] Keylet
+        brokerKeylet() const
+        {
+            return keylet::loanBroker(brokerID);
+        }
+        [[nodiscard]] Keylet
+        vaultKeylet() const
+        {
+            return keylet::vault(vaultID);
+        }
+
+        [[nodiscard]] int
+        vaultScale(jtx::Env const& env) const
+        {
+            using namespace jtx;
+
+            auto const vaultSle = env.le(keylet::vault(vaultID));
+            return getAssetsTotalScale(vaultSle);
+        }
+    };
+
+    struct LoanParameters
+    {
+        // The account submitting the transaction. May be borrower or broker.
+        jtx::Account account;
+        // The counterparty. Should be the other of borrower or broker.
+        jtx::Account counter;
+        // Whether the counterparty is specified in the `counterparty` field, or
+        // only signs.
+        bool counterpartyExplicit = true;
+        Number principalRequest;
+        // NOLINTBEGIN(readability-redundant-member-init)
+        std::optional setFee = std::nullopt;
+        std::optional originationFee = std::nullopt;
+        std::optional serviceFee = std::nullopt;
+        std::optional lateFee = std::nullopt;
+        std::optional closeFee = std::nullopt;
+        std::optional overFee = std::nullopt;
+        std::optional interest = std::nullopt;
+        std::optional lateInterest = std::nullopt;
+        std::optional closeInterest = std::nullopt;
+        std::optional overpaymentInterest = std::nullopt;
+        std::optional payTotal = std::nullopt;
+        std::optional payInterval = std::nullopt;
+        std::optional gracePd = std::nullopt;
+        std::optional flags = std::nullopt;
+        // NOLINTEND(readability-redundant-member-init)
+
+        template 
+        jtx::JTx
+        operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const
+        {
+            using namespace jtx;
+            using namespace jtx::loan;
+
+            JTx jt{loan::set(
+                account,
+                broker.brokerID,
+                broker.asset(principalRequest).number(),
+                flags.value_or(0))};
+
+            Sig(sfCounterpartySignature, counter)(env, jt);
+
+            Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt);
+
+            if (counterpartyExplicit)
+                kCounterparty(counter)(env, jt);
+            if (originationFee)
+                kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt);
+            if (serviceFee)
+                kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt);
+            if (lateFee)
+                kLatePaymentFee(broker.asset(*lateFee).number())(env, jt);
+            if (closeFee)
+                kClosePaymentFee(broker.asset(*closeFee).number())(env, jt);
+            if (overFee)
+                kOverpaymentFee (*overFee)(env, jt);
+            if (interest)
+                kInterestRate (*interest)(env, jt);
+            if (lateInterest)
+                kLateInterestRate (*lateInterest)(env, jt);
+            if (closeInterest)
+                kCloseInterestRate (*closeInterest)(env, jt);
+            if (overpaymentInterest)
+                kOverpaymentInterestRate (*overpaymentInterest)(env, jt);
+            if (payTotal)
+                kPaymentTotal (*payTotal)(env, jt);
+            if (payInterval)
+                kPaymentInterval (*payInterval)(env, jt);
+            if (gracePd)
+                kGracePeriod (*gracePd)(env, jt);
+
+            return env.jt(jt, fN...);
+        }
+    };
+
+    struct PaymentParameters
+    {
+        Number overpaymentFactor = Number{1};
+        std::optional overpaymentExtra = std::nullopt;
+        std::uint32_t flags = 0;
+        bool showStepBalances = false;
+        bool validateBalances = true;
+
+        static PaymentParameters const&
+        defaults()
+        {
+            static PaymentParameters const kResult{};
+            return kResult;
+        }
+    };
+
+    struct LoanState
+    {
+        std::uint32_t previousPaymentDate = 0;
+        NetClock::time_point startDate;
+        std::uint32_t nextPaymentDate = 0;
+        std::uint32_t paymentRemaining = 0;
+        std::int32_t const loanScale = 0;
+        Number totalValue = 0;
+        Number principalOutstanding = 0;
+        Number managementFeeOutstanding = 0;
+        Number periodicPayment = 0;
+        std::uint32_t flags = 0;
+        std::uint32_t const paymentInterval = 0;
+        TenthBips32 const interestRate{};
+    };
+
+    /**
+     * Helper class to compare the expected state of a loan and loan broker
+     * against the data in the ledger.
+     */
+    struct VerifyLoanStatus
+    {
+    public:
+        jtx::Env const& env;
+        BrokerInfo const& broker;
+        jtx::Account const& pseudoAccount;
+        Keylet const& loanKeylet;
+
+        VerifyLoanStatus(
+            jtx::Env const& env,
+            BrokerInfo const& broker,
+            jtx::Account const& pseudo,
+            Keylet const& keylet)
+            : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet)
+        {
+        }
+
+        /**
+         * Checks the expected broker state against the ledger
+         */
+        void
+        checkBroker(
+            Number const& principalOutstanding,
+            Number const& interestOwed,
+            TenthBips32 interestRate,
+            std::uint32_t paymentInterval,
+            std::uint32_t paymentsRemaining,
+            std::uint32_t ownerCount) const
+        {
+            using namespace jtx;
+            if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+                env.test.BEAST_EXPECT(brokerSle))
+            {
+                TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
+                auto const brokerDebt = brokerSle->at(sfDebtTotal);
+
+                if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
+                    env.test.BEAST_EXPECT(vaultSle))
+                {
+                    auto const expectedDebt =
+                        env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+                            getVaultVersion(vaultSle) == VaultVersion::CashBasis
+                        ? principalOutstanding
+                        : principalOutstanding + interestOwed;
+                    env.test.BEAST_EXPECT(brokerDebt == expectedDebt);
+                    env.test.BEAST_EXPECT(
+                        env.balance(pseudoAccount, broker.asset).number() ==
+                        brokerSle->at(sfCoverAvailable));
+                    env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount);
+
+                    Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)};
+                    env.test.BEAST_EXPECT(
+                        vaultSle->at(sfAssetsAvailable) ==
+                        env.balance(vaultPseudo, broker.asset).number());
+                    if (ownerCount == 0)
+                    {
+                        // The Vault must be perfectly balanced if there
+                        // are no loans outstanding
+                        auto const total = vaultSle->at(sfAssetsTotal);
+                        auto const available = vaultSle->at(sfAssetsAvailable);
+                        env.test.BEAST_EXPECT(total == available);
+                        env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+                    }
+                }
+            }
+        }
+
+        void
+        checkPayment(
+            std::int32_t loanScale,
+            jtx::Account const& account,
+            jtx::PrettyAmount const& balanceBefore,
+            STAmount const& expectedPayment,
+            jtx::PrettyAmount const& adjustment) const
+        {
+            auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent());
+
+            STAmount const balanceChangeAmount{
+                broker.asset,
+                roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)};
+            {
+                auto const difference = roundToScale(
+                    env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount),
+                    borrowerScale);
+                env.test.expect(
+                    roundToScale(difference, loanScale) >= beast::kZero,
+                    "Balance before: " + to_string(balanceBefore.value()) +
+                        ", expected change: " + to_string(balanceChangeAmount) +
+                        ", difference (balance after - expected): " + to_string(difference),
+                    __FILE__,
+                    __LINE__);
+            }
+        }
+
+        /**
+         * Checks both the loan and broker expect states against the ledger
+         */
+        void
+        operator()(
+            std::uint32_t previousPaymentDate,
+            std::uint32_t nextPaymentDate,
+            std::uint32_t paymentRemaining,
+            Number const& loanScale,
+            Number const& totalValue,
+            Number const& principalOutstanding,
+            Number const& managementFeeOutstanding,
+            Number const& periodicPayment,
+            std::uint32_t flags) const
+        {
+            using namespace jtx;
+            if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan))
+            {
+                env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate);
+                env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining);
+                env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate);
+                env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale);
+                env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue);
+                env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding);
+                env.test.BEAST_EXPECT(
+                    loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding);
+                env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment);
+                env.test.BEAST_EXPECT(loan->at(sfFlags) == flags);
+
+                auto const ls = constructLoanState(loan);
+
+                auto const interestRate = TenthBips32{loan->at(sfInterestRate)};
+                auto const paymentInterval = loan->at(sfPaymentInterval);
+                checkBroker(
+                    principalOutstanding,
+                    ls.interestDue,
+                    interestRate,
+                    paymentInterval,
+                    paymentRemaining,
+                    1);
+
+                if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+                    env.test.BEAST_EXPECT(brokerSle))
+                {
+                    if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
+                        env.test.BEAST_EXPECT(vaultSle))
+                    {
+                        if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u))
+                        {
+                            env.test.BEAST_EXPECT(
+                                vaultSle->at(sfLossUnrealized) ==
+                                (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+                                         getVaultVersion(vaultSle) == VaultVersion::CashBasis
+                                     ? principalOutstanding
+                                     : totalValue - managementFeeOutstanding));
+                        }
+                        else
+                        {
+                            env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+                        }
+                    }
+                }
+            }
+        }
+
+        /**
+         * Checks both the loan and broker expect states against the ledger
+         */
+        void
+        operator()(LoanState const& state) const
+        {
+            operator()(
+                state.previousPaymentDate,
+                state.nextPaymentDate,
+                state.paymentRemaining,
+                state.loanScale,
+                state.totalValue,
+                state.principalOutstanding,
+                state.managementFeeOutstanding,
+                state.periodicPayment,
+                state.flags);
+        };
+    };
+
+    BrokerInfo
+    createVaultAndBroker(
+        jtx::Env& env,
+        jtx::PrettyAsset const& asset,
+        jtx::Account const& lender,
+        BrokerParameters const& params = BrokerParameters::defaults())
+    {
+        using namespace jtx;
+
+        Vault const vault{env};
+
+        auto const deposit = asset(params.vaultDeposit);
+        auto const debtMaximumValue = asset(params.debtMax).value();
+        auto const coverDepositValue = asset(params.coverDeposit).value();
+
+        auto const coverRateMinValue = params.coverRateMin;
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults. Many callers of this
+        // helper leave vaultKind at the OpenEnded default and don't care
+        // about the vault kind per se — they just need a broker on a
+        // vault. When LP V1.1 is enabled, transparently promote to
+        // ClosedEnded so those tests keep working without threading
+        // vaultKind through every call site. Callers that explicitly
+        // asked for ClosedEnded are left untouched. Tests that want to
+        // exercise the open-ended rejection under LP V1.1 build their own
+        // vault directly instead of going through this helper, since it
+        // always promotes OpenEnded once the amendment is enabled.
+        auto effectiveVaultKind = params.vaultKind;
+        if (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+            effectiveVaultKind == VaultKind::OpenEnded)
+        {
+            effectiveVaultKind = VaultKind::ClosedEnded;
+        }
+
+        std::optional subscriptionDate;
+        std::optional redemptionDate;
+        if (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);
+        env.close();
+        BEAST_EXPECT(env.le(vaultKeylet));
+
+        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
+        env.close();
+        if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault))
+        {
+            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;
+        env(set(lender, vaultKeylet.key, params.flags),
+            kData(params.data),
+            kManagementFeeRate(params.managementFeeRate),
+            kDebtMaximum(debtMaximumValue),
+            kCoverRateMinimum(coverRateMinValue),
+            kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation)));
+
+        if (coverDepositValue != beast::kZero)
+            env(coverDeposit(lender, keylet.key, coverDepositValue));
+
+        env.close();
+
+        return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate};
+    }
+
+    /**
+     * Get the state without checking anything
+     */
+    LoanState
+    getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet)
+    {
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        // Lookup the current loan state
+        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
+        {
+            return LoanState{
+                .previousPaymentDate = loan->at(sfPreviousPaymentDueDate),
+                .startDate = tp{d{loan->at(sfStartDate)}},
+                .nextPaymentDate = loan->at(sfNextPaymentDueDate),
+                .paymentRemaining = loan->at(sfPaymentRemaining),
+                .loanScale = loan->at(sfLoanScale),
+                .totalValue = loan->at(sfTotalValueOutstanding),
+                .principalOutstanding = loan->at(sfPrincipalOutstanding),
+                .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding),
+                .periodicPayment = loan->at(sfPeriodicPayment),
+                .flags = loan->at(sfFlags),
+                .paymentInterval = loan->at(sfPaymentInterval),
+                .interestRate = TenthBips32{loan->at(sfInterestRate)},
+            };
+        }
+        return LoanState{};
+    }
+
+    /**
+     * Get the state and check the values against the parameters used in
+     * `lifecycle`
+     */
+    LoanState
+    getCurrentState(
+        jtx::Env const& env,
+        BrokerInfo const& broker,
+        Keylet const& loanKeylet,
+        VerifyLoanStatus const& verifyLoanStatus)
+    {
+        using namespace std::chrono_literals;
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        auto const state = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(state.previousPaymentDate == 0);
+        BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s);
+        BEAST_EXPECT(state.paymentRemaining == 12);
+        BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value());
+        BEAST_EXPECT(
+            state.loanScale >=
+            (broker.asset.integral()
+                 ? 0
+                 : std::max(broker.vaultScale(env), state.principalOutstanding.exponent())));
+        BEAST_EXPECT(state.paymentInterval == 600);
+        {
+            NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+            BEAST_EXPECT(
+                state.totalValue ==
+                roundToAsset(
+                    broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale));
+        }
+        BEAST_EXPECT(
+            state.managementFeeOutstanding ==
+            computeManagementFee(
+                broker.asset,
+                state.totalValue - state.principalOutstanding,
+                broker.params.managementFeeRate,
+                state.loanScale));
+
+        verifyLoanStatus(state);
+
+        return state;
+    }
+
+    bool
+    canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state)
+    {
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
+                BEAST_EXPECT(vaultSle))
+            {
+                // log << vaultSle->getJson() << std::endl;
+                auto const assetsUnavailable =
+                    vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable);
+                auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) +
+                    (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+                             getVaultVersion(vaultSle) == VaultVersion::CashBasis
+                         ? state.principalOutstanding
+                         : state.totalValue - state.managementFeeOutstanding);
+
+                if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable))
+                {
+                    return false;
+                }
+            }
+        }
+        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
+    jtx::PrettyAsset
+    createAsset(
+        jtx::Env& env,
+        AssetType assetType,
+        BrokerParameters const& brokerParams,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower)
+    {
+        using namespace jtx;
+
+        switch (assetType)
+        {
+            case AssetType::XRP:
+                // TODO: remove the factor, and set up loans in drops
+                return PrettyAsset{xrpIssue(), 1'000'000};
+
+            case AssetType::IOU: {
+                PrettyAsset const asset{issuer[iouCurrency_]};
+
+                auto const limit =
+                    asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit));
+                if (lender != issuer)
+                    env(trust(lender, limit));
+                if (borrower != issuer)
+                    env(trust(borrower, limit));
+
+                return asset;
+            }
+
+            case AssetType::MPT: {
+                // Enough to cover initial fees
+                if (!env.le(keylet::account(issuer)))
+                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer);
+                if (!env.le(keylet::account(lender)))
+                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender));
+                if (!env.le(keylet::account(borrower)))
+                    env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower));
+
+                MPTTester mptt{env, issuer, kMptInitNoFund};
+                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                // Scale the MPT asset so interest is interesting
+                PrettyAsset const asset{mptt.issuanceID(), 10'000};
+                // Need to do the authorization here because mptt isn't
+                // accessible outside
+                if (lender != issuer)
+                    mptt.authorize({.account = lender});
+                if (borrower != issuer)
+                    mptt.authorize({.account = borrower});
+
+                env.close();
+
+                return asset;
+            }
+
+            default:
+                throw std::runtime_error("Unknown asset type");
+        }
+    }
+
+    // Predicts the keylet of the next loan `broker` will originate, before
+    // that loan exists, by reading the broker's current LoanSequence.
+    Keylet
+    nextLoanKeylet(jtx::Env const& env, BrokerInfo const& broker)
+    {
+        auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerStateBefore))
+            return keylet::loan(broker.brokerID, SeqProxy::rawSequence(0));
+        auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
+        return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+    }
+
+    // Funds issuer/lender/borrower with XRP, creates an IOU asset issued by
+    // `issuer`, establishes trustlines for lender and borrower, and pays
+    // them starting balances. This is the exact setup shared by several of
+    // the fuzzer-derived regression tests below.
+    jtx::PrettyAsset
+    createFundedIouAsset(
+        jtx::Env& env,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower,
+        Number const& lenderPay = 100'000'000,
+        Number const& borrowerPay = 1'000'000)
+    {
+        using namespace jtx;
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env.close();
+
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000)));
+        env(trustLenderTx);
+        auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000)));
+        env(trustBorrowerTx);
+        auto payLenderTx = pay(issuer, lender, iouAsset(lenderPay));
+        env(payLenderTx);
+        auto payIssuerTx = pay(issuer, borrower, iouAsset(borrowerPay));
+        env(payIssuerTx);
+        env.close();
+
+        return iouAsset;
+    }
+
+    // Funds issuer/lender/borrower with XRP, sets DefaultRipple on the
+    // issuer, creates a "USD" IOU asset with a large trust limit, and pays
+    // lender/borrower starting balances. Shared setup for several
+    // overpayment/rounding regression tests below.
+    static jtx::PrettyAsset
+    createFundedRippleIouAsset(
+        jtx::Env& env,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower,
+        Number const& lenderPay = 1'000'000,
+        Number const& borrowerPay = 1'000'000)
+    {
+        using namespace jtx;
+
+        env.fund(XRP(1'000'000), issuer, lender, borrower);
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const iouAsset = issuer["USD"];
+        STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
+        env(trust(lender, iouLimit));
+        env(trust(borrower, iouLimit));
+        env(pay(issuer, lender, iouAsset(lenderPay)));
+        env(pay(issuer, borrower, iouAsset(borrowerPay)));
+        env.close();
+
+        return iouAsset;
+    }
+
+    // Returns the broker's pseudo-account, or `fallback` if the broker's
+    // ledger entry cannot be read.
+    jtx::Account
+    brokerPseudoAccount(jtx::Env const& env, BrokerInfo const& broker, jtx::Account const& fallback)
+    {
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return fallback;
+        auto const brokerPseudo = brokerSle->at(sfAccount);
+        return jtx::Account("Broker pseudo-account", brokerPseudo);
+    }
+
+    void
+    describeLoan(
+        jtx::Env& env,
+        BrokerParameters const& brokerParams,
+        LoanParameters const& loanParams,
+        AssetType assetType,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower)
+    {
+        using namespace jtx;
+
+        auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
+        auto const principal = asset(loanParams.principalRequest).number();
+        auto const interest = loanParams.interest.value_or(TenthBips32{});
+        auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval);
+        auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal);
+        auto const feeRate = brokerParams.managementFeeRate;
+        auto const props = computeLoanProperties(
+            env.current()->rules(),
+            asset,
+            principal,
+            interest,
+            interval,
+            total,
+            feeRate,
+            asset(brokerParams.vaultDeposit).number().exponent());
+        log << "Loan properties:\n"
+            << "\tPrincipal: " << principal << std::endl
+            << "\tInterest rate: " << interest << std::endl
+            << "\tPayment interval: " << interval << std::endl
+            << "\tManagement Fee Rate: " << feeRate << std::endl
+            << "\tTotal Payments: " << total << std::endl
+            << "\tPeriodic Payment: " << props.periodicPayment << std::endl
+            << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl
+            << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl
+            << "\tLoan Scale: " << props.loanScale << std::endl
+            << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl;
+
+        // checkGuards returns a TER, so success is 0
+        BEAST_EXPECT(!checkLoanGuards(
+            asset,
+            asset(loanParams.principalRequest).number(),
+            loanParams.interest.value_or(TenthBips32{}) != beast::kZero,
+            loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal),
+            props,
+            env.journal));
+    }
+
+    std::optional>
+    createLoan(
+        jtx::Env& env,
+        AssetType assetType,
+        BrokerParameters const& brokerParams,
+        LoanParameters const& loanParams,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower)
+    {
+        using namespace jtx;
+
+        // Enough to cover initial fees
+        env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer);
+        if (lender != issuer)
+            env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender));
+        if (borrower != issuer && borrower != lender)
+            env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower));
+
+        describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower);
+
+        // Make the asset
+        auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower);
+
+        env.close();
+        if (asset.native() || lender != issuer)
+        {
+            env(
+                pay((asset.native() ? env.master : issuer),
+                    lender,
+                    asset(brokerParams.vaultDeposit + brokerParams.coverDeposit)));
+        }
+        // Fund the borrower later once we know the total loan
+        // size
+
+        BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
+
+        auto const pseudoAcctOpt = [&]() -> std::optional {
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return std::nullopt;
+            auto const brokerPseudo = brokerSle->at(sfAccount);
+            return Account("Broker pseudo-account", brokerPseudo);
+        }();
+        if (!pseudoAcctOpt)
+            return std::nullopt;
+        Account const& pseudoAcct = *pseudoAcctOpt;
+
+        auto const loanKeyletOpt = [&]() -> std::optional {
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return std::nullopt;
+
+            // Broker has no loans
+            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
+
+            // The loan keylet is based on the LoanSequence of the
+            // _LOAN_BROKER_ object.
+            auto const loanSequence = brokerSle->at(sfLoanSequence);
+            return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
+        }();
+        if (!loanKeyletOpt)
+            return std::nullopt;
+        Keylet const& loanKeylet = *loanKeyletOpt;
+
+        env(loanParams(env, broker));
+
+        env.close();
+
+        return std::make_tuple(broker, loanKeylet, pseudoAcct);
+    }
+
+    static void
+    topUpBorrower(
+        jtx::Env& env,
+        BrokerInfo const& broker,
+        jtx::Account const& issuer,
+        jtx::Account const& borrower,
+        LoanState const& state,
+        std::optional const& servFee)
+    {
+        using namespace jtx;
+
+        STAmount const serviceFee = broker.asset(servFee.value_or(0));
+
+        // Ensure the borrower has enough funds to make the payments
+        // (including tx fees, if necessary)
+        auto const borrowerBalance = env.balance(borrower, broker.asset);
+
+        auto const baseFee = env.current()->fees().base;
+
+        // Add extra for transaction fees and reserves, if appropriate, or a
+        // tiny amount for the extra paid in each transaction
+        auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) +
+            (broker.asset.native() ? Number(
+                                         baseFee * state.paymentRemaining +
+                                         accountReserve(*env.current(), borrower.id(), env.journal))
+                                   : broker.asset(15).number());
+
+        auto const shortage = totalNeeded - borrowerBalance.number();
+
+        if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower))
+        {
+            env(
+                pay((broker.asset.native() ? env.master : issuer),
+                    borrower,
+                    STAmount{broker.asset, shortage}));
+        }
+    }
+
+    void
+    makeLoanPayments(
+        jtx::Env& env,
+        BrokerInfo const& broker,
+        LoanParameters const& loanParams,
+        Keylet const& loanKeylet,
+        VerifyLoanStatus const& verifyLoanStatus,
+        jtx::Account const& issuer,
+        jtx::Account const& lender,
+        jtx::Account const& borrower,
+        PaymentParameters const& paymentParams = PaymentParameters::defaults())
+    {
+        // Make all the individual payments
+        using namespace jtx;
+        using namespace jtx::loan;
+        using namespace std::chrono_literals;
+        using d = NetClock::duration;
+
+        bool const showStepBalances = paymentParams.showStepBalances;
+
+        auto const currencyLabel = getCurrencyLabel(broker.asset);
+
+        auto const baseFee = env.current()->fees().base;
+
+        env.close();
+        auto state = getCurrentState(env, broker, loanKeylet);
+
+        verifyLoanStatus(state);
+
+        STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0));
+
+        topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee);
+
+        // Periodic payment amount will consist of
+        // 1. principal outstanding (1000)
+        // 2. interest interest rate (at 12%)
+        // 3. payment interval (600s)
+        // 4. loan service fee (2)
+        // Calculate these values without the helper functions
+        // to verify they're working correctly The numbers in
+        // the below BEAST_EXPECTs may not hold across assets.
+        auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval);
+        STAmount const roundedPeriodicPayment{
+            broker.asset,
+            roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
+
+        if (!showStepBalances)
+        {
+            log << currencyLabel << " Payment components: "
+                << "Payments remaining, "
+                << "rawInterest, rawPrincipal, "
+                   "rawMFee, "
+                << "trackedValueDelta, trackedPrincipalDelta, "
+                   "trackedInterestDelta, trackedMgmtFeeDelta, special"
+                << std::endl;
+        }
+
+        // Include the service fee
+        STAmount const totalDue = roundToScale(
+            roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward);
+
+        auto currentRoundedState = constructLoanState(
+            state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
+        {
+            auto const raw = computeTheoreticalLoanState(
+                env.current()->rules(),
+                state.periodicPayment,
+                periodicRate,
+                state.paymentRemaining,
+                broker.params.managementFeeRate);
+
+            if (showStepBalances)
+            {
+                log << currencyLabel << " Starting loan balances: "
+                    << "\n\tTotal value: " << currentRoundedState.valueOutstanding
+                    << "\n\tPrincipal: " << currentRoundedState.principalOutstanding
+                    << "\n\tInterest: " << currentRoundedState.interestDue
+                    << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue
+                    << "\n\tPayments remaining " << state.paymentRemaining << std::endl;
+            }
+            else
+            {
+                log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", "
+                    << raw.interestDue << ", " << raw.principalOutstanding << ", "
+                    << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", "
+                    << currentRoundedState.principalOutstanding << ", "
+                    << currentRoundedState.interestDue << ", "
+                    << currentRoundedState.managementFeeDue << std::endl;
+            }
+        }
+
+        // Try to pay a little extra to show that it's _not_
+        // taken
+        auto const extraAmount = paymentParams.overpaymentExtra
+            ? broker.asset(*paymentParams.overpaymentExtra).value()
+            : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20});
+
+        STAmount const transactionAmount =
+            STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount;
+
+        auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number();
+        auto const initialState = state;
+        xrpl::detail::PaymentComponents totalPaid{
+            .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0};
+        Number totalInterestPaid = 0;
+        Number totalFeesPaid = 0;
+        std::size_t totalPaymentsMade = 0;
+
+        xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
+            env.current()->rules(),
+            state.periodicPayment,
+            periodicRate,
+            state.paymentRemaining,
+            broker.params.managementFeeRate);
+
+        auto validateBorrowerBalance = [&]() {
+            if (borrower == issuer || !paymentParams.validateBalances)
+                return;
+            auto const totalSpent =
+                (totalPaid.trackedValueDelta + totalFeesPaid +
+                 (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero));
+            BEAST_EXPECT(
+                env.balance(borrower, broker.asset).number() ==
+                borrowerInitialBalance - totalSpent);
+        };
+
+        auto const defaultRound = broker.asset.integral() ? 3 : 0;
+        auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) {
+            auto const p = places.value_or(defaultRound);
+            if (p == 0)
+                return n;
+            auto const factor = Number{1, p};
+            return (n * factor).truncate() / factor;
+        };
+        while (state.paymentRemaining > 0)
+        {
+            validateBorrowerBalance();
+            // Compute the expected principal amount
+            auto const paymentComponents = xrpl::detail::computePaymentComponents(
+                env.current()->rules(),
+                broker.asset.raw(),
+                state.loanScale,
+                state.totalValue,
+                state.principalOutstanding,
+                state.managementFeeOutstanding,
+                state.periodicPayment,
+                periodicRate,
+                state.paymentRemaining,
+                broker.params.managementFeeRate);
+
+            BEAST_EXPECT(
+                paymentComponents.trackedValueDelta <= roundedPeriodicPayment ||
+                (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final &&
+                 paymentComponents.trackedValueDelta >= roundedPeriodicPayment));
+            BEAST_EXPECT(
+                paymentComponents.trackedValueDelta ==
+                paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() +
+                    paymentComponents.trackedManagementFeeDelta);
+
+            xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
+                env.current()->rules(),
+                state.periodicPayment,
+                periodicRate,
+                state.paymentRemaining - 1,
+                broker.params.managementFeeRate);
+            xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
+            BEAST_EXPECT(
+                deltas.total() == deltas.principal + deltas.interest + deltas.managementFee);
+            BEAST_EXPECT(
+                paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                deltas.total() == state.periodicPayment ||
+                (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14);
+
+            if (!showStepBalances)
+            {
+                log << currencyLabel << " Payment components: " << state.paymentRemaining << ", "
+
+                    << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee
+                    << ", " << paymentComponents.trackedValueDelta << ", "
+                    << paymentComponents.trackedPrincipalDelta << ", "
+                    << paymentComponents.trackedInterestPart() << ", "
+                    << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* {
+                    if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final)
+                        return "final";
+                    if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra)
+                        return "extra";
+                    return "none";
+                }() << std::endl;
+            }
+
+            auto const totalDueAmount =
+                STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee};
+
+            if (paymentParams.validateBalances)
+            {
+                // Due to the rounding algorithms to keep the interest and
+                // principal in sync with "true" values, the computed amount
+                // may be a little less than the rounded fixed payment
+                // amount. For integral types, the difference should be < 3
+                // (1 unit for each of the interest and management fee). For
+                // IOUs, the difference should be dust.
+                Number const diff = totalDue - totalDueAmount;
+                BEAST_EXPECT(
+                    paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                    diff == beast::kZero ||
+                    (diff > beast::kZero &&
+                     ((broker.asset.integral() && (static_cast(diff) < 3)) ||
+                      (state.loanScale - diff.exponent() > 13))));
+
+                BEAST_EXPECT(
+                    paymentComponents.trackedPrincipalDelta >= beast::kZero &&
+                    paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
+                BEAST_EXPECT(
+                    paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final ||
+                    paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
+            }
+
+            auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
+
+            // Make the payment
+            env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags));
+
+            env.close(d{state.paymentInterval / 2});
+
+            if (paymentParams.validateBalances)
+            {
+                // Need to account for fees if the loan is in XRP
+                PrettyAmount adjustment = broker.asset(0);
+                if (broker.asset.native())
+                {
+                    adjustment = env.current()->fees().base;
+                }
+
+                // Check the result
+                verifyLoanStatus.checkPayment(
+                    state.loanScale,
+                    borrower,
+                    borrowerBalanceBeforePayment,
+                    totalDueAmount,
+                    adjustment);
+            }
+
+            if (showStepBalances)
+            {
+                auto const loanSle = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loanSle))
+                {
+                    // No reason for this not to exist
+                    return;
+                }
+                auto const current = constructLoanState(loanSle);
+                auto const errors = nextTrueState - current;
+                log << currencyLabel << " Loan balances: "
+                    << "\n\tAmount taken: " << paymentComponents.trackedValueDelta
+                    << "\n\tTotal value: " << current.valueOutstanding
+                    << " (true: " << truncate(nextTrueState.valueOutstanding)
+                    << ", error: " << truncate(errors.total())
+                    << ")\n\tPrincipal: " << current.principalOutstanding
+                    << " (true: " << truncate(nextTrueState.principalOutstanding)
+                    << ", error: " << truncate(errors.principal)
+                    << ")\n\tInterest: " << current.interestDue
+                    << " (true: " << truncate(nextTrueState.interestDue)
+                    << ", error: " << truncate(errors.interest)
+                    << ")\n\tMgmt fee: " << current.managementFeeDue
+                    << " (true: " << truncate(nextTrueState.managementFeeDue)
+                    << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining "
+                    << loanSle->at(sfPaymentRemaining) << std::endl;
+
+                currentRoundedState = current;
+            }
+
+            --state.paymentRemaining;
+            state.previousPaymentDate = state.nextPaymentDate;
+            if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final)
+            {
+                state.paymentRemaining = 0;
+                state.nextPaymentDate = 0;
+            }
+            else
+            {
+                state.nextPaymentDate += state.paymentInterval;
+            }
+            state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
+            state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
+            state.totalValue -= paymentComponents.trackedValueDelta;
+
+            if (paymentParams.validateBalances)
+                verifyLoanStatus(state);
+
+            totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
+            totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
+            totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta;
+            totalInterestPaid += paymentComponents.trackedInterestPart();
+            totalFeesPaid += serviceFee;
+            ++totalPaymentsMade;
+
+            currentTrueState = nextTrueState;
+        }
+        validateBorrowerBalance();
+
+        // Loan is paid off
+        BEAST_EXPECT(state.paymentRemaining == 0);
+        BEAST_EXPECT(state.principalOutstanding == 0);
+
+        auto const initialInterestDue = initialState.totalValue -
+            (initialState.principalOutstanding + initialState.managementFeeOutstanding);
+        if (paymentParams.validateBalances)
+        {
+            // Make sure all the payments add up
+            BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
+            BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
+            BEAST_EXPECT(
+                totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
+            // This is almost a tautology given the previous checks, but
+            // check it anyway for completeness.
+            BEAST_EXPECT(totalInterestPaid == initialInterestDue);
+            BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
+        }
+
+        if (showStepBalances)
+        {
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+            {
+                // No reason for this not to exist
+                return;
+            }
+            log << currencyLabel << " Total amounts paid: "
+                << "\n\tTotal value: " << totalPaid.trackedValueDelta
+                << " (initial: " << truncate(initialState.totalValue)
+                << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta)
+                << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta
+                << " (initial: " << truncate(initialState.principalOutstanding) << ", error: "
+                << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta)
+                << ")\n\tInterest: " << totalInterestPaid
+                << " (initial: " << truncate(initialInterestDue)
+                << ", error: " << truncate(initialInterestDue - totalInterestPaid)
+                << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta
+                << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: "
+                << truncate(
+                       initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta)
+                << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl;
+        }
+    }
+
+    void
+    runLoan(
+        AssetType assetType,
+        BrokerParameters const& brokerParams,
+        LoanParameters const& loanParams,
+        FeatureBitset features)
+    {
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        Env env(*this, features);
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+        auto pseudoAcct = std::get(*loanResult);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
+
+        makeLoanPayments(
+            env,
+            broker,
+            loanParams,
+            loanKeylet,
+            verifyLoanStatus,
+            issuer,
+            lender,
+            borrower,
+            PaymentParameters{.showStepBalances = true});
+    }
+
+    /**
+     * Runs through the complete lifecycle of a loan
+     *
+     * 1. Create a loan.
+     * 2. Test a bunch of transaction failure conditions.
+     * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done
+     *    depends on the callback. e.g. Default, Early payoff, make all the
+     * normal payments, etc.
+     * 4. Delete the loan. The loan will alternate between being deleted by the
+     *    lender and the borrower.
+     */
+    void
+    lifecycle(
+        std::string const& caseLabel,
+        char const* label,
+        jtx::Env& env,
+        Number const& loanAmount,
+        int interestExponent,
+        jtx::Account const& lender,
+        jtx::Account const& borrower,
+        jtx::Account const& evan,
+        BrokerInfo const& broker,
+        jtx::Account const& pseudoAcct,
+        std::uint32_t flags,
+        // The end of life callback is expected to take the loan to 0 payments
+        // remaining, one way or another
+        std::function
+            toEndOfLife)
+    {
+        auto const [keylet, loanSequence] = [&]() {
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+            {
+                // will be invalid
+                return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0));
+            }
+
+            // Broker has no loans
+            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
+
+            // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_
+            // object.
+            auto const loanSequence = brokerSle->at(sfLoanSequence);
+            return std::make_pair(
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)), loanSequence);
+        }();
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet);
+
+        // No loans yet
+        verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0);
+
+        if (!BEAST_EXPECT(loanSequence != 0))
+            return;
+
+        testcase << caseLabel << " " << label;
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        auto applyExponent = [interestExponent, this](TenthBips32 value) mutable {
+            BEAST_EXPECT(value > TenthBips32(0));
+            while (interestExponent > 0)
+            {
+                auto const oldValue = value;
+                value *= 10;
+                --interestExponent;
+                BEAST_EXPECT(value / 10 == oldValue);
+            }
+            while (interestExponent < 0)
+            {
+                auto const oldValue = value;
+                value /= 10;
+                ++interestExponent;
+                BEAST_EXPECT(value * 10 == oldValue);
+            }
+            return value;
+        };
+
+        auto const borrowerOwnerCount = env.ownerCount(borrower);
+
+        auto const loanSetFee = env.current()->fees().base * 2;
+        LoanParameters const loanParams{
+            .account = borrower,
+            .counter = lender,
+            .counterpartyExplicit = false,
+            .principalRequest = loanAmount,
+            .setFee = loanSetFee,
+            .originationFee = 1,
+            .serviceFee = 2,
+            .lateFee = 3,
+            .closeFee = 4,
+            .overFee = applyExponent(percentageToTenthBips(5) / 10),
+            .interest = applyExponent(percentageToTenthBips(12)),
+            // 2.4%
+            .lateInterest = applyExponent(percentageToTenthBips(24) / 10),
+            .closeInterest = applyExponent(percentageToTenthBips(36) / 10),
+            .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10),
+            .payTotal = 12,
+            .payInterval = 600,
+            .gracePd = 60,
+            .flags = flags,
+        };
+        Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value();
+        auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value();
+        auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value();
+        auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value();
+        auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value();
+
+        auto const borrowerStartbalance = env.balance(borrower, broker.asset);
+
+        auto createJtx = loanParams(env, broker);
+        // Successfully create a Loan
+        env(createJtx);
+
+        env.close();
+
+        auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count();
+
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1);
+        }
+
+        {
+            // Need to account for fees if the loan is in XRP
+            PrettyAmount adjustment = broker.asset(0);
+            if (broker.asset.native())
+            {
+                adjustment = 2 * env.current()->fees().base;
+            }
+
+            BEAST_EXPECT(
+                env.balance(borrower, broker.asset).value() ==
+                borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount -
+                    adjustment.value());
+        }
+
+        auto const loanFlags =
+            createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0);
+
+        if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
+        {
+            // log << "loan after create: " << to_string(loan->getJson())
+            //     << std::endl;
+            BEAST_EXPECT(
+                loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment));
+            BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence);
+            BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
+            BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID);
+            BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount);
+            BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount);
+            BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount);
+            BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount);
+            BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee);
+            BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest);
+            BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest);
+            BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest);
+            BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest);
+            BEAST_EXPECT(loan->at(sfStartDate) == startDate);
+            BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval);
+            BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd);
+            BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval);
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal);
+            BEAST_EXPECT(
+                loan->at(sfLoanScale) >=
+                (broker.asset.integral()
+                     ? 0
+                     : std::max(broker.vaultScale(env), principalRequestAmount.exponent())));
+            BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount);
+        }
+
+        auto state = getCurrentState(env, broker, keylet, verifyLoanStatus);
+
+        auto const loanProperties = computeLoanProperties(
+            env.current()->rules(),
+            broker.asset.raw(),
+            state.principalOutstanding,
+            state.interestRate,
+            state.paymentInterval,
+            state.paymentRemaining,
+            broker.params.managementFeeRate,
+            state.loanScale);
+
+        verifyLoanStatus(
+            0,
+            startDate + *loanParams.payInterval,
+            *loanParams.payTotal,
+            state.loanScale,
+            loanProperties.loanState.valueOutstanding,
+            principalRequestAmount,
+            loanProperties.loanState.managementFeeDue,
+            loanProperties.periodicPayment,
+            loanFlags | 0);
+
+        // Manage the loan
+        // no-op
+        env(manage(lender, keylet.key, 0));
+        {
+            // no flags
+            auto jt = manage(lender, keylet.key, 0);
+            jt.removeMember(sfFlags.getName());
+            env(jt);
+        }
+        // Only the lender can manage
+        env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION));
+        // unknown flags
+        env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG));
+        // combinations of flags are not allowed
+        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG));
+        env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG));
+        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG));
+        env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault),
+            Ter(temINVALID_FLAG));
+        // invalid loan ID
+        env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY));
+        // Loan is unimpaired, can't unimpair it again
+        env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+        // Loan is unimpaired, it can go into default, but only after it's past
+        // due
+        env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+
+        // Check the vault
+        bool const canImpair = canImpairLoan(env, broker, state);
+        // 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;
+
+        env.close();
+
+        verifyLoanStatus(
+            0,
+            nextDueDate,
+            *loanParams.payTotal,
+            loanProperties.loanScale,
+            loanProperties.loanState.valueOutstanding,
+            principalRequestAmount,
+            loanProperties.loanState.managementFeeDue,
+            loanProperties.periodicPayment,
+            loanFlags | 0);
+
+        // Can't delete the loan yet. It has payments remaining.
+        env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS));
+
+        if (BEAST_EXPECT(toEndOfLife))
+            toEndOfLife(keylet, verifyLoanStatus);
+        env.close();
+
+        // Verify the loan is at EOL
+        if (auto loan = env.le(keylet); BEAST_EXPECT(loan))
+        {
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0);
+            BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0);
+        }
+        auto const borrowerStartingBalance = env.balance(borrower, broker.asset);
+
+        // Try to delete the loan broker with an active loan
+        env(loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS));
+        // Ensure the above tx doesn't get ordered after the LoanDelete and
+        // delete our broker!
+        env.close();
+
+        // Test failure cases
+        env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG));
+        env(del(evan, keylet.key), Ter(tecNO_PERMISSION));
+        env(del(lender, broker.brokerID), Ter(tecNO_ENTRY));
+
+        // Delete the loan
+        // Either the borrower or the lender can delete the loan. Alternate
+        // between who does it across tests.
+        static unsigned kDeleteCounter = 0;
+        auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower;
+        env(del(deleter, keylet.key));
+        env.close();
+
+        PrettyAmount adjustment = broker.asset(0);
+        if (deleter == borrower)
+        {
+            // Need to account for fees if the loan is in XRP
+            if (broker.asset.native())
+            {
+                adjustment = env.current()->fees().base;
+            }
+        }
+
+        // No loans left
+        verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0);
+
+        BEAST_EXPECT(
+            env.balance(borrower, broker.asset).value() ==
+            borrowerStartingBalance.value() - adjustment);
+        BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount);
+
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
+        }
+    }
+
+    static std::string
+    getCurrencyLabel(Asset const& asset)
+    {
+        if (asset.native())
+            return "XRP";
+        if (asset.holds())
+            return "IOU";
+        if (asset.holds())
+            return "MPT";
+        return "Unknown";
+    }
+
+    /**
+     * Wrapper to run a series of lifecycle tests for a given asset and loan
+     * amount
+     *
+     * Will be used in the future to vary the loan parameters. For now, it is
+     * only called once.
+     *
+     * Tests a bunch of LoanSet failure conditions before lifecycle.
+     */
+    template 
+    void
+    testCaseWrapper(
+        jtx::Env& env,
+        jtx::MPTTester& mptt,
+        std::array const& assets,
+        BrokerInfo const& broker,
+        Number const& loanAmount,
+        int interestExponent)
+    {
+        using namespace jtx;
+        using namespace lending;
+
+        auto const& asset = broker.asset.raw();
+        auto const currencyLabel = getCurrencyLabel(asset);
+        auto const caseLabel = [&]() {
+            std::stringstream ss;
+            ss << "Lifecycle: " << loanAmount << " " << currencyLabel
+               << " Scale interest to: " << interestExponent << " ";
+            return ss.str();
+        }();
+        testcase << caseLabel;
+
+        using namespace loan;
+        using namespace std::chrono_literals;
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        Account const issuer{"issuer"};
+        // For simplicity, lender will be the sole actor for the vault &
+        // brokers.
+        Account const lender{"lender"};
+        // Borrower only wants to borrow
+        Account const borrower{"borrower"};
+        // Evan will attempt to be naughty
+        Account const evan{"evan"};
+        // Do not fund alice
+        Account const alice{"alice"};
+
+        Number const principalRequest = broker.asset(loanAmount).value();
+        Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0);
+        BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10);
+        Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value();
+        Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value();
+        Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value();
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+
+        auto const pseudoAcct = brokerPseudoAccount(env, broker, lender);
+
+        auto const baseFee = env.current()->fees().base;
+
+        auto badKeylet = keylet::vault(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
+        // Try some failure cases
+        // flags are checked first
+        env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee,
+            Ter(temINVALID_FLAG));
+
+        // field length validation
+        // sfData: good length, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kData(std::string(kMaxDataPayloadLength, 'X')),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfData: too long
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kData(std::string(kMaxDataPayloadLength + 1, 'Y')),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // field range validation
+        // sfOverpaymentFee: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kOverpaymentFee(kMaxOverpaymentFee),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfOverpaymentFee: too big
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kOverpaymentFee(kMaxOverpaymentFee + 1),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfInterestRate: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kInterestRate(kMaxInterestRate),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kInterestRate(TenthBips32(0)),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfInterestRate: too big
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(kMaxInterestRate + 1),
+            loanSetFee,
+            Ter(temINVALID));
+        // sfInterestRate: too small
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kInterestRate(TenthBips32(-1)),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfLateInterestRate: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kLateInterestRate(kMaxLateInterestRate),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kLateInterestRate(TenthBips32(0)),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfLateInterestRate: too big
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kLateInterestRate(kMaxLateInterestRate + 1),
+            loanSetFee,
+            Ter(temINVALID));
+        // sfLateInterestRate: too small
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kLateInterestRate(TenthBips32(-1)),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfCloseInterestRate: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kCloseInterestRate(kMaxCloseInterestRate),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kCloseInterestRate(TenthBips32(0)),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfCloseInterestRate: too big
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kCloseInterestRate(kMaxCloseInterestRate + 1),
+            loanSetFee,
+            Ter(temINVALID));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kCloseInterestRate(TenthBips32(-1)),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfOverpaymentInterestRate: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kOverpaymentInterestRate(kMaxOverpaymentInterestRate),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kOverpaymentInterestRate(TenthBips32(0)),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfOverpaymentInterestRate: too big
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1),
+            loanSetFee,
+            Ter(temINVALID));
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kOverpaymentInterestRate(TenthBips32(-1)),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfPaymentTotal: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kPaymentTotal(LoanSet::kMinPaymentTotal),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfPaymentTotal: too small (there is no max)
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(LoanSet::kMinPaymentTotal - 1),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfPaymentInterval: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kPaymentInterval(LoanSet::kMinPaymentInterval),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfPaymentInterval: too small (there is no max)
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentInterval(LoanSet::kMinPaymentInterval - 1),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // sfGracePeriod: good value, bad account
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, borrower),
+            kPaymentInterval(LoanSet::kMinPaymentInterval * 2),
+            kGracePeriod(LoanSet::kMinPaymentInterval * 2),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // sfGracePeriod: larger than paymentInterval
+        env(set(evan, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentInterval(LoanSet::kMinPaymentInterval * 2),
+            kGracePeriod(LoanSet::kMinPaymentInterval * 3),
+            loanSetFee,
+            Ter(temINVALID));
+
+        // insufficient fee - single sign
+        env(set(borrower, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, lender),
+            Ter(telINSUF_FEE_P));
+        // insufficient fee - multisign
+        env(signers(lender, 2, {{evan, 1}, {borrower, 1}}));
+        env(signers(borrower, 2, {{evan, 1}, {lender, 1}}));
+        env(set(borrower, broker.brokerID, principalRequest),
+            kCounterparty(lender),
+            Msig(evan, lender),
+            Msig(sfCounterpartySignature, evan, borrower),
+            Fee(env.current()->fees().base * 5 - 1),
+            Ter(telINSUF_FEE_P));
+        // Bad multisign signatures for borrower (Account)
+        env(set(borrower, broker.brokerID, principalRequest),
+            kCounterparty(lender),
+            Msig(alice, issuer),
+            Msig(sfCounterpartySignature, evan, borrower),
+            Fee(env.current()->fees().base * 5),
+            Ter(tefBAD_SIGNATURE));
+        // Bad multisign signatures for issuer (Counterparty)
+        env(set(borrower, broker.brokerID, principalRequest),
+            kCounterparty(lender),
+            Msig(evan, lender),
+            Msig(sfCounterpartySignature, alice, issuer),
+            Fee(env.current()->fees().base * 5 - 1),
+            Ter(tefBAD_SIGNATURE));
+        env(signers(lender, kNone));
+        env(signers(borrower, kNone));
+        // multisign sufficient fee, but no signers set up
+        env(set(borrower, broker.brokerID, principalRequest),
+            kCounterparty(lender),
+            Msig(evan, lender),
+            Msig(sfCounterpartySignature, evan, borrower),
+            Fee(env.current()->fees().base * 5),
+            Ter(tefNOT_MULTI_SIGNING));
+        // not the broker owner, no counterparty, not signed by broker
+        // owner
+        env(set(borrower, broker.brokerID, principalRequest),
+            Sig(sfCounterpartySignature, evan),
+            loanSetFee,
+            Ter(tefBAD_AUTH));
+        // not the broker owner, counterparty is borrower
+        env(set(evan, broker.brokerID, principalRequest),
+            kCounterparty(borrower),
+            Sig(sfCounterpartySignature, borrower),
+            loanSetFee,
+            Ter(tecNO_PERMISSION));
+        // not a LoanBroker object, no counterparty
+        env(set(lender, badKeylet.key, principalRequest),
+            Sig(sfCounterpartySignature, evan),
+            loanSetFee,
+            Ter(temBAD_SIGNER));
+        // not a LoanBroker object, counterparty is valid
+        env(set(lender, badKeylet.key, principalRequest),
+            kCounterparty(borrower),
+            Sig(sfCounterpartySignature, borrower),
+            loanSetFee,
+            Ter(tecNO_ENTRY));
+        // borrower doesn't exist
+        env(set(lender, broker.brokerID, principalRequest),
+            kCounterparty(alice),
+            Sig(sfCounterpartySignature, alice),
+            loanSetFee,
+            Ter(terNO_ACCOUNT));
+
+        // Request more funds than the vault has available
+        env(set(evan, broker.brokerID, totalVaultRequest + 1),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee,
+            Ter(tecINSUFFICIENT_FUNDS));
+
+        // Request more funds than the broker's first-loss capital can
+        // cover.
+        env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1),
+            Sig(sfCounterpartySignature, lender),
+            loanSetFee,
+            Ter(tecINSUFFICIENT_FUNDS));
+
+        // Frozen trust line / locked MPT issuance
+        // XRP can not be frozen, but run through the loop anyway to test
+        // the tecLIMIT_EXCEEDED case
+        {
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+
+            auto const vaultPseudo = [&]() {
+                auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID)));
+                if (!BEAST_EXPECT(vaultSle))
+                {
+                    // This will be wrong, but the test has failed anyway.
+                    return Account{lender};
+                }
+                auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount));
+                return vaultPseudo;
+            }();
+
+            auto const [freeze, deepfreeze, unfreeze, expectedResult] =
+                [&]() -> std::tuple<
+                          std::function,
+                          std::function,
+                          std::function,
+                          TER> {
+                // Freeze / lock the asset
+                std::function const empty;
+                if (broker.asset.native())
+                {
+                    // XRP can't be frozen
+                    return std::make_tuple(empty, empty, empty, tesSUCCESS);
+                }
+                if (broker.asset.holds())
+                {
+                    auto freeze = [&](Account const& holder) {
+                        env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze));
+                    };
+                    auto deepfreeze = [&](Account const& holder) {
+                        env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
+                    };
+                    auto unfreeze = [&](Account const& holder) {
+                        env(trust(
+                            issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze));
+                    };
+                    return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN);
+                }
+
+                auto freeze = [&](Account const& holder) {
+                    mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock});
+                };
+                auto unfreeze = [&](Account const& holder) {
+                    mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock});
+                };
+                return std::make_tuple(freeze, empty, unfreeze, tecLOCKED);
+            }();
+
+            // Try freezing the accounts that can't be frozen
+            if (freeze)
+            {
+                for (auto const& account : {vaultPseudo, evan})
+                {
+                    // Freeze the account
+                    freeze(account);
+
+                    // Try to create a loan with a frozen line
+                    env(set(evan, broker.brokerID, debtMaximumRequest),
+                        Sig(sfCounterpartySignature, lender),
+                        loanSetFee,
+                        Ter(expectedResult));
+
+                    // Unfreeze the account
+                    BEAST_EXPECT(unfreeze);
+                    unfreeze(account);
+
+                    // Ensure the line is unfrozen with a request that is fine
+                    // except too it requests more principal than the broker can
+                    // carry
+                    env(set(evan, broker.brokerID, debtMaximumRequest + 1),
+                        Sig(sfCounterpartySignature, lender),
+                        loanSetFee,
+                        Ter(tecLIMIT_EXCEEDED));
+                }
+            }
+
+            // Deep freeze the borrower, which prevents them from receiving
+            // funds
+            if (deepfreeze)
+            {
+                // Make sure evan has a trust line that so the issuer can
+                // freeze it. (Don't need to do this for the borrower,
+                // because LoanSet will create a line to the borrower
+                // automatically.)
+                env(trust(evan, issuer[iouCurrency_](100'000)));
+
+                for (auto const& account : {// these accounts can't be frozen, which deep freeze
+                                            // implies
+                                            vaultPseudo,
+                                            evan,
+                                            // these accounts can't be deep frozen
+                                            lender})
+                {
+                    // Freeze evan
+                    deepfreeze(account);
+
+                    // Try to create a loan with a deep frozen line
+                    env(set(evan, broker.brokerID, debtMaximumRequest),
+                        Sig(sfCounterpartySignature, lender),
+                        loanSetFee,
+                        Ter(expectedResult));
+
+                    // Unfreeze evan
+                    BEAST_EXPECT(unfreeze);
+                    unfreeze(account);
+
+                    // Ensure the line is unfrozen with a request that is fine
+                    // except too it requests more principal than the broker can
+                    // carry
+                    env(set(evan, broker.brokerID, debtMaximumRequest + 1),
+                        Sig(sfCounterpartySignature, lender),
+                        loanSetFee,
+                        Ter(tecLIMIT_EXCEEDED));
+                }
+            }
+        }
+
+        // Finally! Create a loan
+
+        auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) {
+            if (auto const brokerSle = env.le(keylet::loanBroker(brokerID));
+                BEAST_EXPECT(brokerSle))
+            {
+                auto const available = brokerSle->at(sfCoverAvailable);
+                BEAST_EXPECT(available == expected);
+                return available;
+            }
+            return Number{};
+        };
+        auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) {
+            if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+                BEAST_EXPECT(brokerSle))
+            {
+                BEAST_EXPECT(
+                    state.loanScale >=
+                    (broker.asset.integral()
+                         ? 0
+                         : std::max(
+                               broker.vaultScale(env), state.principalOutstanding.exponent())));
+                NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+                auto const defaultAmount = roundToAsset(
+                    broker.asset,
+                    std::min(
+                        tenthBipsOfValue(
+                            tenthBipsOfValue(
+                                brokerSle->at(sfDebtTotal), broker.params.coverRateMin),
+                            broker.params.coverRateLiquidation),
+                        state.totalValue - state.managementFeeOutstanding),
+                    state.loanScale);
+                return std::make_pair(defaultAmount, brokerSle->at(sfOwner));
+            }
+            return std::make_pair(Number{}, AccountID{});
+        };
+        auto replenishCover = [&env, &coverAvailable](
+                                  BrokerInfo const& broker,
+                                  AccountID const& brokerAcct,
+                                  Number const& startingCoverAvailable,
+                                  Number const& amountToBeCovered) {
+            coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered);
+            env(loan_broker::coverDeposit(
+                brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered}));
+            coverAvailable(broker.brokerID, startingCoverAvailable);
+            env.close();
+        };
+
+        auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) {
+            return [&, impair, baseFlag](
+                       Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
+                // toEndOfLife
+                //
+                // Default the loan
+
+                // Initialize values with the current state
+                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
+                BEAST_EXPECT(state.flags == baseFlag);
+
+                auto const& broker = verifyLoanStatus.broker;
+                auto const startingCoverAvailable = coverAvailable(
+                    broker.brokerID, broker.asset(broker.params.coverDeposit).number());
+
+                if (impair)
+                {
+                    // 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));
+
+                    if (canImpair)
+                    {
+                        state.flags |= tfLoanImpair;
+                        // 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));
+                    }
+                    verifyLoanStatus(state);
+                }
+
+                auto const nextDueDate = tp{d{state.nextPaymentDate}};
+
+                // Can't default the loan yet. The grace period hasn't
+                // expired
+                env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+
+                // Let some time pass so that the loan can be
+                // defaulted
+                env.close(nextDueDate + 60s);
+
+                auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker);
+
+                // Default the loan
+                env(manage(lender, loanKeylet.key, tfLoanDefault));
+                env.close();
+
+                // The LoanBroker just lost some of it's first-loss capital.
+                // Replenish it.
+                replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered);
+
+                state.flags |= tfLoanDefault;
+                state.paymentRemaining = 0;
+                state.totalValue = 0;
+                state.principalOutstanding = 0;
+                state.managementFeeOutstanding = 0;
+                state.nextPaymentDate = 0;
+                verifyLoanStatus(state);
+
+                // Once a loan is defaulted, it can't be managed
+                env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+                env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
+                // Can't make a payment on it either
+                env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED));
+            };
+        };
+
+        auto singlePayment = [&](Keylet const& loanKeylet,
+                                 VerifyLoanStatus const& verifyLoanStatus,
+                                 LoanState& state,
+                                 STAmount const& payoffAmount,
+                                 std::uint32_t numPayments,
+                                 std::uint32_t baseFlag,
+                                 std::uint32_t txFlags) {
+            // toEndOfLife
+            //
+            verifyLoanStatus(state);
+
+            // Send some bogus pay transactions
+            env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags),
+                Ter(temINVALID));
+            // broker.asset(80) is less than a single payment, but all these
+            // checks fail before that matters
+            env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT));
+            env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY));
+            env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION));
+
+            // TODO: Write a general "isFlag" function? See STObject::isFlag.
+            // Maybe add a static overloaded member?
+            if (!(state.flags & lsfLoanOverpayment))
+            {
+                // If the loan does not allow overpayments, send a payment that
+                // tries to make an overpayment. Do not include `txFlags`, so we
+                // don't end up duplicating the next test transaction.
+                //
+                // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag:
+                // with fix on → preflight passes, apply returns tecNO_PERMISSION;
+                // with fix off → preflight rejects the flag, returns temINVALID_FLAG.
+                bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3);
+                STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}};
+                XRPAmount const overpayFee{
+                    baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)};
+                env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
+                    Fee(overpayFee),
+                    Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG}));
+
+                if (hasFix313)
+                {
+                    env.disableFeature(fixCleanup3_1_3);
+                    env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment),
+                        Fee(overpayFee),
+                        Ter(temINVALID_FLAG));
+                    env.enableFeature(fixCleanup3_1_3);
+                }
+            }
+            // Try to send a payment marked as multiple mutually exclusive
+            // payment types. Do not include `txFlags`, so we don't duplicate
+            // the prior test transaction.
+            env(pay(borrower,
+                    loanKeylet.key,
+                    broker.asset(state.periodicPayment * 2),
+                    tfLoanLatePayment | tfLoanFullPayment),
+                Ter(temINVALID_FLAG));
+            env(pay(borrower,
+                    loanKeylet.key,
+                    broker.asset(state.periodicPayment * 2),
+                    tfLoanLatePayment | tfLoanOverpayment),
+                Ter(temINVALID_FLAG));
+            env(pay(borrower,
+                    loanKeylet.key,
+                    broker.asset(state.periodicPayment * 2),
+                    tfLoanOverpayment | tfLoanFullPayment),
+                Ter(temINVALID_FLAG));
+            env(pay(borrower,
+                    loanKeylet.key,
+                    broker.asset(state.periodicPayment * 2),
+                    tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment),
+                Ter(temINVALID_FLAG));
+
+            {
+                auto const otherAsset =
+                    broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0];
+                env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET));
+            }
+
+            // Amount doesn't cover a single payment
+            env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags),
+                Ter(tecINSUFFICIENT_PAYMENT));
+
+            // Get the balance after these failed transactions take
+            // fees
+            auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
+
+            BEAST_EXPECT(payoffAmount > state.principalOutstanding);
+            // Try to pay a little extra to show that it's _not_
+            // taken
+            auto const transactionAmount = payoffAmount + broker.asset(10);
+
+            // Send a transaction that tries to pay more than the borrowers's
+            // balance
+            XRPAmount const badFee{
+                baseFee *
+                (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment /
+                     kLoanPaymentsPerFeeIncrement +
+                 1)};
+            env(pay(borrower,
+                    loanKeylet.key,
+                    STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2},
+                    txFlags),
+                Fee(badFee),
+                Ter(tecINSUFFICIENT_FUNDS));
+
+            XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)};
+            env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee));
+
+            env.close();
+
+            // log << env.meta()->getJson() << std::endl;
+
+            // Need to account for fees if the loan is in XRP
+            PrettyAmount adjustment = broker.asset(0);
+            if (broker.asset.native())
+            {
+                adjustment = badFee + goodFee;
+            }
+
+            state.paymentRemaining = 0;
+            state.principalOutstanding = 0;
+            state.totalValue = 0;
+            state.managementFeeOutstanding = 0;
+            state.previousPaymentDate =
+                state.nextPaymentDate + (state.paymentInterval * (numPayments - 1));
+            state.nextPaymentDate = 0;
+            verifyLoanStatus(state);
+
+            verifyLoanStatus.checkPayment(
+                state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment);
+
+            // Can't impair or default a paid off loan
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
+        };
+
+        auto fullPayment = [&](std::uint32_t baseFlag) {
+            return [&, baseFlag](
+                       Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
+                // toEndOfLife
+                //
+                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
+                env.close(state.startDate + 20s);
+                auto const loanAge = (env.now() - state.startDate).count();
+                BEAST_EXPECT(loanAge == 30);
+
+                // Full payoff amount will consist of
+                // 1. principal outstanding (1000)
+                // 2. accrued interest (at 12%)
+                // 3. prepayment penalty (closeInterest at 3.6%)
+                // 4. close payment fee (4)
+                // Calculate these values without the helper functions
+                // to verify they're working correctly The numbers in
+                // the below BEAST_EXPECTs may not hold across assets.
+                Number const interval = state.paymentInterval;
+                auto const periodicRate = interval * Number(12, -2) / kSecondsInYear;
+                BEAST_EXPECT(
+                    periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{}));
+                STAmount const principalOutstanding{broker.asset, state.principalOutstanding};
+                STAmount const accruedInterest{
+                    broker.asset, state.principalOutstanding * periodicRate * loanAge / interval};
+                BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19)));
+                STAmount const prepaymentPenalty{
+                    broker.asset, state.principalOutstanding * Number(36, -3)};
+                BEAST_EXPECT(prepaymentPenalty == broker.asset(36));
+                STAmount const closePaymentFee = broker.asset(4);
+                auto const payoffAmount = roundToScale(
+                    principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee,
+                    state.loanScale);
+                BEAST_EXPECT(
+                    payoffAmount ==
+                    roundToAsset(
+                        broker.asset,
+                        broker.asset(Number(1040000114155251, -12)).number(),
+                        state.loanScale));
+
+                // The terms of this loan actually make the early payoff
+                // more expensive than just making payments
+                BEAST_EXPECT(
+                    payoffAmount >
+                    state.paymentRemaining * (state.periodicPayment + broker.asset(2).value()));
+
+                singlePayment(
+                    loanKeylet,
+                    verifyLoanStatus,
+                    state,
+                    payoffAmount,
+                    1,
+                    baseFlag,
+                    tfLoanFullPayment);
+            };
+        };
+
+        auto combineAllPayments = [&](std::uint32_t baseFlag) {
+            return
+                [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
+                    // toEndOfLife
+                    //
+
+                    auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
+                    env.close();
+
+                    BEAST_EXPECT(
+                        STAmount(broker.asset, state.periodicPayment) ==
+                        broker.asset(Number(8333457002039338267, -17)));
+
+                    // Make all the payments in one transaction
+                    // service fee is 2
+                    auto const startingPayments = state.paymentRemaining;
+                    STAmount const payoffAmount = [&]() {
+                        NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
+                        auto const rawPayoff =
+                            startingPayments * (state.periodicPayment + broker.asset(2).value());
+                        STAmount payoffAmount{broker.asset, rawPayoff};
+                        BEAST_EXPECTS(
+                            payoffAmount == broker.asset(Number(1024014840244721, -12)),
+                            to_string(payoffAmount));
+                        BEAST_EXPECT(payoffAmount > state.principalOutstanding);
+
+                        payoffAmount = roundToScale(payoffAmount, state.loanScale);
+
+                        return payoffAmount;
+                    }();
+
+                    auto const totalPayoffValue =
+                        state.totalValue + startingPayments * broker.asset(2).value();
+                    STAmount const totalPayoffAmount{broker.asset, totalPayoffValue};
+
+                    BEAST_EXPECTS(
+                        totalPayoffAmount == payoffAmount,
+                        "Payoff amount: " + to_string(payoffAmount) +
+                            ". Total Value: " + to_string(totalPayoffAmount));
+
+                    singlePayment(
+                        loanKeylet,
+                        verifyLoanStatus,
+                        state,
+                        payoffAmount,
+                        state.paymentRemaining,
+                        baseFlag,
+                        0);
+                };
+        };
+
+        // There are a lot of fields that can be set on a loan, but most
+        // of them only affect the "math" when a payment is made. The
+        // only one that really affects behavior is the
+        // `tfLoanOverpayment` flag.
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Impair and Default",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            defaultImmediately(lsfLoanOverpayment));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Impair and Default",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            0,
+            defaultImmediately(0));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Default without Impair",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            defaultImmediately(lsfLoanOverpayment, false));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Default without Impair",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            0,
+            defaultImmediately(0, false));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Pay off immediately",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            0,
+            fullPayment(0));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Pay off immediately",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            fullPayment(lsfLoanOverpayment));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Combine all payments",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            0,
+            combineAllPayments(0));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Combine all payments",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            combineAllPayments(lsfLoanOverpayment));
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Make payments",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            0,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
+                // toEndOfLife
+                //
+                // Draw and make multiple payments
+                auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus);
+                BEAST_EXPECT(state.flags == 0);
+                env.close();
+
+                verifyLoanStatus(state);
+
+                env.close(state.startDate + 20s);
+                auto const loanAge = (env.now() - state.startDate).count();
+                BEAST_EXPECT(loanAge == 30);
+
+                // Periodic payment amount will consist of
+                // 1. principal outstanding (1000)
+                // 2. interest interest rate (at 12%)
+                // 3. payment interval (600s)
+                // 4. loan service fee (2)
+                // Calculate these values without the helper functions
+                // to verify they're working correctly The numbers in
+                // the below BEAST_EXPECTs may not hold across assets.
+                Number const interval = state.paymentInterval;
+                auto const periodicRate = interval * Number(12, -2) / kSecondsInYear;
+                BEAST_EXPECT(
+                    periodicRate == Number(2283105022831050228, -24, Number::Normalized{}));
+                STAmount const roundedPeriodicPayment{
+                    broker.asset,
+                    roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
+
+                testcase << currencyLabel << " Payment components: "
+                         << "Payments remaining, rawInterest, rawPrincipal, "
+                            "rawMFee, trackedValueDelta, trackedPrincipalDelta, "
+                            "trackedInterestDelta, trackedMgmtFeeDelta, special";
+
+                auto const serviceFee = broker.asset(2);
+
+                BEAST_EXPECT(
+                    roundedPeriodicPayment ==
+                    roundToScale(
+                        broker.asset(
+                            Number(8333457002039338267, -17), Number::RoundingMode::Upward),
+                        state.loanScale,
+                        Number::RoundingMode::Upward));
+                // 83334570.01162141
+                // Include the service fee
+                STAmount const totalDue = roundToScale(
+                    roundedPeriodicPayment + serviceFee,
+                    state.loanScale,
+                    Number::RoundingMode::Upward);
+                // Only check the first payment since the rounding
+                // may drift as payments are made
+                BEAST_EXPECT(
+                    totalDue ==
+                    roundToScale(
+                        broker.asset(
+                            Number(8533457002039338267, -17), Number::RoundingMode::Upward),
+                        state.loanScale,
+                        Number::RoundingMode::Upward));
+
+                {
+                    auto const raw = computeTheoreticalLoanState(
+                        env.current()->rules(),
+                        state.periodicPayment,
+                        periodicRate,
+                        state.paymentRemaining,
+                        broker.params.managementFeeRate);
+                    auto const rounded = constructLoanState(
+                        state.totalValue,
+                        state.principalOutstanding,
+                        state.managementFeeOutstanding);
+                    testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining
+                             << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", "
+                             << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", "
+                             << rounded.principalOutstanding << ", " << rounded.interestDue << ", "
+                             << rounded.managementFeeDue;
+                }
+
+                // Try to pay a little extra to show that it's _not_
+                // taken
+                STAmount const transactionAmount =
+                    STAmount{broker.asset, totalDue} + broker.asset(10);
+                // Only check the first payment since the rounding
+                // may drift as payments are made
+                BEAST_EXPECT(
+                    transactionAmount ==
+                    roundToScale(
+                        broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward),
+                        state.loanScale,
+                        Number::RoundingMode::Upward));
+
+                auto const initialState = state;
+                xrpl::detail::PaymentComponents totalPaid{
+                    .trackedValueDelta = 0,
+                    .trackedPrincipalDelta = 0,
+                    .trackedManagementFeeDelta = 0};
+                Number totalInterestPaid = 0;
+                std::size_t totalPaymentsMade = 0;
+
+                xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
+                    env.current()->rules(),
+                    state.periodicPayment,
+                    periodicRate,
+                    state.paymentRemaining,
+                    broker.params.managementFeeRate);
+
+                while (state.paymentRemaining > 0)
+                {
+                    // Compute the expected principal amount
+                    auto const paymentComponents = xrpl::detail::computePaymentComponents(
+                        env.current()->rules(),
+                        broker.asset.raw(),
+                        state.loanScale,
+                        state.totalValue,
+                        state.principalOutstanding,
+                        state.managementFeeOutstanding,
+                        state.periodicPayment,
+                        periodicRate,
+                        state.paymentRemaining,
+                        broker.params.managementFeeRate);
+
+                    BEAST_EXPECTS(
+                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                            paymentComponents.trackedValueDelta <= roundedPeriodicPayment,
+                        "Delta: " + to_string(paymentComponents.trackedValueDelta) +
+                            ", periodic payment: " + to_string(roundedPeriodicPayment));
+
+                    xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
+                        env.current()->rules(),
+                        state.periodicPayment,
+                        periodicRate,
+                        state.paymentRemaining - 1,
+                        broker.params.managementFeeRate);
+                    xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState;
+
+                    testcase << currencyLabel << " Payment components: " << state.paymentRemaining
+                             << ", " << deltas.interest << ", " << deltas.principal << ", "
+                             << deltas.managementFee << ", " << paymentComponents.trackedValueDelta
+                             << ", " << paymentComponents.trackedPrincipalDelta << ", "
+                             << paymentComponents.trackedInterestPart() << ", "
+                             << paymentComponents.trackedManagementFeeDelta << ", "
+                             << [&]() -> char const* {
+                        if (paymentComponents.specialCase ==
+                            ::xrpl::detail::PaymentSpecialCase::Final)
+                            return "final";
+                        if (paymentComponents.specialCase ==
+                            ::xrpl::detail::PaymentSpecialCase::Extra)
+                            return "extra";
+                        return "none";
+                    }();
+
+                    auto const totalDueAmount = STAmount{
+                        broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()};
+
+                    // Due to the rounding algorithms to keep the interest and
+                    // principal in sync with "true" values, the computed amount
+                    // may be a little less than the rounded fixed payment
+                    // amount. For integral types, the difference should be < 3
+                    // (1 unit for each of the interest and management fee). For
+                    // IOUs, the difference should be after the 8th digit.
+                    Number const diff = totalDue - totalDueAmount;
+                    BEAST_EXPECT(
+                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                        diff == beast::kZero ||
+                        (diff > beast::kZero &&
+                         ((broker.asset.integral() && (static_cast(diff) < 3)) ||
+                          (state.loanScale - diff.exponent() > 13))));
+
+                    BEAST_EXPECT(
+                        paymentComponents.trackedValueDelta ==
+                        paymentComponents.trackedPrincipalDelta +
+                            paymentComponents.trackedInterestPart() +
+                            paymentComponents.trackedManagementFeeDelta);
+                    BEAST_EXPECT(
+                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                        paymentComponents.trackedValueDelta <= roundedPeriodicPayment);
+
+                    BEAST_EXPECT(
+                        state.paymentRemaining < 12 ||
+                        roundToAsset(
+                            broker.asset,
+                            deltas.principal,
+                            state.loanScale,
+                            Number::RoundingMode::Upward) ==
+                            roundToScale(
+                                broker.asset(
+                                    Number(8333228691531218890, -17), Number::RoundingMode::Upward),
+                                state.loanScale,
+                                Number::RoundingMode::Upward));
+                    BEAST_EXPECT(
+                        paymentComponents.trackedPrincipalDelta >= beast::kZero &&
+                        paymentComponents.trackedPrincipalDelta <= state.principalOutstanding);
+                    BEAST_EXPECT(
+                        paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final ||
+                        paymentComponents.trackedPrincipalDelta == state.principalOutstanding);
+                    BEAST_EXPECT(
+                        paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final ||
+                        (state.periodicPayment.exponent() -
+                         (deltas.principal + deltas.interest + deltas.managementFee -
+                          state.periodicPayment)
+                             .exponent()) > 14);
+
+                    auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
+
+                    // 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));
+                    }
+
+                    env.close();
+
+                    // Make the payment
+                    env(pay(borrower, loanKeylet.key, transactionAmount));
+
+                    env.close();
+
+                    // Need to account for fees if the loan is in XRP
+                    PrettyAmount adjustment = broker.asset(0);
+                    if (broker.asset.native())
+                    {
+                        adjustment = env.current()->fees().base;
+                    }
+
+                    // Check the result
+                    verifyLoanStatus.checkPayment(
+                        state.loanScale,
+                        borrower,
+                        borrowerBalanceBeforePayment,
+                        totalDueAmount,
+                        adjustment);
+
+                    --state.paymentRemaining;
+                    state.previousPaymentDate = state.nextPaymentDate;
+                    if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final)
+                    {
+                        state.paymentRemaining = 0;
+                        state.nextPaymentDate = 0;
+                    }
+                    else
+                    {
+                        state.nextPaymentDate += state.paymentInterval;
+                    }
+                    state.principalOutstanding -= paymentComponents.trackedPrincipalDelta;
+                    state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta;
+                    state.totalValue -= paymentComponents.trackedValueDelta;
+
+                    verifyLoanStatus(state);
+
+                    totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta;
+                    totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta;
+                    totalPaid.trackedManagementFeeDelta +=
+                        paymentComponents.trackedManagementFeeDelta;
+                    totalInterestPaid += paymentComponents.trackedInterestPart();
+                    ++totalPaymentsMade;
+
+                    currentTrueState = nextTrueState;
+                }
+
+                // Loan is paid off
+                BEAST_EXPECT(state.paymentRemaining == 0);
+                BEAST_EXPECT(state.principalOutstanding == 0);
+
+                // Make sure all the payments add up
+                BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue);
+                BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding);
+                BEAST_EXPECT(
+                    totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding);
+                // This is almost a tautology given the previous checks, but
+                // check it anyway for completeness.
+                BEAST_EXPECT(
+                    totalInterestPaid ==
+                    initialState.totalValue -
+                        (initialState.principalOutstanding +
+                         initialState.managementFeeOutstanding));
+                BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining);
+
+                // Can't impair or default a paid off loan
+                env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
+                env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
+            });
+
+#if LOAN_TODO
+        // TODO
+
+        /*
+        LoanPay fails with tecINVARIANT_FAILED  error when loan_broker(also
+        borrower) tries to do the payment. Here's the scenario: Create a XRP
+        loan with loan broker as borrower, loan origination fee and loan service
+        fee. Loan broker makes the first payment with periodic payment and loan
+        service fee.
+        */
+
+        auto time = [&](std::string label, std::function timed) {
+            if (!BEAST_EXPECT(timed))
+                return;
+
+            using clock_type = std::chrono::steady_clock;
+            using duration_type = std::chrono::milliseconds;
+
+            auto const start = clock_type::now();
+            timed();
+            auto const duration =
+                std::chrono::duration_cast(clock_type::now() - start);
+
+            log << label << " took " << duration.count() << "ms" << std::endl;
+
+            return duration;
+        };
+
+        lifecycle(
+            caseLabel,
+            "timing",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) {
+                // Estimate optimal values for kLoanPaymentsPerFeeIncrement and
+                // kLoanMaximumPaymentsPerTransaction.
+                using namespace loan;
+
+                auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet);
+                auto const serviceFee = broker.asset(2).value();
+
+                STAmount const totalDue{
+                    broker.asset,
+                    roundPeriodicPayment(
+                        broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
+
+                // Make a single payment
+                time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); });
+                env.close();
+
+                // Make all but the final payment
+                auto const numPayments = (state.paymentRemaining - 2);
+                STAmount const bigPayment{broker.asset, totalDue * numPayments};
+                XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)};
+                time("ten payments", [&]() {
+                    env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee));
+                });
+                env.close();
+
+                time("final payment", [&]() {
+                    // Make the final payment
+                    env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1}));
+                });
+                env.close();
+            });
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Explicit overpayment",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment prohibited - Late payment",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Late payment",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
+
+        lifecycle(
+            caseLabel,
+            "Loan overpayment allowed - Late payment and overpayment",
+            env,
+            loanAmount,
+            interestExponent,
+            lender,
+            borrower,
+            evan,
+            broker,
+            pseudoAcct,
+            tfLoanOverpayment,
+            [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; });
+
+#endif
+    }
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
new file mode 100644
index 0000000000..566633b690
--- /dev/null
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -0,0 +1,625 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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 LoanValidation_test : public LoanTestBase
+{
+private:
+    void
+    testDisabled()
+    {
+        testcase("Disabled");
+        // Lending Protocol depends on Single Asset Vault (SAV). Test
+        // combinations of the two amendments.
+        // Single Asset Vault depends on MPTokensV1, but don't test every combo
+        // of that.
+        using namespace jtx;
+        auto failAll = [this](FeatureBitset features) {
+            Env env(*this, features);
+
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(10000), alice, bob);
+
+            auto const keylet = keylet::loanBroker(alice, SeqProxy::rawSequence(env.seq(alice)));
+
+            using namespace std::chrono_literals;
+            using namespace loan;
+
+            // counter party signature is optional on LoanSet. Confirm that by
+            // sending transaction without one.
+            auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED));
+            env(setTx);
+
+            // All loan transactions are disabled.
+            // 1. LoanSet
+            setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED));
+            env(setTx);
+            // Actual sequence will be based off the loan broker, but we
+            // obviously don't have one of those if the amendment is disabled
+            auto const loanKeylet = keylet::loan(keylet.key, SeqProxy::rawSequence(env.seq(alice)));
+            // Other Loan transactions are disabled, too.
+            // 2. LoanDelete
+            env(del(alice, loanKeylet.key), Ter(temDISABLED));
+            // 3. LoanManage
+            env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED));
+            // 4. LoanPay
+            env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED));
+        };
+        failAll(all_ - featureMPTokensV1);
+        failAll(all_ - featureSingleAssetVault - featureLendingProtocol);
+        failAll(all_ - featureSingleAssetVault);
+        failAll(all_ - featureLendingProtocol);
+    }
+
+    void
+    testInvalidLoanSet(VaultKind vaultKind)
+    {
+        testcase(
+            std::string("Invalid LoanSet (") +
+            (vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        Account const sponsor{"sponsor"};
+        auto const iou = issuer["IOU"];
+
+        auto testWrapper = [&](auto&& test) {
+            Env env(*this);
+            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, {.vaultKind = vaultKind})};
+
+            auto const loanSetFee = Fee(env.current()->fees().base * 2);
+            Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
+            test(env, brokerInfo, loanSetFee, debtMaximumRequest);
+        };
+
+        // preflight:
+        testWrapper([&](Env& env,
+                        BrokerInfo const& brokerInfo,
+                        jtx::Fee const& loanSetFee,
+                        Number const& debtMaximumRequest) {
+            for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee})
+            {
+                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                    sponsor::As(sponsor, sponsorFlags),
+                    Sig(sfCounterpartySignature, lender),
+                    loanSetFee,
+                    Ter(temINVALID_FLAG));
+            }
+
+            // first temBAD_SIGNER: TODO
+            // invalid grace period
+            {
+                // zero grace period
+                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                    Sig(sfCounterpartySignature, lender),
+                    kGracePeriod(0),
+                    loanSetFee,
+                    Ter(temINVALID));
+
+                // grace period less than default minimum
+                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                    Sig(sfCounterpartySignature, lender),
+                    kGracePeriod(LoanSet::kDefaultGracePeriod - 1),
+                    loanSetFee,
+                    Ter(temINVALID));
+
+                // grace period greater than payment interval
+                env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                    Sig(sfCounterpartySignature, lender),
+                    kPaymentInterval(120),
+                    kGracePeriod(121),
+                    loanSetFee,
+                    Ter(temINVALID));
+            }
+            // empty/zero broker ID
+            {
+                auto jv = set(borrower, uint256{}, debtMaximumRequest);
+
+                auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) {
+                    // empty broker ID
+                    jv[sfLoanBrokerID] = id;
+                    env(jv,
+                        Sig(sfCounterpartySignature, lender),
+                        loanSetFee,
+                        Txflags(flags),
+                        Ter(temINVALID));
+                };
+                // empty broker ID
+                testZeroBrokerID(std::string(""));
+                // zero broker ID
+                // needs a flag to distinguish the parsed STTx from the prior
+                // test
+                testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig);
+            }
+
+            // preflightCheckSigningKey() failure:
+            // can it happen? the signature is checked before transactor
+            // executes
+
+            JTx const tx = env.jt(
+                set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee);
+            STTx local = *(tx.stx);
+            auto counterpartySig = local.getFieldObject(sfCounterpartySignature);
+            auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey);
+            badPubKey[20] ^= 0xAA;
+            counterpartySig.setFieldVL(sfSigningPubKey, badPubKey);
+            local.setFieldObject(sfCounterpartySignature, counterpartySig);
+            json::Value jvResult;
+            jvResult[jss::tx_blob] = strHex(local.getSerializer().slice());
+            auto res = env.rpc("json", "submit", to_string(jvResult))["result"];
+            BEAST_EXPECT(
+                res[jss::error] == "invalidTransaction" &&
+                res[jss::error_exception] ==
+                    "fails local checks: Counterparty: Invalid signature.");
+        });
+
+        // preclaim:
+        testWrapper([&](Env& env,
+                        BrokerInfo const& brokerInfo,
+                        jtx::Fee const& loanSetFee,
+                        Number const& debtMaximumRequest) {
+            // canAddHoldingFailure (IOU only, if MPT doesn't have
+            // MPTCanTransfer set, then can't create Vault/LoanBroker,
+            // and LoanSet will fail with different error
+            env(fclear(issuer, asfDefaultRipple));
+            env.close();
+            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee,
+                Ter(terNO_RIPPLE));
+        });
+
+        // doApply:
+        testWrapper([&](Env& env,
+                        BrokerInfo const& brokerInfo,
+                        jtx::Fee const& loanSetFee,
+                        Number const& debtMaximumRequest) {
+            auto const amt =
+                env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal);
+            env(pay(borrower, issuer, amt));
+
+            // tecINSUFFICIENT_RESERVE
+            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee,
+                Ter(tecINSUFFICIENT_RESERVE));
+
+            // addEmptyHolding failure
+            env(pay(issuer, borrower, amt));
+            env(fset(issuer, asfGlobalFreeze));
+            env.close();
+
+            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee,
+                Ter(tecFROZEN));
+        });
+    }
+
+    void
+    testInvalidLoanDelete()
+    {
+        testcase("Invalid LoanDelete");
+        using namespace jtx;
+        using namespace loan;
+
+        // preflight: temINVALID, LoanID == zero
+        {
+            Account const alice{"alice"};
+            Env env(*this);
+            env.fund(XRP(1'000), alice);
+            env.close();
+            env(del(alice, beast::kZero), Ter(temINVALID));
+        }
+    }
+
+    void
+    testInvalidLoanManage()
+    {
+        testcase("Invalid LoanManage");
+        using namespace jtx;
+        using namespace loan;
+
+        // preflight: temINVALID, LoanID == zero
+        {
+            Account const alice{"alice"};
+            Env env(*this);
+            env.fund(XRP(1'000), alice);
+            env.close();
+            env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID));
+        }
+    }
+
+    void
+    testInvalidLoanPay()
+    {
+        testcase("Invalid LoanPay");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        auto const iou = issuer["IOU"];
+
+        // preclaim
+        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 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();
+
+        std::uint32_t const loanSequence = 1;
+        auto const loanKeylet =
+            keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(loanSequence));
+
+        env(fset(issuer, asfGlobalFreeze));
+        env.close();
+
+        // preclaim: tecFROZEN
+        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
+        env.close();
+
+        env(fclear(issuer, asfGlobalFreeze));
+        env.close();
+
+        auto const pseudoBroker = [&]() -> std::optional {
+            if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
+                BEAST_EXPECT(brokerSle))
+            {
+                return Account{"pseudo", brokerSle->at(sfAccount)};
+            }
+
+            return std::nullopt;
+        }();
+        if (!pseudoBroker)
+            return;
+
+        // Lender and pseudoaccount must both be frozen
+        env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze));
+        env(trust(
+            issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze));
+        env.close();
+
+        // preclaim: tecFROZEN due to deep frozen
+        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN));
+        env.close();
+
+        // Only one needs to be unfrozen
+        env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
+        env.close();
+
+        // 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));
+        env.close();
+
+        // preclaim: tecKILLED
+        // note that tecKILLED in loanMakePayment()
+        // doesn't happen because of the preclaim check.
+        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED));
+    }
+
+    void
+    testRequireAuth()
+    {
+        testcase("Require Auth - Implicit Pseudo-account authorization");
+        using namespace jtx;
+        using namespace loan;
+        Account const lender{"lender"};
+        Account const issuer{"issuer"};
+        Account const borrower{"borrower"};
+        Env env(*this);
+
+        env.fund(XRP(100'000), issuer, lender, borrower);
+        env.close();
+
+        auto asset = MPTTester({
+            .env = env,
+            .issuer = issuer,
+            .holders = {lender, borrower},
+            .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock,
+            .authHolder = true,
+        });
+
+        env(pay(issuer, lender, asset(5'000'000)));
+        BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)};
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value();
+
+        auto forUnauthAuth = [&](auto&& doTx) {
+            for (auto const flag : {tfMPTUnauthorize, 0u})
+            {
+                asset.authorize({.account = issuer, .holder = borrower, .flags = flag});
+                env.close();
+                doTx(flag == 0);
+                env.close();
+            }
+        };
+
+        // Can't create a loan if the borrower is not authorized
+        forUnauthAuth([&](bool authorized) {
+            auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
+            env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
+                Sig(sfCounterpartySignature, lender),
+                loanSetFee,
+                err);
+        });
+
+        static constexpr std::uint32_t kLoanSequence = 1;
+        auto const loanKeylet =
+            keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(kLoanSequence));
+
+        // Can't loan pay if the borrower is not authorized
+        forUnauthAuth([&](bool authorized) {
+            auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS);
+            env(pay(borrower, loanKeylet.key, debtMaximumRequest), err);
+        });
+    }
+
+    void
+    testLimitExceeded()
+    {
+        testcase("RIPD-4125 - overpayment");
+
+        using namespace jtx;
+
+        Account const issuer("issuer");
+        Account const lender("lender");
+        Account const borrower("borrower");
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 100'000,
+            .debtMax = 0,
+            .coverRateMin = TenthBips32{0},
+            .managementFeeRate = TenthBips16{0},
+            .coverRateLiquidation = TenthBips32{0}};
+        LoanParameters const loanParams{
+            .account = lender,
+            .counter = borrower,
+            .principalRequest = Number{200000, -6},
+            .interest = TenthBips32{50000},
+            .payTotal = 3,
+            .payInterval = 200,
+            .gracePd = 60,
+            .flags = tfLoanOverpayment,
+        };
+
+        auto const assetType = AssetType::XRP;
+
+        Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning);
+
+        auto loanResult =
+            createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower);
+
+        if (BEAST_EXPECT(loanResult); !loanResult.has_value())
+            return;
+
+        auto broker = std::get(*loanResult);
+        auto loanKeylet = std::get(*loanResult);
+        auto pseudoAcct = std::get(*loanResult);
+
+        VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet);
+
+        auto const state = getCurrentState(env, broker, loanKeylet);
+
+        env(loan::pay(
+            borrower,
+            loanKeylet.key,
+            STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1},
+            tfLoanOverpayment));
+        env.close();
+
+        PaymentParameters const paymentParams{
+            .showStepBalances = false,
+            .validateBalances = true,
+        };
+
+        makeLoanPayments(
+            env,
+            broker,
+            loanParams,
+            loanKeylet,
+            verifyLoanStatus,
+            issuer,
+            lender,
+            borrower,
+            paymentParams);
+    }
+
+    void
+    testWrongMaxDebtBehavior(FeatureBitset features)
+    {
+        // From FIND-003
+        testcase << "Wrong Max Debt Behavior";
+
+        using namespace jtx;
+        using namespace std::chrono_literals;
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+
+        BrokerParameters const brokerParams{.debtMax = 0};
+        env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender));
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+        BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
+
+        if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            BEAST_EXPECT(brokerSle))
+        {
+            BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0);
+        }
+
+        using namespace loan;
+
+        auto const loanSetFee = Fee(env.current()->fees().base * 2);
+        Number const principalRequest{1, 3};
+
+        // 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();
+        for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded})
+            testInvalidLoanSet(kind);
+        testInvalidLoanDelete();
+        testInvalidLoanManage();
+        testInvalidLoanPay();
+        testRequireAuth();
+        testLimitExceeded();
+        testLoanBrokerRequiresClosedEndedVault();
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testWrongMaxDebtBehavior(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        runAmendmentIndependent();
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            runAmendmentSensitive(features);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(LoanValidation, 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..43f2b0354c
--- /dev/null
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -0,0 +1,2772 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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: pre-fixCleanup3_4_0, a VaultWithdraw that both
+    // creates and deletes an MPToken fails. Post-fixCleanup3_4_0 that is
+    // allowed.
+
+    //
+    // 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..6909a1bffa
--- /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 instant interest recognition 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..8d7afe67f2
--- /dev/null
+++ b/src/test/app/vault/VaultLifecycle_test.cpp
@@ -0,0 +1,1856 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#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 redeemAllNoAssetMpt = [this](TER expected) {
+            return [this, expected](
+                       Env& env,
+                       Account const&,
+                       Account const& owner,
+                       Account const& depositor,
+                       Asset const& asset,
+                       Vault& vault,
+                       MPTTester& mptt) {
+                testcase << "MPT non-owner redeems all shares with no asset MPToken"
+                         << (isTesSuccess(expected) ? "" : " pre-fixCleanup3_4_0");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = depositor,
+                     .id = keylet.key,
+                     .amount = asset(1000)});  // all assets held by depositor
+                env(tx);
+                env.close();
+
+                auto const vaultSle = env.le(keylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                auto const shareMPTID = vaultSle->at(sfShareMPTID);
+
+                // Depositor's asset MPToken balance is now zero; delete it.
+                mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
+                env.close();
+
+                auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
+
+                auto const shareKeylet = keylet::mptoken(shareMPTID, depositor.id());
+                auto const sleShareBefore = env.le(shareKeylet);
+                if (!BEAST_EXPECT(sleShareBefore))
+                    return;
+                auto const shareAmountBefore = sleShareBefore->at(sfMPTAmount);
+                auto const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
+                auto const assetsAvailableBefore = vaultSle->at(sfAssetsAvailable);
+
+                // Redeeming ALL shares in one transaction both erases the
+                // now-empty share MPToken and re-creates the asset MPToken.
+                tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx, Ter{expected});
+                env.close();
+
+                auto const sleAsset = env.le(mptoken);
+                auto const sleShare = env.le(shareKeylet);
+                auto const vaultAfter = env.le(keylet);
+                if (!BEAST_EXPECT(vaultAfter))
+                    return;
+                if (isTesSuccess(expected))
+                {
+                    if (!BEAST_EXPECT(sleAsset))
+                        return;
+                    BEAST_EXPECT(sleAsset->at(sfMPTAmount) == 1000);
+                    BEAST_EXPECT(!sleShare);
+                    BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == beast::kZero);
+                    BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == beast::kZero);
+                }
+                else
+                {
+                    BEAST_EXPECT(!sleAsset);
+                    if (!BEAST_EXPECT(sleShare))
+                        return;
+                    BEAST_EXPECT(sleShare->at(sfMPTAmount) == shareAmountBefore);
+                    BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
+                    BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore);
+                }
+            };
+        };
+
+        testCase(redeemAllNoAssetMpt(tesSUCCESS), {.requireAuth = false});
+        testCase(
+            redeemAllNoAssetMpt(tecINVARIANT_FAILED),
+            {.requireAuth = false, .features = testableAmendments() - fixCleanup3_4_0});
+
+        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 41b5f81f5d..5e790810f7 100644
--- a/src/test/basics/PerfLog_test.cpp
+++ b/src/test/basics/PerfLog_test.cpp
@@ -1,9 +1,7 @@
 #include 
-#include 
 #include 
 
-#include 
-
+#include 
 #include 
 #include 
 #include 
@@ -15,14 +13,11 @@
 #include 
 #include 
 
-#include 
-#include 
-#include 
-#include 
-
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -30,7 +25,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -43,7 +41,22 @@ 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;
+
+    // The method names to count. PerfLog treats them as opaque keys, so these are
+    // made up rather than taken from the dispatch table: this test then needs no
+    // knowledge of the RPC layer, and does not change shape when a method is
+    // added or removed.
+    //
+    // String literals because PerfLog reads them back as C strings, which is what
+    // NullTerminatedView requires, and they must outlive the PerfLog. Sorted,
+    // because the counters are reported in sorted order.
+    static constexpr std::array kMethodNames{
+        NullTerminatedView{"method_a"},
+        NullTerminatedView{"method_b"},
+        NullTerminatedView{"method_c"},
+        NullTerminatedView{"method_d"},
+        NullTerminatedView{"method_e"}};
 
     // We're only using Env for its Journal.  That Journal gives better
     // coverage in unit tests.
@@ -66,14 +79,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 +109,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";
         }
 
@@ -117,7 +130,7 @@ class PerfLog_test : public beast::unit_test::Suite
         {
             perf::PerfLog::Setup const setup{
                 .perfLog = withFile == WithFile::No ? "" : logFile(), .logInterval = logInterval()};
-            return perf::makePerfLog(setup, app, j, [this]() {
+            return perf::makePerfLog(setup, app, kMethodNames, j, [this]() {
                 signalStop();
                 return;
             });
@@ -129,7 +142,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 +214,7 @@ public:
     void
     testFileCreation()
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         {
             // Verify a PerfLog creates its file when constructed.
@@ -250,28 +263,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 +310,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);
         }
     }
 
@@ -310,9 +326,11 @@ public:
         auto perfLog{fixture.perfLog(withFile)};
         perfLog->start();
 
-        // Get the all the labels we can use for RPC interfaces without
-        // causing an assert.
-        std::vector labels = test::jtx::makeVector(xrpl::RPC::getHandlerNames());
+        // The only labels the RPC interface accepts: those the PerfLog was
+        // constructed with, since rpcStart() reaches UNREACHABLE for any other.
+        // Copied into a vector because they are shuffled below, then paired
+        // positionally with the request ids.
+        auto labels = std::ranges::to(kMethodNames);
         std::shuffle(labels.begin(), labels.end(), defaultPrng());
 
         // Get two IDs to associate with each label.  Errors tend to happen at
@@ -347,7 +365,7 @@ public:
             for (auto& label : labels)
             {
                 // Expect every label in labels to have the same contents.
-                json::Value const& counter{countersJson[label]};
+                json::Value const& counter{countersJson[std::string{label}]};
                 BEAST_EXPECT(counter[jss::duration_us] == "0");
                 BEAST_EXPECT(counter[jss::errored] == "0");
                 BEAST_EXPECT(counter[jss::finished] == "0");
@@ -370,7 +388,7 @@ public:
             std::uint64_t prevDur = std::numeric_limits::max();
             for (int i = 0; i < currents.size(); ++i)
             {
-                BEAST_EXPECT(currents[i].name == labels[i / 2]);
+                BEAST_EXPECT(currents[i].name == labels[i / 2].view());
                 BEAST_EXPECT(prevDur > currents[i].dur);
                 prevDur = currents[i].dur;
             }
@@ -404,7 +422,7 @@ public:
             // their durations with the appropriate labels.
             {
                 // The first label is special.  It should have "errored" : "0".
-                json::Value const& first = rpc[labels[0]];
+                json::Value const& first = rpc[std::string{labels[0]}];
                 BEAST_EXPECT(first[jss::duration_us] != "0");
                 BEAST_EXPECT(first[jss::errored] == "0");
                 BEAST_EXPECT(first[jss::finished] == "1");
@@ -415,7 +433,7 @@ public:
             std::uint64_t prevDur = std::numeric_limits::max();
             for (int i = 1; i < labels.size(); ++i)
             {
-                json::Value const& counter{rpc[labels[i]]};
+                json::Value const& counter{rpc[std::string{labels[i]}]};
                 std::uint64_t const dur{jsonToUInt64(counter[jss::duration_us])};
                 BEAST_EXPECT(dur != 0 && dur < prevDur);
                 prevDur = dur;
@@ -447,7 +465,7 @@ public:
             BEAST_EXPECT(only.size() == 2);
             BEAST_EXPECT(only.isObject());
             BEAST_EXPECT(only[jss::duration_us] != "0");
-            BEAST_EXPECT(only[jss::method] == labels[0]);
+            BEAST_EXPECT(only[jss::method] == std::string{labels[0]});
         };
 
         // Validate the final state of the PerfLog.
@@ -483,7 +501,7 @@ public:
 
             json::Value parsedLastLine;
             json::Reader().parse(lastLine, parsedLastLine);
-            if (!BEAST_EXPECT(!RPC::containsError(parsedLastLine)))
+            if (!BEAST_EXPECT(!rpc::containsError(parsedLastLine)))
             {
                 // Avoid cascade of failures
                 return;
@@ -804,7 +822,7 @@ public:
 
             json::Value parsedLastLine;
             json::Reader().parse(lastLine, parsedLastLine);
-            if (!BEAST_EXPECT(!RPC::containsError(parsedLastLine)))
+            if (!BEAST_EXPECT(!rpc::containsError(parsedLastLine)))
             {
                 // Avoid cascade of failures
                 return;
@@ -944,7 +962,7 @@ public:
 
             json::Value parsedLastLine;
             json::Reader().parse(lastLine, parsedLastLine);
-            if (!BEAST_EXPECT(!RPC::containsError(parsedLastLine)))
+            if (!BEAST_EXPECT(!rpc::containsError(parsedLastLine)))
             {
                 // Avoid cascade of failures
                 return;
@@ -962,7 +980,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()));
@@ -1012,6 +1030,35 @@ public:
         }
     }
 
+    // makePerfLog() copies the range of names it is given, so only the names have
+    // to outlive the PerfLog. Here the range does not: it is destroyed before the
+    // counters are read. Retaining it instead is a use-after-free, which a
+    // sanitizer build reports directly and which otherwise surfaces as a failed
+    // assertion or a Debug-mode heap-corruption abort, not a silent pass.
+    void
+    testCallerRangeNeedNotOutlive()
+    {
+        testcase("Caller's range need not outlive the PerfLog");
+
+        Fixture const fixture{env_.app(), j_};
+
+        std::unique_ptr perfLog;
+        {
+            std::vector const names{kMethodNames.begin(), kMethodNames.end()};
+            perf::PerfLog::Setup const setup{.perfLog = "", .logInterval = fixture.logInterval()};
+            perfLog = perf::makePerfLog(setup, env_.app(), names, j_, []() {});
+        }
+
+        perfLog->start();
+        perfLog->rpcStart(kMethodNames[0], 1);
+        perfLog->rpcFinish(kMethodNames[0], 1);
+
+        // Reads the retained names, which is where a dangling range would surface.
+        json::Value const counters{perfLog->countersJson()[jss::rpc]};
+        BEAST_EXPECT(counters.isMember(std::string{kMethodNames[0].view()}));
+        perfLog->stop();
+    }
+
     void
     run() override
     {
@@ -1024,6 +1071,7 @@ public:
         testInvalidID(WithFile::Yes);
         testRotate(WithFile::No);
         testRotate(WithFile::Yes);
+        testCallerRangeNeedNotOutlive();
     }
 };
 
diff --git a/src/test/beast/IPEndpointCommon.h b/src/test/beast/IPEndpointCommon.h
index 45d036476c..6fb2bd9569 100644
--- a/src/test/beast/IPEndpointCommon.h
+++ b/src/test/beast/IPEndpointCommon.h
@@ -7,7 +7,7 @@
 
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 inline Endpoint
 randomEP(bool v4 = true)
@@ -44,4 +44,4 @@ randomEP(bool v4 = true)
         randInt(1, UINT16_MAX)};
 }
 
-}  // namespace beast::IP
+}  // namespace beast::ip
diff --git a/src/test/beast/IPEndpoint_test.cpp b/src/test/beast/IPEndpoint_test.cpp
index bc04087891..b61878aa76 100644
--- a/src/test/beast/IPEndpoint_test.cpp
+++ b/src/test/beast/IPEndpoint_test.cpp
@@ -22,7 +22,7 @@
 #include 
 #include 
 
-namespace beast::IP {
+namespace beast::ip {
 
 //------------------------------------------------------------------------------
 
@@ -475,4 +475,4 @@ public:
 
 BEAST_DEFINE_TESTSUITE(IPEndpoint, beast, beast);
 
-}  // namespace beast::IP
+}  // namespace beast::ip
diff --git a/src/test/beast/LexicalCast_test.cpp b/src/test/beast/LexicalCast_test.cpp
deleted file mode 100644
index b1d37daab8..0000000000
--- a/src/test/beast/LexicalCast_test.cpp
+++ /dev/null
@@ -1,280 +0,0 @@
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace beast {
-
-class LexicalCast_test : public unit_test::Suite
-{
-public:
-    template 
-    static IntType
-    nextRandomInt(xor_shift_engine& r)
-    {
-        return static_cast(r());
-    }
-
-    template 
-    void
-    testInteger(IntType in)
-    {
-        std::string s;
-        auto out = static_cast(~in);  // Ensure out != in
-
-        expect(lexicalCastChecked(s, in));
-        expect(lexicalCastChecked(out, s));
-        expect(out == in);
-    }
-
-    template 
-    void
-    testIntegers(xor_shift_engine& r)
-    {
-        {
-            std::stringstream ss;
-            ss << "random " << typeid(IntType).name();
-            testcase(ss.str());
-
-            for (int i = 0; i < 1000; ++i)
-            {
-                auto const value = nextRandomInt(r);
-                testInteger(value);
-            }
-        }
-
-        {
-            std::stringstream ss;
-            ss << "numeric_limits <" << typeid(IntType).name() << ">";
-            testcase(ss.str());
-
-            testInteger(std::numeric_limits::min());
-            testInteger(std::numeric_limits::max());
-        }
-    }
-
-    void
-    testPathologies()
-    {
-        testcase("pathologies");
-        try
-        {
-            lexicalCastThrow("\xef\xbc\x91\xef\xbc\x90");  // utf-8 encoded
-        }
-        catch (BadLexicalCast const&)
-        {
-            pass();
-        }
-    }
-
-    template 
-    void
-    tryBadConvert(std::string const& s)
-    {
-        T out;
-        expect(!lexicalCastChecked(out, s), s);
-    }
-
-    void
-    testConversionOverflows()
-    {
-        testcase("conversion overflows");
-
-        tryBadConvert("99999999999999999999");
-        tryBadConvert("4294967300");
-        tryBadConvert("75821");
-    }
-
-    void
-    testConversionUnderflows()
-    {
-        testcase("conversion underflows");
-
-        tryBadConvert("-1");
-
-        tryBadConvert("-99999999999999999999");
-        tryBadConvert("-4294967300");
-        tryBadConvert("-75821");
-    }
-
-    template 
-    bool
-    tryEdgeCase(std::string const& s)
-    {
-        T ret;
-
-        bool const result = lexicalCastChecked(ret, s);
-
-        if (!result)
-            return false;
-
-        return s == std::to_string(ret);
-    }
-
-    void
-    testEdgeCases()
-    {
-        testcase("conversion edge cases");
-
-        expect(tryEdgeCase("18446744073709551614"));
-        expect(tryEdgeCase("18446744073709551615"));
-        expect(!tryEdgeCase("18446744073709551616"));
-
-        expect(tryEdgeCase("9223372036854775806"));
-        expect(tryEdgeCase("9223372036854775807"));
-        expect(!tryEdgeCase("9223372036854775808"));
-
-        expect(tryEdgeCase("-9223372036854775807"));
-        expect(tryEdgeCase("-9223372036854775808"));
-        expect(!tryEdgeCase("-9223372036854775809"));
-
-        expect(tryEdgeCase("4294967294"));
-        expect(tryEdgeCase("4294967295"));
-        expect(!tryEdgeCase("4294967296"));
-
-        expect(tryEdgeCase("2147483646"));
-        expect(tryEdgeCase("2147483647"));
-        expect(!tryEdgeCase("2147483648"));
-
-        expect(tryEdgeCase("-2147483647"));
-        expect(tryEdgeCase("-2147483648"));
-        expect(!tryEdgeCase("-2147483649"));
-
-        expect(tryEdgeCase("65534"));
-        expect(tryEdgeCase("65535"));
-        expect(!tryEdgeCase("65536"));
-
-        expect(tryEdgeCase("32766"));
-        expect(tryEdgeCase("32767"));
-        expect(!tryEdgeCase("32768"));
-
-        expect(tryEdgeCase("-32767"));
-        expect(tryEdgeCase("-32768"));
-        expect(!tryEdgeCase("-32769"));
-    }
-
-    template 
-    void
-    testThrowConvert(std::string const& s, bool success)
-    {
-        bool result = !success;
-        T out;
-
-        try
-        {
-            out = lexicalCastThrow(s);
-            result = true;
-        }
-        catch (BadLexicalCast const&)
-        {
-            result = false;
-        }
-
-        expect(result == success, s);
-    }
-
-    void
-    testThrowingConversions()
-    {
-        testcase("throwing conversion");
-
-        testThrowConvert("99999999999999999999", false);
-        testThrowConvert("9223372036854775806", true);
-
-        testThrowConvert("4294967290", true);
-        testThrowConvert("42949672900", false);
-        testThrowConvert("429496729000", false);
-        testThrowConvert("4294967290000", false);
-
-        testThrowConvert("5294967295", false);
-        testThrowConvert("-2147483644", true);
-
-        testThrowConvert("66666", false);
-        testThrowConvert("-5711", true);
-    }
-
-    void
-    testZero()
-    {
-        testcase("zero conversion");
-
-        {
-            std::int32_t out = 0;
-
-            expect(lexicalCastChecked(out, "-0"), "0");
-            expect(lexicalCastChecked(out, "0"), "0");
-            expect(lexicalCastChecked(out, "+0"), "0");
-        }
-
-        {
-            std::uint32_t out = 0;
-
-            expect(!lexicalCastChecked(out, "-0"), "0");
-            expect(lexicalCastChecked(out, "0"), "0");
-            expect(lexicalCastChecked(out, "+0"), "0");
-        }
-    }
-
-    void
-    testEntireRange()
-    {
-        testcase("entire range");
-
-        std::int32_t i = std::numeric_limits::min();
-        std::string const empty;
-
-        while (i <= std::numeric_limits::max())
-        {
-            auto const j = static_cast(i);
-
-            auto actual = std::to_string(j);
-
-            auto result = lexicalCast(j, empty);
-
-            expect(result == actual, actual + " (string to integer)");
-
-            if (result == actual)
-            {
-                auto number = lexicalCast(result);
-
-                if (number != j)
-                    expect(false, actual + " (integer to string)");
-            }
-
-            i++;
-        }
-    }
-
-    void
-    run() override
-    {
-        std::int64_t const seedValue = 50;
-
-        xor_shift_engine r(seedValue);
-
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-        testIntegers(r);
-
-        testPathologies();
-        testConversionOverflows();
-        testConversionUnderflows();
-        testThrowingConversions();
-        testZero();
-        testEdgeCases();
-        testEntireRange();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(LexicalCast, beast, beast);
-
-}  // namespace beast
diff --git a/src/test/beast/SemanticVersion_test.cpp b/src/test/beast/SemanticVersion_test.cpp
deleted file mode 100644
index d7079080c8..0000000000
--- a/src/test/beast/SemanticVersion_test.cpp
+++ /dev/null
@@ -1,266 +0,0 @@
-#include 
-#include 
-
-#include 
-
-namespace beast {
-
-class SemanticVersion_test : public unit_test::Suite
-{
-    using identifier_list = SemanticVersion::identifier_list;
-
-public:
-    void
-    checkPass(std::string const& input, bool shouldPass = true)
-    {
-        SemanticVersion v;
-
-        if (shouldPass)
-        {
-            BEAST_EXPECT(v.parse(input));
-            BEAST_EXPECT(v.print() == input);
-        }
-        else
-        {
-            BEAST_EXPECT(!v.parse(input));
-        }
-    }
-
-    void
-    checkFail(std::string const& input)
-    {
-        checkPass(input, false);
-    }
-
-    // check input and input with appended metadata
-    void
-    checkMeta(std::string const& input, bool shouldPass)
-    {
-        checkPass(input, shouldPass);
-
-        checkPass(input + "+a", shouldPass);
-        checkPass(input + "+1", shouldPass);
-        checkPass(input + "+a.b", shouldPass);
-        checkPass(input + "+ab.cd", shouldPass);
-
-        checkFail(input + "!");
-        checkFail(input + "+");
-        checkFail(input + "++");
-        checkFail(input + "+!");
-        checkFail(input + "+.");
-        checkFail(input + "+a.!");
-    }
-
-    void
-    checkMetaFail(std::string const& input)
-    {
-        checkMeta(input, false);
-    }
-
-    // check input, input with appended release data,
-    // input with appended metadata, and input with both
-    // appended release data and appended metadata
-    //
-    void
-    checkRelease(std::string const& input, bool shouldPass = true)
-    {
-        checkMeta(input, shouldPass);
-
-        checkMeta(input + "-1", shouldPass);
-        checkMeta(input + "-a", shouldPass);
-        checkMeta(input + "-a1", shouldPass);
-        checkMeta(input + "-a1.b1", shouldPass);
-        checkMeta(input + "-ab.cd", shouldPass);
-        checkMeta(input + "--", shouldPass);
-
-        checkMetaFail(input + "+");
-        checkMetaFail(input + "!");
-        checkMetaFail(input + "-");
-        checkMetaFail(input + "-!");
-        checkMetaFail(input + "-.");
-        checkMetaFail(input + "-a.!");
-        checkMetaFail(input + "-0.a");
-    }
-
-    // Checks the major.minor.version string alone and with all
-    // possible combinations of release identifiers and metadata.
-    //
-    void
-    check(std::string const& input, bool shouldPass = true)
-    {
-        checkRelease(input, shouldPass);
-    }
-
-    void
-    negcheck(std::string const& input)
-    {
-        check(input, false);
-    }
-
-    void
-    testParse()
-    {
-        testcase("parsing");
-
-        check("0.0.0");
-        check("1.2.3");
-        check("2147483647.2147483647.2147483647");  // max int
-
-        // negative values
-        negcheck("-1.2.3");
-        negcheck("1.-2.3");
-        negcheck("1.2.-3");
-
-        // missing parts
-        negcheck("");
-        negcheck("1");
-        negcheck("1.");
-        negcheck("1.2");
-        negcheck("1.2.");
-        negcheck(".2.3");
-
-        // whitespace
-        negcheck(" 1.2.3");
-        negcheck("1 .2.3");
-        negcheck("1.2 .3");
-        negcheck("1.2.3 ");
-
-        // leading zeroes
-        negcheck("01.2.3");
-        negcheck("1.02.3");
-        negcheck("1.2.03");
-    }
-
-    static identifier_list
-    ids()
-    {
-        return identifier_list();
-    }
-
-    static identifier_list
-    ids(std::string const& s1)
-    {
-        identifier_list v;
-        v.push_back(s1);
-        return v;
-    }
-
-    static identifier_list
-    ids(std::string const& s1, std::string const& s2)
-    {
-        identifier_list v;
-        v.push_back(s1);
-        v.push_back(s2);
-        return v;
-    }
-
-    static identifier_list
-    ids(std::string const& s1, std::string const& s2, std::string const& s3)
-    {
-        identifier_list v;
-        v.push_back(s1);
-        v.push_back(s2);
-        v.push_back(s3);
-        return v;
-    }
-
-    // Checks the decomposition of the input into appropriate values
-    void
-    checkValues(
-        std::string const& input,
-        int majorVersion,
-        int minorVersion,
-        int patchVersion,
-        identifier_list const& preReleaseIdentifiers = identifier_list(),
-        identifier_list const& metaData = identifier_list())
-    {
-        SemanticVersion v;
-
-        BEAST_EXPECT(v.parse(input));
-
-        BEAST_EXPECT(v.majorVersion == majorVersion);
-        BEAST_EXPECT(v.minorVersion == minorVersion);
-        BEAST_EXPECT(v.patchVersion == patchVersion);
-
-        BEAST_EXPECT(v.preReleaseIdentifiers == preReleaseIdentifiers);
-        BEAST_EXPECT(v.metaData == metaData);
-    }
-
-    void
-    testValues()
-    {
-        testcase("values");
-
-        checkValues("0.1.2", 0, 1, 2);
-        checkValues("1.2.3", 1, 2, 3);
-        checkValues("1.2.3-rc1", 1, 2, 3, ids("rc1"));
-        checkValues("1.2.3-rc1.debug", 1, 2, 3, ids("rc1", "debug"));
-        checkValues("1.2.3-rc1.debug.asm", 1, 2, 3, ids("rc1", "debug", "asm"));
-        checkValues("1.2.3+full", 1, 2, 3, ids(), ids("full"));
-        checkValues("1.2.3+full.prod", 1, 2, 3, ids(), ids("full", "prod"));
-        checkValues("1.2.3+full.prod.x86", 1, 2, 3, ids(), ids("full", "prod", "x86"));
-        checkValues(
-            "1.2.3-rc1.debug.asm+full.prod.x86",
-            1,
-            2,
-            3,
-            ids("rc1", "debug", "asm"),
-            ids("full", "prod", "x86"));
-    }
-
-    // makes sure the left version is less than the right
-    void
-    checkLessInternal(std::string const& lhs, std::string const& rhs)
-    {
-        SemanticVersion left;
-        SemanticVersion right;
-
-        BEAST_EXPECT(left.parse(lhs));
-        BEAST_EXPECT(right.parse(rhs));
-
-        BEAST_EXPECT(compare(left, left) == 0);
-        BEAST_EXPECT(compare(right, right) == 0);
-        BEAST_EXPECT(compare(left, right) < 0);
-        BEAST_EXPECT(compare(right, left) > 0);
-
-        BEAST_EXPECT(left < right);
-        BEAST_EXPECT(right > left);
-        BEAST_EXPECT(left == left);
-        BEAST_EXPECT(right == right);
-    }
-
-    void
-    checkLess(std::string const& lhs, std::string const& rhs)
-    {
-        checkLessInternal(lhs, rhs);
-        checkLessInternal(lhs + "+meta", rhs);
-        checkLessInternal(lhs, rhs + "+meta");
-        checkLessInternal(lhs + "+meta", rhs + "+meta");
-    }
-
-    void
-    testCompare()
-    {
-        testcase("comparisons");
-
-        checkLess("1.0.0-alpha", "1.0.0-alpha.1");
-        checkLess("1.0.0-alpha.1", "1.0.0-alpha.beta");
-        checkLess("1.0.0-alpha.beta", "1.0.0-beta");
-        checkLess("1.0.0-beta", "1.0.0-beta.2");
-        checkLess("1.0.0-beta.2", "1.0.0-beta.11");
-        checkLess("1.0.0-beta.11", "1.0.0-rc.1");
-        checkLess("1.0.0-rc.1", "1.0.0");
-        checkLess("0.9.9", "1.0.0");
-    }
-
-    void
-    run() override
-    {
-        testParse();
-        testValues();
-        testCompare();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(SemanticVersion, beast, beast);
-}  // namespace beast
diff --git a/src/test/beast/beast_Zero_test.cpp b/src/test/beast/beast_Zero_test.cpp
deleted file mode 100644
index bb61844caa..0000000000
--- a/src/test/beast/beast_Zero_test.cpp
+++ /dev/null
@@ -1,115 +0,0 @@
-#include 
-#include 
-
-namespace beast {
-
-struct AdlTester
-{
-};
-
-int
-signum(AdlTester)
-{
-    return 0;
-}
-
-namespace inner_adl_test {
-
-struct AdlTester2
-{
-};
-
-int
-signum(AdlTester2)
-{
-    return 0;
-}
-
-}  // namespace inner_adl_test
-
-class Zero_test : public beast::unit_test::Suite
-{
-private:
-    struct IntegerWrapper
-    {
-        int value;
-
-        IntegerWrapper(int v) : value(v)
-        {
-        }
-
-        [[nodiscard]] int
-        signum() const
-        {
-            return value;
-        }
-    };
-
-public:
-    void
-    expectSame(bool result, bool correct, char const* message)
-    {
-        expect(result == correct, message);
-    }
-
-    void
-    testLhsZero(IntegerWrapper x)
-    {
-        expectSame(x >= kZero, x.signum() >= 0, "lhs greater-than-or-equal-to");
-        expectSame(x > kZero, x.signum() > 0, "lhs greater than");
-        expectSame(x == kZero, x.signum() == 0, "lhs equal to");
-        expectSame(x != kZero, x.signum() != 0, "lhs not equal to");
-        expectSame(x < kZero, x.signum() < 0, "lhs less than");
-        expectSame(x <= kZero, x.signum() <= 0, "lhs less-than-or-equal-to");
-    }
-
-    void
-    testLhsZero()
-    {
-        testcase("lhs zero");
-
-        testLhsZero(-7);
-        testLhsZero(0);
-        testLhsZero(32);
-    }
-
-    void
-    testRhsZero(IntegerWrapper x)
-    {
-        expectSame(kZero >= x, 0 >= x.signum(), "rhs greater-than-or-equal-to");
-        expectSame(kZero > x, 0 > x.signum(), "rhs greater than");
-        expectSame(kZero == x, 0 == x.signum(), "rhs equal to");
-        expectSame(kZero != x, 0 != x.signum(), "rhs not equal to");
-        expectSame(kZero < x, 0 < x.signum(), "rhs less than");
-        expectSame(kZero <= x, 0 <= x.signum(), "rhs less-than-or-equal-to");
-    }
-
-    void
-    testRhsZero()
-    {
-        testcase("rhs zero");
-
-        testRhsZero(-4);
-        testRhsZero(0);
-        testRhsZero(64);
-    }
-
-    void
-    testAdl()
-    {
-        expect(AdlTester{} == kZero, "ADL failure!");
-        expect(inner_adl_test::AdlTester2{} == kZero, "ADL failure!");
-    }
-
-    void
-    run() override
-    {
-        testLhsZero();
-        testRhsZero();
-        testAdl();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Zero, beast, beast);
-
-}  // namespace beast
diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp
index e98a0e1e88..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)
             {
@@ -1575,6 +1574,87 @@ r.ripple.com:51235
 
         // Above upper bound
         BEAST_EXPECT(!testDiverged("901"));
+
+        testcase("overlay: manifest counts");
+
+        // Both keys share one range and one parse path, so exercise each
+        // through the same helper.
+        auto testCount = [](std::string const& key,
+                            std::string const& value) -> std::optional {
+            try
+            {
+                Config c;
+                c.loadFromString("[overlay]\n" + key + "=" + value);
+                return key == "max_trusted_count" ? c.maxTrustedCount : c.maxUntrustedCount;
+            }
+            catch (std::runtime_error const&)
+            {
+                return {};
+            }
+        };
+
+        for (auto const* key : {"max_untrusted_count", "max_trusted_count"})
+        {
+            // Failures. A bad value must surface as std::runtime_error, not
+            // the std::bad_cast that the underlying parse throws.
+            BEAST_EXPECT(!testCount(key, "none"));
+            BEAST_EXPECT(!testCount(key, "0.5"));
+            BEAST_EXPECT(!testCount(key, "400 manifests"));
+            BEAST_EXPECT(!testCount(key, "-1"));
+
+            // Below lower bound
+            BEAST_EXPECT(!testCount(key, "0"));
+            BEAST_EXPECT(!testCount(key, "49"));
+
+            // In bounds
+            BEAST_EXPECT(testCount(key, "50") == 50);
+            BEAST_EXPECT(testCount(key, "51") == 51);
+            BEAST_EXPECT(testCount(key, "300") == 300);
+            BEAST_EXPECT(testCount(key, "400") == 400);
+            BEAST_EXPECT(testCount(key, "999") == 999);
+            BEAST_EXPECT(testCount(key, "1000") == 1000);
+
+            // Above upper bound
+            BEAST_EXPECT(!testCount(key, "1001"));
+        }
+
+        // Each key is independent: setting one leaves the other unset.
+        {
+            Config c;
+            c.loadFromString("[overlay]\nmax_untrusted_count=500");
+            BEAST_EXPECT(c.maxUntrustedCount == 500);
+            BEAST_EXPECT(!c.maxTrustedCount);
+        }
+        {
+            Config c;
+            c.loadFromString("[overlay]\nmax_trusted_count=500");
+            BEAST_EXPECT(c.maxTrustedCount == 500);
+            BEAST_EXPECT(!c.maxUntrustedCount);
+        }
+
+        // Both can be set together.
+        {
+            Config c;
+            c.loadFromString("[overlay]\nmax_untrusted_count=250\nmax_trusted_count=750");
+            BEAST_EXPECT(c.maxUntrustedCount == 250);
+            BEAST_EXPECT(c.maxTrustedCount == 750);
+        }
+
+        // Unset leaves no override, so the use sites fall back to the defaults.
+        {
+            Config c;
+            c.loadFromString("[overlay]\nip_limit=64");
+            BEAST_EXPECT(!c.maxUntrustedCount);
+            BEAST_EXPECT(!c.maxTrustedCount);
+        }
+
+        // No [overlay] section at all leaves both unset too.
+        {
+            Config c;
+            c.loadFromString("");
+            BEAST_EXPECT(!c.maxUntrustedCount);
+            BEAST_EXPECT(!c.maxTrustedCount);
+        }
     }
 
     void
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/core/Workers_test.cpp b/src/test/core/Workers_test.cpp
index fe3820b84a..6824b94769 100644
--- a/src/test/core/Workers_test.cpp
+++ b/src/test/core/Workers_test.cpp
@@ -9,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -21,17 +22,17 @@ namespace perf {
 class PerfLogTest : public PerfLog
 {
     void
-    rpcStart(std::string const& method, std::uint64_t requestId) override
+    rpcStart(std::string_view method, std::uint64_t requestId) override
     {
     }
 
     void
-    rpcFinish(std::string const& method, std::uint64_t requestId) override
+    rpcFinish(std::string_view method, std::uint64_t requestId) override
     {
     }
 
     void
-    rpcError(std::string const& method, std::uint64_t dur) override
+    rpcError(std::string_view method, std::uint64_t requestId) override
     {
     }
 
diff --git a/src/test/jtx/AMM.h b/src/test/jtx/AMM.h
index 68b6d9f745..435e32b7b4 100644
--- a/src/test/jtx/AMM.h
+++ b/src/test/jtx/AMM.h
@@ -199,7 +199,7 @@ public:
         std::optional const& asset2 = std::nullopt,
         std::optional const& ammAccount = std::nullopt,
         bool ignoreParams = false,
-        unsigned apiVersion = RPC::kApiInvalidVersion) const;
+        unsigned apiVersion = rpc::kApiInvalidVersion) const;
 
     [[nodiscard]] json::Value
     ammRpcInfo(
diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h
index 404ddbe31d..5c1b90328b 100644
--- a/src/test/jtx/ConfidentialTransfer.h
+++ b/src/test/jtx/ConfidentialTransfer.h
@@ -94,6 +94,117 @@ protected:
         return proof;
     }
 
+    // Generate a forged single bulletproof for a single value and blinding factor.
+    // Used to test ConvertBack overdraft prevention via bulletproof verification.
+    static Buffer
+    getForgedSingleBulletproof(
+        uint64_t value,
+        Buffer const& blindingFactor,
+        uint256 const& contextHash)
+    {
+        auto* const ctx = mpt_secp256k1_context();
+
+        secp256k1_pubkey h;
+        secp256k1_mpt_get_h_generator(ctx, &h);
+
+        Buffer proof(kEcSingleBulletproofLength);
+        size_t proofLen = kEcSingleBulletproofLength;
+
+        if (secp256k1_bulletproof_prove_agg(
+                ctx,
+                proof.data(),
+                &proofLen,
+                &value,
+                blindingFactor.data(),
+                1,  // m = 1 (single bulletproof)
+                &h,
+                contextHash.data()) == 0)
+            Throw("Failed to generate forged single bulletproof");
+
+        return proof;
+    }
+
+    // Forges a ConvertBack proof (compact sigma + single bulletproof) whose
+    // sigma component claims claimedBalance (which may be wrong) while binding
+    // to the real pedersen commitment and encrypted spending balance
+    // ciphertext already on the ledger. The bulletproof component is built
+    // from realBalance so it stays honest.
+    // mpt_get_convert_back_proof does not allow to build a proof whose amount
+    // exceeds the holder's claimed balance.
+    static Buffer
+    getForgedConvertBackProof(
+        test::jtx::MPTTester& mpt,
+        test::jtx::Account const& holder,
+        uint64_t claimedBalance,
+        uint64_t realBalance,
+        uint64_t amt,
+        Buffer const& pedersenCommitment,
+        Buffer const& encryptedSpendingBalance,
+        Buffer const& pcBlindingFactor,
+        uint256 const& contextHash)
+    {
+        if (pedersenCommitment.size() != kCompressedEcPointLength)
+            Throw("getForgedConvertBackProof: bad pedersenCommitment length");
+        if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength)
+        {
+            Throw(
+                "getForgedConvertBackProof: bad encryptedSpendingBalance length");
+        }
+        if (amt > realBalance)
+            Throw("getForgedConvertBackProof: amt exceeds realBalance");
+
+        auto* const ctx = mpt_secp256k1_context();
+        auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey");
+        auto const holderPrivKey =
+            requireOptional(mpt.getPrivKey(holder), "Missing holder privkey");
+
+        secp256k1_pubkey pkHolder;
+        if (secp256k1_ec_pubkey_parse(
+                ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != 1)
+            Throw("Failed to parse holder's public key");
+
+        secp256k1_pubkey pcB;
+        if (secp256k1_ec_pubkey_parse(
+                ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != 1)
+            Throw("Failed to parse pedersen commitment");
+
+        secp256k1_pubkey b1, b2;
+        if (secp256k1_ec_pubkey_parse(
+                ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 ||
+            secp256k1_ec_pubkey_parse(
+                ctx,
+                &b2,
+                encryptedSpendingBalance.data() + kCompressedEcPointLength,
+                kCompressedEcPointLength) != 1)
+            Throw("Failed to parse balance ciphertext");
+
+        Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
+        if (secp256k1_compact_convertback_prove(
+                ctx,
+                sigmaProof.data(),
+                claimedBalance,
+                holderPrivKey.data(),
+                pcBlindingFactor.data(),
+                &pkHolder,
+                &b1,
+                &b2,
+                &pcB,
+                contextHash.data()) != 1)
+            Throw("Failed to generate convertback sigma proof");
+
+        auto const forgedBulletproof =
+            getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash);
+
+        Buffer proof(kEcConvertBackProofLength);
+        std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE);
+        std::memcpy(
+            proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE,
+            forgedBulletproof.data(),
+            kEcSingleBulletproofLength);
+
+        return proof;
+    }
+
     // Get a bad ciphertext with valid structure but cryptographic invalid for
     // testing purposes. For preflight test purposes.
     static Buffer const&
@@ -317,6 +428,111 @@ protected:
         }
     };
 
+    // Forges a ConfidentialMPTSend proof (compact sigma + double bulletproof)
+    // for setup.sendAmount against setup's real balance commitment/ciphertext.
+    // mpt_get_confidential_send_proof does not allow to build a proof whose amount
+    // exceeds the sender's claimed balance.
+    static Buffer
+    getForgedSendProof(
+        test::jtx::MPTTester& mpt,
+        test::jtx::Env& env,
+        test::jtx::Account const& sender,
+        test::jtx::Account const& dest,
+        ConfidentialSendSetup const& setup)
+    {
+        auto* const ctx = mpt_secp256k1_context();
+
+        secp256k1_pubkey c1;
+        std::vector c2Vec(setup.recipients.size());
+        std::vector pkVec(setup.recipients.size());
+        for (std::size_t i = 0; i < setup.recipients.size(); ++i)
+        {
+            auto const& r = setup.recipients[i];
+            if (i == 0 &&
+                secp256k1_ec_pubkey_parse(
+                    ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1)
+                Throw("Failed to parse C1");
+            if (secp256k1_ec_pubkey_parse(
+                    ctx,
+                    &c2Vec[i],
+                    r.encryptedAmount.data() + kCompressedEcPointLength,
+                    kCompressedEcPointLength) != 1)
+                Throw("Failed to parse C2");
+            if (secp256k1_ec_pubkey_parse(
+                    ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1)
+                Throw("Failed to parse recipient pubkey");
+        }
+
+        secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2;
+        if (secp256k1_ec_pubkey_parse(
+                ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 ||
+            secp256k1_ec_pubkey_parse(
+                ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 ||
+            secp256k1_ec_pubkey_parse(
+                ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 ||
+            secp256k1_ec_pubkey_parse(
+                ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 ||
+            secp256k1_ec_pubkey_parse(
+                ctx,
+                &b2,
+                setup.prevEncryptedSpending.data() + kCompressedEcPointLength,
+                kCompressedEcPointLength) != 1)
+            Throw("Failed to parse commitments/ciphertext");
+
+        Buffer const senderPrivKey =
+            requireOptional(mpt.getPrivKey(sender), "Missing sender privkey");
+        auto const ctxHash = getSendContextHash(
+            sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version);
+
+        Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
+        if (secp256k1_compact_standard_prove(
+                ctx,
+                sigmaProof.data(),
+                setup.sendAmount,
+                setup.prevSpending,
+                setup.blindingFactor.data(),
+                senderPrivKey.data(),
+                setup.balanceBlindingFactor.data(),
+                setup.recipients.size(),
+                &c1,
+                c2Vec.data(),
+                pkVec.data(),
+                &pcAmount,
+                &pkSender,
+                &pcBalance,
+                &b1,
+                &b2,
+                ctxHash.data()) != 1)
+            Throw("Failed to generate sigma proof");
+
+        // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic
+        // commitment subtraction (mod the curve order) — that mismatch is
+        // exactly what makes the forged proof fail verification.
+        // Computed without a wrapping `uint64` subtract: Clang UBSan treats
+        // unsigned overflow as fatal (see incrementConfidentialVersion).
+        std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending
+            ? setup.prevSpending - setup.sendAmount
+            : ~setup.sendAmount + setup.prevSpending + 1;
+
+        Buffer negAmountBf(kEcBlindingFactorLength);
+        Buffer remainingBf(kEcBlindingFactorLength);
+        secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data());
+        secp256k1_mpt_scalar_add(
+            remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data());
+
+        auto const forgedBulletproof = getForgedBulletproof(
+            {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash);
+
+        Buffer combinedProof(kEcSendProofLength);
+        std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE);
+        std::memcpy(
+            combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE,
+            forgedBulletproof.data(),
+            kEcDoubleBulletproofLength);
+
+        return combinedProof;
+    }
+
     // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key
     // generation, and seeding each holder with a confidential balance.
     // The caller supplies the issuer and any number of holders.
@@ -388,6 +604,18 @@ protected:
         }
     };
 
+    // Create an issuance that can hold confidential balances, with the listed
+    // holders funded and authorized, and a key pair generated for the issuer,
+    // every holder, and every extra key owner. The keys are
+    // generated but not registered.
+    static void
+    setupConfidentialIssuance(
+        test::jtx::MPTTester& mpt,
+        test::jtx::Account const& issuer,
+        std::vector const& holders,
+        std::vector const& keyOwners = {},
+        std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance);
+
     // Set up an MPT environment suitable for batch testing.
     // alice is issuer; bob has 'bobAmt' in confidential spending; carol has
     // 'carolAmt' in confidential spending; dave is initialised with pubkey but
diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h
index a175fd5006..5cb841578a 100644
--- a/src/test/jtx/Env.h
+++ b/src/test/jtx/Env.h
@@ -1081,7 +1081,7 @@ Env::rpc(
     Args&&... args)
 {
     return doRpc(
-        RPC::kApiCommandLineVersion,
+        rpc::kApiCommandLineVersion,
         std::vector{cmd, std::forward(args)...},
         headers);
 }
diff --git a/src/test/jtx/PeerStub.h b/src/test/jtx/PeerStub.h
new file mode 100644
index 0000000000..cabe94f351
--- /dev/null
+++ b/src/test/jtx/PeerStub.h
@@ -0,0 +1,185 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+/**
+ * A `Peer` whose every method is a no-op returning a default.
+ *
+ * Derive from this and override only the methods a test cares about. Adding a
+ * method to `Peer` then costs one stub here, not one per double.
+ *
+ * The id and the node public key are real, because the code under test routes
+ * and deduplicates on both.
+ */
+class PeerStub : public Peer
+{
+public:
+    /**
+     * @param id  The connection id reported by `id()`.
+     */
+    explicit PeerStub(id_t id = 0)
+        : id_(id), nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey()))
+    {
+    }
+
+    ~PeerStub() override = default;
+
+    void
+    send(std::shared_ptr const&) override
+    {
+    }
+
+    [[nodiscard]] beast::ip::Endpoint
+    getRemoteAddress() const override
+    {
+        return {};
+    }
+
+    void
+    sendTxQueue() override
+    {
+    }
+
+    void
+    addTxQueue(uint256 const&) override
+    {
+    }
+
+    void
+    removeTxQueue(uint256 const&) override
+    {
+    }
+
+    void
+    charge(resource::Charge const&, std::string const&) override
+    {
+    }
+
+    [[nodiscard]] id_t
+    id() const override
+    {
+        return id_;
+    }
+
+    [[nodiscard]] bool
+    cluster() const override
+    {
+        return false;
+    }
+
+    [[nodiscard]] bool
+    isHighLatency() const override
+    {
+        return false;
+    }
+
+    [[nodiscard]] int
+    getScore(bool) const override
+    {
+        return 0;
+    }
+
+    [[nodiscard]] PublicKey const&
+    getNodePublic() const override
+    {
+        return nodePublicKey_;
+    }
+
+    json::Value
+    json() override
+    {
+        return {};
+    }
+
+    [[nodiscard]] bool
+    supportsFeature(ProtocolFeature) const override
+    {
+        return false;
+    }
+
+    [[nodiscard]] std::optional
+    publisherListSequence(PublicKey const&) const override
+    {
+        return {};
+    }
+
+    void
+    setPublisherListSequence(PublicKey const&, std::size_t const) override
+    {
+    }
+
+    [[nodiscard]] std::string const&
+    fingerprint() const override
+    {
+        return fingerprint_;
+    }
+
+    [[nodiscard]] uint256
+    getClosedLedgerHash() const override
+    {
+        return {};
+    }
+
+    [[nodiscard]] bool
+    hasLedger(uint256 const&, std::uint32_t) const override
+    {
+        return false;
+    }
+
+    void
+    ledgerRange(std::uint32_t&, std::uint32_t&) const override
+    {
+    }
+
+    [[nodiscard]] bool
+    hasTxSet(uint256 const&) const override
+    {
+        return false;
+    }
+
+    void
+    cycleStatus() override
+    {
+    }
+
+    bool
+    hasRange(std::uint32_t, std::uint32_t) override
+    {
+        return false;
+    }
+
+    [[nodiscard]] bool
+    compressionEnabled() const override
+    {
+        return false;
+    }
+
+    [[nodiscard]] bool
+    txReduceRelayEnabled() const override
+    {
+        return false;
+    }
+
+private:
+    id_t const id_;
+    PublicKey const nodePublicKey_;
+    std::string const fingerprint_;
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h
index e7a2808f07..382a6fe333 100644
--- a/src/test/jtx/TestHelpers.h
+++ b/src/test/jtx/TestHelpers.h
@@ -26,6 +26,7 @@
 #include 
 #include   // IWYU pragma: keep
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -314,21 +315,6 @@ 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 
-auto
-makeVector(Input const& input)
-    requires requires(Input& v) {
-        std::begin(v);
-        std::end(v);
-    }
-{
-    return std::vector(std::begin(input), std::end(input));
-}
-
 // Functions used in debugging
 json::Value
 getAccountOffers(Env& env, AccountID const& acct, bool current = false);
@@ -779,9 +765,9 @@ inline constexpr FeeLevel64 kBaseFeeLevel{TxQ::kBaseLevel};
 inline constexpr FeeLevel64 kMinEscalationFeeLevel = kBaseFeeLevel * 500;
 
 inline uint256
-getCheckIndex(AccountID const& account, std::uint32_t uSequence)
+getCheckIndex(AccountID const& account, std::uint32_t const sequence)
 {
-    return keylet::check(account, uSequence).key;
+    return keylet::check(account, SeqProxy::rawSequence(sequence)).key;
 }
 
 template 
@@ -876,7 +862,7 @@ checkMetrics(
 /* LoanBroker */
 /******************************************************************************/
 
-namespace loanBroker {
+namespace loan_broker {
 
 json::Value
 set(AccountID const& account, uint256 const& vaultId, std::uint32_t flags = 0);
@@ -917,7 +903,7 @@ auto const kCoverRateLiquidation =
 
 auto const kDestination = JTxFieldWrapper(sfDestination);
 
-}  // namespace loanBroker
+}  // namespace loan_broker
 
 /* Loan */
 /******************************************************************************/
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/AMM.cpp b/src/test/jtx/impl/AMM.cpp
index 8effce288e..74232037ce 100644
--- a/src/test/jtx/impl/AMM.cpp
+++ b/src/test/jtx/impl/AMM.cpp
@@ -234,7 +234,7 @@ AMM::ammRpcInfo(
             jv[jss::amm_account] = *ammAccount;
     }
     auto jr =
-        (apiVersion == RPC::kApiInvalidVersion
+        (apiVersion == rpc::kApiInvalidVersion
              ? env_.rpc("json", "amm_info", to_string(jv))
              : env_.rpc(apiVersion, "json", "amm_info", to_string(jv)));
     if (jr.isObject() && jr.isMember(jss::result) && jr[jss::result].isMember(jss::status))
diff --git a/src/test/jtx/impl/ConfidentialTransfer.cpp b/src/test/jtx/impl/ConfidentialTransfer.cpp
new file mode 100644
index 0000000000..6c1538316c
--- /dev/null
+++ b/src/test/jtx/impl/ConfidentialTransfer.cpp
@@ -0,0 +1,37 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl {
+
+void
+ConfidentialTransferTestBase::setupConfidentialIssuance(
+    test::jtx::MPTTester& mpt,
+    test::jtx::Account const& issuer,
+    std::vector const& holders,
+    std::vector const& keyOwners,
+    std::uint32_t flags)
+{
+    using namespace test::jtx;
+    mpt.create({
+        .ownerCount = 1,
+        .flags = flags,
+    });
+
+    for (auto const& holder : holders)
+    {
+        mpt.authorize({.account = holder});
+        mpt.pay(issuer, holder, 100);
+        mpt.generateKeyPair(holder);
+    }
+
+    mpt.generateKeyPair(issuer);
+    for (auto const& keyOwner : keyOwners)
+        mpt.generateKeyPair(keyOwner);
+}
+
+}  // namespace xrpl
diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp
index 4da2e2b521..35553bdeb2 100644
--- a/src/test/jtx/impl/Env.cpp
+++ b/src/test/jtx/impl/Env.cpp
@@ -498,9 +498,9 @@ Env::postconditions(
          !test.expect(
              parsed.rpcCode == jt.rpcCode->first && parsed.rpcMessage == jt.rpcCode->second,
              "apply " + locStr + ": Got RPC result "s +
-                 (parsed.rpcCode ? RPC::getErrorInfo(*parsed.rpcCode).token.cStr() : "NO RESULT") +
+                 (parsed.rpcCode ? rpc::getErrorInfo(*parsed.rpcCode).token.cStr() : "NO RESULT") +
                  " (" + parsed.rpcMessage + "); Expected " +
-                 RPC::getErrorInfo(jt.rpcCode->first).token.cStr() + " (" + jt.rpcCode->second +
+                 rpc::getErrorInfo(jt.rpcCode->first).token.cStr() + " (" + jt.rpcCode->second +
                  ")")) ||
         bad;
     // If we have an rpcCode (just checked), then the rpcException check is
diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp
index 4d3869b4f9..2fa2aebcda 100644
--- a/src/test/jtx/impl/TestHelpers.cpp
+++ b/src/test/jtx/impl/TestHelpers.cpp
@@ -43,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -211,10 +212,10 @@ findPathsRequest(
     using namespace jtx;
 
     auto& app = env.app();
-    Resource::Charge loadType = Resource::kFeeReferenceRpc;
-    Resource::Consumer c;
+    resource::Charge loadType = resource::kFeeReferenceRpc;
+    resource::Consumer c;
 
-    RPC::JsonContext context{
+    rpc::JsonContext context{
         {.j = env.journal,
          .app = app,
          .loadType = loadType,
@@ -224,7 +225,7 @@ findPathsRequest(
          .role = Role::USER,
          .coro = {},
          .infoSub = {},
-         .apiVersion = RPC::kApiVersionIfUnspecified},
+         .apiVersion = rpc::kApiVersionIfUnspecified},
         {},
         {}};
 
@@ -252,7 +253,7 @@ findPathsRequest(
     app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) {
         context.params = std::move(params);
         context.coro = coro;
-        RPC::doCommand(context, result);
+        rpc::doCommand(context, result);
         g.signal();
     });
 
@@ -571,7 +572,8 @@ claim(
 uint256
 channel(AccountID const& account, AccountID const& dst, std::uint32_t seqProxyValue)
 {
-    auto const k = keylet::payChannel(account, dst, seqProxyValue);
+    auto const seqProxy = SeqProxy::rawSequence(seqProxyValue);
+    auto const k = keylet::payChannel(account, dst, seqProxy);
     return k.key;
 }
 
@@ -743,7 +745,7 @@ issueHelperMPT(IssuerArgs const& args)
 /* LoanBroker */
 /******************************************************************************/
 
-namespace loanBroker {
+namespace loan_broker {
 
 json::Value
 set(AccountID const& account, uint256 const& vaultId, uint32_t flags)
@@ -809,7 +811,7 @@ coverClawback(AccountID const& account, std::uint32_t flags)
     return jv;
 }
 
-}  // namespace loanBroker
+}  // namespace loan_broker
 
 /* Loan */
 /******************************************************************************/
diff --git a/src/test/jtx/impl/attester.cpp b/src/test/jtx/impl/attester.cpp
index ac946a1bf3..3799d957e9 100644
--- a/src/test/jtx/impl/attester.cpp
+++ b/src/test/jtx/impl/attester.cpp
@@ -24,7 +24,7 @@ signClaimAttestation(
     std::uint64_t claimID,
     std::optional const& dst)
 {
-    auto const toSign = Attestations::AttestationClaim::message(
+    auto const toSign = attestations::AttestationClaim::message(
         bridge, sendingAccount, sendingAmount, rewardAccount, wasLockingChainSend, claimID, dst);
     return sign(pk, sk, makeSlice(toSign));
 }
@@ -42,7 +42,7 @@ signCreateAccountAttestation(
     std::uint64_t createCount,
     AccountID const& dst)
 {
-    auto const toSign = Attestations::AttestationCreateAccount::message(
+    auto const toSign = attestations::AttestationCreateAccount::message(
         bridge,
         sendingAccount,
         sendingAmount,
diff --git a/src/test/jtx/impl/batch.cpp b/src/test/jtx/impl/batch.cpp
index b1061f65a3..d8d067d95a 100644
--- a/src/test/jtx/impl/batch.cpp
+++ b/src/test/jtx/impl/batch.cpp
@@ -102,7 +102,7 @@ Sig::operator()(Env& env, JTx& jt) const
         serializeBatch(
             msg,
             stx.getAccountID(sfAccount),
-            stx.getSeqValue(),
+            stx.getSeqProxy().value(),
             stx.getFlags(),
             stx.getBatchTransactionIDs());
         finishMultiSigningData(e.acct.id(), msg);
@@ -146,7 +146,7 @@ Msig::operator()(Env& env, JTx& jt) const
         serializeBatch(
             msg,
             stx.getAccountID(sfAccount),
-            stx.getSeqValue(),
+            stx.getSeqProxy().value(),
             stx.getFlags(),
             stx.getBatchTransactionIDs());
         msg.addBitString(master.id());
diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp
index bc65738b44..14690058ec 100644
--- a/src/test/jtx/impl/envconfig.cpp
+++ b/src/test/jtx/impl/envconfig.cpp
@@ -7,8 +7,10 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -60,6 +62,15 @@ setupConfigForUnitTests(Config& cfg)
 
 namespace jtx {
 
+std::unique_ptr
+onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval)
+{
+    cfg->ledgerHistory = deleteInterval;
+    auto& section = cfg->section(Sections::kNodeDatabase);
+    section.set(Keys::kOnlineDelete, std::to_string(deleteInterval));
+    return cfg;
+}
+
 std::unique_ptr
 noAdmin(std::unique_ptr cfg)
 {
diff --git a/src/test/jtx/impl/escrow.cpp b/src/test/jtx/impl/escrow.cpp
index 61c260a5d0..c2f3f94fa3 100644
--- a/src/test/jtx/impl/escrow.cpp
+++ b/src/test/jtx/impl/escrow.cpp
@@ -9,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -58,7 +59,7 @@ cancel(AccountID const& account, Account const& from, std::uint32_t seq)
 Rate
 rate(Env& env, Account const& account, std::uint32_t const& seq)
 {
-    auto const sle = env.le(keylet::escrow(account.id(), seq));
+    auto const sle = env.le(keylet::escrow(account.id(), SeqProxy::rawSequence(seq)));
     if (sle->isFieldPresent(sfTransferRate))
         return xrpl::Rate((*sle)[sfTransferRate]);
     return Rate{0};
diff --git a/src/test/jtx/impl/ledgerStateFixes.cpp b/src/test/jtx/impl/ledgerStateFixes.cpp
index 30c6659124..ae195021b8 100644
--- a/src/test/jtx/impl/ledgerStateFixes.cpp
+++ b/src/test/jtx/impl/ledgerStateFixes.cpp
@@ -9,7 +9,7 @@
 
 #include 
 
-namespace xrpl::test::jtx::ledgerStateFix {
+namespace xrpl::test::jtx::ledger_state_fix {
 
 // Fix NFTokenPage links on owner's account.  acct pays fee.
 json::Value
@@ -35,4 +35,4 @@ bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir)
     return jv;
 }
 
-}  // namespace xrpl::test::jtx::ledgerStateFix
+}  // namespace xrpl::test::jtx::ledger_state_fix
diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp
index dddfc88c7f..542043015b 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 
@@ -30,6 +30,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 
@@ -38,12 +39,12 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -63,14 +64,23 @@ constexpr std::uint64_t kElGamalDecryptRangeHigh = 3000;
  *
  * @param opt The optional to unwrap.
  * @param what Description used in the thrown exception if opt is empty.
+ * @param loc The call site to report in the thrown exception, defaulting to
+ *            the immediate caller.
  * @return A const reference to the contained value.
  */
 template 
 [[nodiscard]] T const&
-requireValue(std::optional const& opt, char const* what)
+requireValue(
+    std::optional const& opt,
+    char const* what,
+    std::source_location const& loc = std::source_location::current())
 {
     if (!opt)
-        Throw(what);
+    {
+        Throw(
+            std::string(what) + " must be present (called from " +
+            std::string(loc.function_name()) + ")");
+    }
     return *opt;
 }
 
@@ -92,22 +102,78 @@ makePedersenParams(PedersenProofParams const& params)
     return res;
 }
 
-}  // namespace
-
-struct MPTSetFlagMapping
+/**
+ * @brief Sets sfAccount on jv to the given account.
+ *
+ * @param jv The JSON object to set the field on.
+ * @param account The account to set. Throws if not present.
+ * @return The resolved account.
+ */
+Account const&
+setAccountField(json::Value& jv, std::optional const& account)
 {
-    std::uint32_t setFlag;
-    std::uint32_t ledgerFlag;
-};
+    Account const& act = requireValue(account, "account");
+    jv[sfAccount] = act.human();
+    return act;
+}
 
-static constexpr std::array mptSetFlagMappings = {{
-    {.setFlag = tmfMPTSetCanLock, .ledgerFlag = lsfMPTCanLock},
-    {.setFlag = tmfMPTSetRequireAuth, .ledgerFlag = lsfMPTRequireAuth},
-    {.setFlag = tmfMPTSetCanEscrow, .ledgerFlag = lsfMPTCanEscrow},
-    {.setFlag = tmfMPTSetCanClawback, .ledgerFlag = lsfMPTCanClawback},
-    {.setFlag = tmfMPTSetCanTrade, .ledgerFlag = lsfMPTCanTrade},
-    {.setFlag = tmfMPTSetCanTransfer, .ledgerFlag = lsfMPTCanTransfer},
-}};
+/**
+ * @brief Sets sfDestination on jv to the given account.
+ *
+ * @param jv The JSON object to set the field on.
+ * @param dest The destination account to set. Throws if not present.
+ * @return The resolved account.
+ */
+Account const&
+setDestinationField(json::Value& jv, std::optional const& dest)
+{
+    Account const& act = requireValue(dest, "dest");
+    jv[sfDestination] = act.human();
+    return act;
+}
+
+/**
+ * @brief Sets sfZKProof to the given proof if present, otherwise to a
+ *        zero-filled placeholder of the given length.
+ *
+ * @param jv The JSON object to set the field on.
+ * @param proof The real proof to use, if generated.
+ * @param dummyLen The length of the placeholder buffer to use when proof is not set.
+ */
+void
+setProofOrDummy(json::Value& jv, std::optional const& proof, std::size_t dummyLen)
+{
+    jv[sfZKProof.jsonName] = strHex(proof ? *proof : gMakeZeroBuffer(dummyLen));
+}
+
+/**
+ * @brief Looks up an account's key at a given key epoch.
+ *
+ * @param keys accounts' history of keys, indexed by key epoch.
+ * @param account The account whose key is being looked up.
+ * @param epoch The key epoch, or std::nullopt for the account's latest key.
+ * @return The key, or std::nullopt if the account has no key at that epoch.
+ */
+[[nodiscard]] std::optional
+keyAtEpoch(
+    std::unordered_map> const& keys,
+    AccountID const& account,
+    std::optional epoch)
+{
+    auto const it = keys.find(account);
+    if (it == keys.end() || it->second.empty())
+        return std::nullopt;
+
+    if (!epoch)
+        return it->second.back();
+
+    if (*epoch >= it->second.size())
+        return std::nullopt;
+
+    return it->second[*epoch];
+}
+
+}  // namespace
 
 void
 MptFlags::operator()(Env& env) const
@@ -195,7 +261,7 @@ makeMPTCreate(MPTInitDef const& arg)
             .transferFee = arg.transferFee,
             .pay = {{arg.holders, *arg.pay}},
             .flags = arg.flags,
-            .mutableFlags = arg.mutableFlags,
+            .immutableFlags = arg.immutableFlags,
             .authHolder = arg.authHolder};
     }
     return {
@@ -203,7 +269,7 @@ makeMPTCreate(MPTInitDef const& arg)
         .transferFee = arg.transferFee,
         .authorize = arg.holders,
         .flags = arg.flags,
-        .mutableFlags = arg.mutableFlags,
+        .immutableFlags = arg.immutableFlags,
         .authHolder = arg.authHolder};
 }
 
@@ -245,8 +311,8 @@ MPTTester::createJV(MPTCreate const& arg)
         jv[sfMaximumAmount] = std::to_string(*arg.maxAmt);
     if (arg.domainID)
         jv[sfDomainID] = to_string(*arg.domainID);
-    if (arg.mutableFlags)
-        jv[sfMutableFlags] = *arg.mutableFlags;
+    if (arg.immutableFlags)
+        jv[sfImmutableFlags] = *arg.immutableFlags;
     jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
 
     return jv;
@@ -264,7 +330,7 @@ MPTTester::create(MPTCreate const& arg)
          .assetScale = arg.assetScale,
          .transferFee = arg.transferFee,
          .metadata = arg.metadata,
-         .mutableFlags = arg.mutableFlags,
+         .immutableFlags = arg.immutableFlags,
          .domainID = arg.domainID});
     if (!isTesSuccess(submit(arg, jv)))
     {
@@ -463,8 +529,8 @@ MPTTester::setJV(MPTSet const& arg)
         jv[sfDelegate] = arg.delegate->human();
     if (arg.domainID)
         jv[sfDomainID] = to_string(*arg.domainID);
-    if (arg.mutableFlags)
-        jv[sfMutableFlags] = *arg.mutableFlags;
+    if (arg.immutableFlags)
+        jv[sfImmutableFlags] = *arg.immutableFlags;
     if (arg.transferFee)
         jv[sfTransferFee] = *arg.transferFee;
     if (arg.metadata)
@@ -487,95 +553,85 @@ MPTTester::set(MPTSet const& arg)
         {.account = arg.account ? arg.account : issuer_,
          .holder = arg.holder,
          .id = arg.id ? arg.id : id_,
-         .mutableFlags = arg.mutableFlags,
+         .immutableFlags = arg.immutableFlags,
          .transferFee = arg.transferFee,
          .metadata = arg.metadata,
          .delegate = arg.delegate,
          .domainID = arg.domainID,
          .issuerPubKey = arg.issuerPubKey,
          .auditorPubKey = arg.auditorPubKey});
-    if (submit(arg, jv) == tesSUCCESS && ((arg.flags.value_or(0) != 0u) || arg.mutableFlags))
+    if (submit(arg, jv) == tesSUCCESS && arg.flags.value_or(0) != 0u)
     {
-        if (((arg.flags.value_or(0) != 0u) || arg.mutableFlags))
-        {
-            auto require = [&](std::optional const& holder, bool unchanged) {
-                auto flags = getFlags(holder);
-                if (!unchanged)
+        auto require = [&](std::optional const& holder, bool unchanged) {
+            auto flags = getFlags(holder);
+            if (!unchanged)
+            {
+                if (arg.flags)
                 {
-                    if (arg.flags)
+                    if (*arg.flags & tfMPTLock)
                     {
-                        if (*arg.flags & tfMPTLock)
-                        {
-                            flags |= lsfMPTLocked;
-                        }
-                        else if (*arg.flags & tfMPTUnlock)
-                        {
-                            flags &= ~lsfMPTLocked;
-                        }
+                        flags |= lsfMPTLocked;
+                    }
+                    else if (*arg.flags & tfMPTUnlock)
+                    {
+                        flags &= ~lsfMPTLocked;
                     }
 
-                    if (arg.mutableFlags)
+                    for (auto const& f : MPTokenIssuanceSet::flagMapping)
                     {
-                        for (auto const& [setFlag, ledgerFlag] : mptSetFlagMappings)
+                        if ((*arg.flags & f.setFlag) != 0u)
                         {
-                            if ((*arg.mutableFlags & setFlag) != 0u)
-                            {
-                                flags |= ledgerFlag;
-                            }
+                            flags |= f.ledgerFlag;
                         }
-
-                        if (*arg.mutableFlags & tmfMPTSetCanHoldConfidentialBalance)
-                            flags |= tfMPTCanHoldConfidentialBalance;
                     }
                 }
-                env_.require(MptFlags(*this, flags, holder));
-            };
-            if (arg.account)
-                require(std::nullopt, arg.holder.has_value());
-            if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr))
-                require(*account, false);
-
-            if (arg.issuerPubKey)
-            {
-                env_.require(RequireAny([&]() -> bool {
-                    return forObject([&](SLEP const& sle) -> bool {
-                        if (sle)
-                        {
-                            auto const issuerPubKey = getPubKey(issuer_);
-                            if (!issuerPubKey)
-                            {
-                                Throw(
-                                    "MPTTester::set: issuer's pubkey is not set");
-                            }
-
-                            return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey);
-                        }
-                        return false;
-                    });
-                }));
             }
-            if (arg.auditorPubKey)
-            {
-                env_.require(RequireAny([&]() -> bool {
-                    return forObject([&](SLEP const& sle) -> bool {
-                        if (sle)
+            env_.require(MptFlags(*this, flags, holder));
+        };
+        if (arg.account)
+            require(std::nullopt, arg.holder.has_value());
+        if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr))
+            require(*account, false);
+
+        if (arg.issuerPubKey)
+        {
+            env_.require(RequireAny([&]() -> bool {
+                return forObject([&](SLEP const& sle) -> bool {
+                    if (sle)
+                    {
+                        auto const issuerPubKey = getPubKey(issuer_);
+                        if (!issuerPubKey)
                         {
-                            if (!auditor_.has_value())
-                                Throw("MPTTester::set: auditor is not set");
-
-                            auto const auditorPubKey = getPubKey(*auditor_);
-                            if (!auditorPubKey)
-                            {
-                                Throw(
-                                    "MPTTester::set: auditor's pubkey is not set");
-                            }
-
-                            return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey);
+                            Throw("MPTTester::set: issuer's pubkey is not set");
                         }
-                        return false;
-                    });
-                }));
-            }
+
+                        return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey);
+                    }
+                    return false;
+                });
+            }));
+        }
+        if (arg.auditorPubKey)
+        {
+            env_.require(RequireAny([&]() -> bool {
+                return forObject([&](SLEP const& sle) -> bool {
+                    if (sle)
+                    {
+                        if (!auditor_.has_value())
+                            Throw("MPTTester::set: auditor is not set");
+
+                        auto const auditorPubKey = getPubKey(*auditor_);
+                        if (!auditorPubKey)
+                        {
+                            Throw(
+                                "MPTTester::set: auditor's pubkey is not set");
+                        }
+
+                        return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey);
+                    }
+                    return false;
+                });
+            }));
         }
     }
 }
@@ -664,6 +720,74 @@ MPTTester::isTransferFeePresent() const
     return forObject([&](SLEP const& sle) -> bool { return sle->isFieldPresent(sfTransferFee); });
 }
 
+[[nodiscard]] bool
+MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const
+{
+    // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not present.
+    return forObject([&](SLEP const& sle) -> bool {
+        return sle->getFieldU32(sfImmutableFlags) == expectedFlags;
+    });
+}
+
+[[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::checkMirrorEpochs(
+    Account const& holder,
+    std::optional issuerKeyMirrorEpoch,
+    std::optional auditorKeyMirrorEpoch) const
+{
+    return forObject(
+        [&](SLEP const& sle) -> bool {
+            return (*sle)[~sfIssuerKeyMirrorEpoch] == issuerKeyMirrorEpoch &&
+                (*sle)[~sfAuditorKeyMirrorEpoch] == auditorKeyMirrorEpoch;
+        },
+        holder);
+}
+
+[[nodiscard]] std::optional
+MPTTester::getMirrorEpoch(Account const& holder, SF_UINT32 const& field) const
+{
+    std::optional epoch;
+    forObject(
+        [&](SLEP const& sle) -> bool {
+            epoch = (*sle)[~field];
+            return true;
+        },
+        holder);
+    return epoch;
+}
+
+[[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,
@@ -933,7 +1057,7 @@ MPTTester::getPedersenCommitment(std::uint64_t const amount, Buffer const& peder
     return buf;
 }
 
-Buffer
+std::optional
 MPTTester::getConvertBackProof(
     Account const& holder,
     std::uint64_t const amount,
@@ -945,13 +1069,13 @@ MPTTester::getConvertBackProof(
 
     auto const sleMptoken = env_.le(keylet::mptoken(issuanceID(), holder.id()));
     if (!sleMptoken || !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending))
-        return gMakeZeroBuffer(kExpectedProofLength);
+        return std::nullopt;
 
     auto const holderPubKey = getPubKey(holder);
     auto const holderPrivKey = getPrivKey(holder);
 
     if (!holderPubKey || !holderPrivKey)
-        return gMakeZeroBuffer(kExpectedProofLength);
+        return std::nullopt;
 
     auto const pedersenParams = makePedersenParams(pcParams);
     Buffer proof(kExpectedProofLength);
@@ -963,7 +1087,7 @@ MPTTester::getConvertBackProof(
             amount,
             &pedersenParams,
             proof.data()) != 0)
-        return gMakeZeroBuffer(kExpectedProofLength);
+        return std::nullopt;
 
     return proof;
 }
@@ -974,33 +1098,32 @@ MPTTester::getEncryptedBalance(Account const& account, EncryptedBalanceType opti
     if (!id_)
         Throw("MPT has not been created");
 
-    if (auto const sle = env_.le(keylet::mptoken(*id_, account.id())))
+    auto const sle = env_.le(keylet::mptoken(*id_, account.id()));
+    if (!sle)
+        return {};
+
+    SField const* field = nullptr;
+    switch (option)
     {
-        if (option == holderEncryptedInbox && sle->isFieldPresent(sfConfidentialBalanceInbox))
-        {
-            return Buffer(
-                (*sle)[sfConfidentialBalanceInbox].data(),
-                (*sle)[sfConfidentialBalanceInbox].size());
-        }
-        if (option == holderEncryptedSpending && sle->isFieldPresent(sfConfidentialBalanceSpending))
-        {
-            return Buffer(
-                (*sle)[sfConfidentialBalanceSpending].data(),
-                (*sle)[sfConfidentialBalanceSpending].size());
-        }
-        if (option == issuerEncryptedBalance && sle->isFieldPresent(sfIssuerEncryptedBalance))
-        {
-            return Buffer(
-                (*sle)[sfIssuerEncryptedBalance].data(), (*sle)[sfIssuerEncryptedBalance].size());
-        }
-        if (option == auditorEncryptedBalance && sle->isFieldPresent(sfAuditorEncryptedBalance))
-        {
-            return Buffer(
-                (*sle)[sfAuditorEncryptedBalance].data(), (*sle)[sfAuditorEncryptedBalance].size());
-        }
+        case holderEncryptedInbox:
+            field = &sfConfidentialBalanceInbox;
+            break;
+        case holderEncryptedSpending:
+            field = &sfConfidentialBalanceSpending;
+            break;
+        case issuerEncryptedBalance:
+            field = &sfIssuerEncryptedBalance;
+            break;
+        case auditorEncryptedBalance:
+            field = &sfAuditorEncryptedBalance;
+            break;
     }
 
-    return {};
+    if (field == nullptr || !sle->isFieldPresent(*field))
+        return {};
+
+    auto const blob = sle->getFieldVL(*field);
+    return Buffer(blob.data(), blob.size());
 }
 
 std::uint32_t
@@ -1017,6 +1140,33 @@ MPTTester::getFlags(std::optional const& holder) const
     return flags;
 }
 
+void
+MPTTester::setIssuanceIdField(json::Value& jv, std::optional const& id) const
+{
+    if (id)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*id);
+    }
+    else if (id_)
+    {
+        jv[sfMPTokenIssuanceID] = to_string(*id_);
+    }
+    else
+    {
+        Throw("MPT has not been created");
+    }
+}
+
+std::uint32_t
+MPTTester::ticketOrSeq(
+    std::optional const& ticketSeq,
+    std::optional const& account) const
+{
+    if (ticketSeq)
+        return *ticketSeq;
+    return env_.seq(requireValue(account, "account"));
+}
+
 MPT
 MPTTester::operator[](std::string const& name) const
 {
@@ -1034,47 +1184,37 @@ void
 MPTTester::fillConversionCiphertexts(
     T const& arg,
     json::Value& jv,
-    Buffer& holderCiphertext,
-    Buffer& issuerCiphertext,
-    std::optional& auditorCiphertext,
-    Buffer& blindingFactor) const
+    Account const& account,
+    std::uint64_t const amount) const
 {
-    blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
+    Buffer const blindingFactor =
+        arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
+    jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
 
     // Handle Holder
-    if (arg.holderEncryptedAmt)
-    {
-        holderCiphertext = *arg.holderEncryptedAmt;
-    }
-    else
-    {
-        holderCiphertext = encryptAmount(
-            requireValue(arg.account, "account"), requireValue(arg.amt, "amt"), blindingFactor);
-    }
+    Buffer const holderCiphertext = arg.holderEncryptedAmt
+        ? *arg.holderEncryptedAmt
+        : encryptAmount(account, amount, blindingFactor);
 
     jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext);
 
     // Handle Issuer
-    if (arg.issuerEncryptedAmt)
-    {
-        issuerCiphertext = *arg.issuerEncryptedAmt;
-    }
-    else
-    {
-        issuerCiphertext = encryptAmount(issuer_, requireValue(arg.amt, "amt"), blindingFactor);
-    }
+    Buffer const issuerCiphertext = arg.issuerEncryptedAmt
+        ? *arg.issuerEncryptedAmt
+        : encryptAmount(issuer_, amount, blindingFactor);
 
     jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext);
 
     // Handle Auditor
+    std::optional auditorCiphertext;
     if (arg.auditorEncryptedAmt)
     {
         auditorCiphertext = *arg.auditorEncryptedAmt;
     }
     else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
     {
-        auditorCiphertext = encryptAmount(
-            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
+        auditorCiphertext =
+            encryptAmount(requireValue(auditor_, "auditor"), amount, blindingFactor);
     }
 
     // Update auditor JSON only if ciphertext exists
@@ -1085,81 +1225,30 @@ MPTTester::fillConversionCiphertexts(
 void
 MPTTester::convert(MPTConvert const& arg)
 {
-    json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
+    json::Value const jv = convertJV(arg, ticketOrSeq(arg.ticketSeq, arg.account));
 
-    jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    Account const& account = requireValue(arg.account, "account");
+    auto const amt = requireValue(arg.amt, "amt");
 
-    if (arg.amt)
-        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
-    if (arg.holderPubKey)
-        jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey);
-
-    Buffer holderCiphertext;
-    Buffer issuerCiphertext;
-    std::optional auditorCiphertext;
-    Buffer blindingFactor;
-
-    fillConversionCiphertexts(
-        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
-
-    jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
-    if (arg.proof)
-    {
-        jv[sfZKProof.jsonName] = *arg.proof;
-    }
-    else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value()))
-    {
-        // whether to automatically generate and attach a Schnorr proof:
-        // if fillSchnorrProof is explicitly set, follow its value;
-        // otherwise, default to generating the proof only if holder pub key is
-        // present.
-        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
-        auto const contextHash =
-            getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq);
-
-        auto const proof = getSchnorrProof(*arg.account, contextHash);
-        if (proof)
-        {
-            jv[sfZKProof.jsonName] = strHex(*proof);
-        }
-        else
-        {
-            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength));
-        }
-    }
-
-    auto const holderAmt = getBalance(*arg.account);
+    auto const holderAmt = getBalance(account);
     auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance();
 
-    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
 
     if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
         Throw("Failed to get Pre-convert balance");
 
+    // The auditor mirror is only touched if the transaction carries an auditor
+    // ciphertext, which mirrors the condition convertJV fills it under.
+    bool const hasAuditorAmt =
+        arg.auditorEncryptedAmt || (auditor_ && arg.fillAuditorEncryptedAmt.value_or(false));
+
     std::optional prevAuditorBalance;
-    if (arg.auditorEncryptedAmt || auditor_)
+    if (hasAuditorAmt)
     {
-        prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance);
         if (!prevAuditorBalance)
             Throw("Failed to get Pre-convert balance");
     }
@@ -1170,59 +1259,57 @@ MPTTester::convert(MPTConvert const& arg)
     {
         auto const postConfidentialOutstanding = getIssuanceConfidentialBalance();
         auto const postOutstanding = getIssuanceOutstandingBalance();
-        env_.require(MptBalance(
-            *this, requireValue(arg.account, "account"), holderAmt - requireValue(arg.amt, "amt")));
+        env_.require(MptBalance(*this, account, holderAmt - amt));
         env_.require(RequireAny([&]() -> bool {
             return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding;
         }));
         env_.require(RequireAny([&]() -> bool {
-            return prevConfidentialOutstanding + *arg.amt == postConfidentialOutstanding;
+            return prevConfidentialOutstanding + amt == postConfidentialOutstanding;
         }));
 
         env_.require(RequireAny([&]() -> bool {
-            return getEncryptedBalance(*arg.account, holderEncryptedInbox).has_value();
+            return getEncryptedBalance(account, holderEncryptedInbox).has_value();
         }));
         env_.require(RequireAny([&]() -> bool {
-            return getEncryptedBalance(*arg.account, holderEncryptedSpending).has_value();
+            return getEncryptedBalance(account, holderEncryptedSpending).has_value();
         }));
         env_.require(RequireAny([&]() -> bool {
-            return getEncryptedBalance(*arg.account, issuerEncryptedBalance).has_value();
+            return getEncryptedBalance(account, issuerEncryptedBalance).has_value();
         }));
 
-        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
-        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+        auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
+        auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
 
         if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance)
             Throw("Failed to get post-convert balance");
 
-        if (arg.auditorEncryptedAmt || auditor_)
+        if (hasAuditorAmt)
         {
-            auto const postAuditorBalance =
-                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+            auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance);
 
             if (!postAuditorBalance)
                 Throw("Failed to get post-convert auditor balance");
 
             env_.require(RequireAny([&]() -> bool {
-                return getEncryptedBalance(*arg.account, auditorEncryptedBalance).has_value();
+                return getEncryptedBalance(account, auditorEncryptedBalance).has_value();
             }));
 
             // auditor's encrypted balance is updated correctly
             env_.require(RequireAny(
-                [&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; }));
+                [&]() -> bool { return *prevAuditorBalance + amt == *postAuditorBalance; }));
         }
         // spending balance should not change
         env_.require(
             RequireAny([&]() -> bool { return *postSpendingBalance == *prevSpendingBalance; }));
 
         // issuer's encrypted balance is updated correctly
-        env_.require(RequireAny(
-            [&]() -> bool { return *prevIssuerBalance + *arg.amt == *postIssuerBalance; }));
+        env_.require(
+            RequireAny([&]() -> bool { return *prevIssuerBalance + amt == *postIssuerBalance; }));
 
         // holder's inbox balance is updated correctly
-        env_.require(RequireAny(
-            [&]() -> bool { return *prevInboxBalance + *arg.amt == *postInboxBalance; }));
+        env_.require(
+            RequireAny([&]() -> bool { return *prevInboxBalance + amt == *postInboxBalance; }));
 
         // sum of holder's inbox and spending balance should equal to issuer's
         // encrypted balance
@@ -1237,7 +1324,7 @@ MPTTester::convert(MPTConvert const& arg)
                     [&](SLEP const& sle) -> bool {
                         if (sle)
                         {
-                            auto const holderPubKey = getPubKey(*arg.account);
+                            auto const holderPubKey = getPubKey(account);
                             if (!holderPubKey)
                             {
                                 Throw(
@@ -1249,7 +1336,7 @@ MPTTester::convert(MPTConvert const& arg)
                         }
                         return false;
                     },
-                    arg.account);
+                    account);
             }));
         }
     }
@@ -1259,41 +1346,17 @@ json::Value
 MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq)
 {
     json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
+    Account const& account = setAccountField(jv, arg.account);
 
     jv[jss::TransactionType] = jss::ConfidentialMPTConvert;
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    setIssuanceIdField(jv, arg.id);
 
-    if (arg.amt)
-        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+    auto const amt = requireValue(arg.amt, "amt");
+    jv[sfMPTAmount.jsonName] = std::to_string(amt);
     if (arg.holderPubKey)
         jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey);
 
-    Buffer holderCiphertext;
-    Buffer issuerCiphertext;
-    std::optional auditorCiphertext;
-    Buffer blindingFactor;
-
-    fillConversionCiphertexts(
-        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
-
-    jv[sfBlindingFactor.jsonName] = strHex(blindingFactor);
+    fillConversionCiphertexts(arg, jv, account, amt);
 
     if (arg.proof)
     {
@@ -1301,17 +1364,8 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq)
     }
     else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value()))
     {
-        auto const contextHash =
-            getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq);
-        auto const proof = getSchnorrProof(*arg.account, contextHash);
-        if (proof)
-        {
-            jv[sfZKProof.jsonName] = strHex(*proof);
-        }
-        else
-        {
-            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength));
-        }
+        auto const contextHash = getConvertContextHash(account.id(), issuanceID(), seq);
+        setProofOrDummy(jv, getSchnorrProof(account, contextHash), kEcSchnorrProofLength);
     }
 
     return jv;
@@ -1320,253 +1374,48 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq)
 void
 MPTTester::send(MPTConfidentialSend const& arg)
 {
-    json::Value jv;
-    jv[jss::TransactionType] = jss::ConfidentialMPTSend;
+    json::Value const jv = sendJV(arg, ticketOrSeq(arg.ticketSeq, arg.account));
 
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
-
-    if (arg.dest)
-    {
-        jv[sfDestination] = arg.dest->human();
-    }
-    else
-    {
-        Throw("Destination not specified");
-    }
-
-    if (!arg.amt)
-        Throw("Amount not specified for testing purposes");
-
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
-
-    Buffer const blindingFactor =
-        arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
-
-    // fill in the encrypted amounts if not provided
-    auto const senderAmt = arg.senderEncryptedAmt
-        ? *arg.senderEncryptedAmt
-        : encryptAmount(*arg.account, *arg.amt, blindingFactor);
-    auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt
-                                              : encryptAmount(*arg.dest, *arg.amt, blindingFactor);
-    auto const issuerAmt = arg.issuerEncryptedAmt
-        ? *arg.issuerEncryptedAmt
-        : encryptAmount(issuer_, *arg.amt, blindingFactor);
-
-    std::optional auditorAmt;
-    if (arg.auditorEncryptedAmt)
-    {
-        auditorAmt = arg.auditorEncryptedAmt;
-    }
-    else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
-    {
-        auditorAmt = encryptAmount(
-            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
-    }
-
-    jv[sfSenderEncryptedAmount] = strHex(senderAmt);
-    jv[sfDestinationEncryptedAmount] = strHex(destAmt);
-    jv[sfIssuerEncryptedAmount] = strHex(issuerAmt);
-    if (auditorAmt)
-        jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt);
-
-    if (arg.credentials)
-    {
-        auto& arr(jv[sfCredentialIDs.jsonName] = json::ValueType::Array);
-        for (auto const& hash : *arg.credentials)
-            arr.append(hash);
-    }
+    Account const& account = requireValue(arg.account, "account");
+    Account const& dest = requireValue(arg.dest, "dest");
+    auto const amt = requireValue(arg.amt, "amt");
 
     // Version counters before send
-    auto const prevSenderVersion = getMPTokenVersion(*arg.account);
-    auto const prevDestVersion = getMPTokenVersion(*arg.dest);
+    auto const prevSenderVersion = getMPTokenVersion(account);
+    auto const prevDestVersion = getMPTokenVersion(dest);
 
     // Sender's previous confidential state
-    auto const prevSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-    auto const prevSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-    auto const prevSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
-    auto const prevSenderInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox);
-    auto const prevSenderSpendingEncrypted =
-        getEncryptedBalance(*arg.account, holderEncryptedSpending);
-    auto const prevSenderIssuerEncrypted =
-        getEncryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevSenderInbox = getDecryptedBalance(account, holderEncryptedInbox);
+    auto const prevSenderSpending = getDecryptedBalance(account, holderEncryptedSpending);
+    auto const prevSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance);
     if (!prevSenderInbox || !prevSenderSpending || !prevSenderIssuer)
         Throw("Failed to get Pre-send balance");
 
     std::optional prevSenderAuditor;
-    auto const prevSenderAuditorEncrypted =
-        getEncryptedBalance(*arg.account, auditorEncryptedBalance);
     if (arg.auditorEncryptedAmt || auditor_)
     {
-        prevSenderAuditor = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        prevSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance);
         if (!prevSenderAuditor)
             Throw("Failed to get Pre-send balance");
     }
 
     // Destination's previous confidential state
-    auto const prevDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox);
-    auto const prevDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending);
-    auto const prevDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance);
-    auto const prevDestInboxEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedInbox);
-    auto const prevDestSpendingEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedSpending);
-    auto const prevDestIssuerEncrypted = getEncryptedBalance(*arg.dest, issuerEncryptedBalance);
+    auto const prevDestInbox = getDecryptedBalance(dest, holderEncryptedInbox);
+    auto const prevDestSpending = getDecryptedBalance(dest, holderEncryptedSpending);
+    auto const prevDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance);
     if (!prevDestInbox || !prevDestSpending || !prevDestIssuer)
         Throw("Failed to get Pre-send balance");
 
     std::optional prevDestAuditor;
-    auto const prevDestAuditorEncrypted = getEncryptedBalance(*arg.dest, auditorEncryptedBalance);
     if (arg.auditorEncryptedAmt || auditor_)
     {
-        prevDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance);
+        prevDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance);
         if (!prevDestAuditor)
             Throw("Failed to get Pre-send balance");
     }
 
-    // Fill in the commitment if not provided
-    // The amount commitment must use the same blinding factor as the ElGamal
-    // encryption. The sigma proof links the two, so using different randomness
-    // for each would cause proof verification to fail.
-    Buffer amountCommitment, balanceCommitment;
-    if (arg.amountCommitment)
-    {
-        amountCommitment = *arg.amountCommitment;
-    }
-    else
-    {
-        amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor);
-    }
-
-    jv[sfAmountCommitment] = strHex(amountCommitment);
-
-    auto const balanceBlindingFactor = generateBlindingFactor();
-    if (arg.balanceCommitment)
-    {
-        balanceCommitment = *arg.balanceCommitment;
-    }
-    else
-    {
-        balanceCommitment = getPedersenCommitment(*prevSenderSpending, balanceBlindingFactor);
-    }
-
-    jv[sfBalanceCommitment] = strHex(balanceCommitment);
-
-    // Fill in the proof if not provided
-    if (arg.proof)
-    {
-        jv[sfZKProof] = *arg.proof;
-    }
-    else
-    {
-        auto const version = getMPTokenVersion(*arg.account);
-        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
-        auto const ctxHash = getSendContextHash(
-            requireValue(arg.account, "account").id(),
-            issuanceID(),
-            seq,
-            requireValue(arg.dest, "dest").id(),
-            version);
-
-        std::vector recipients;
-
-        auto const senderPubKey = getPubKey(*arg.account);
-        auto const destPubKey = getPubKey(*arg.dest);
-        auto const issuerPubKey = getPubKey(issuer_);
-
-        // If a key is missing, we skip adding the recipient. This intentionally
-        // causes proof generation to fail, triggering the dummy proof fallback.
-        if (senderPubKey)
-        {
-            recipients.push_back({
-                .publicKey = Slice(*senderPubKey),
-                .encryptedAmount = senderAmt,
-            });
-        }
-        if (destPubKey)
-        {
-            recipients.push_back({
-                .publicKey = Slice(*destPubKey),
-                .encryptedAmount = destAmt,
-            });
-        }
-        if (issuerPubKey)
-        {
-            recipients.push_back({
-                .publicKey = Slice(*issuerPubKey),
-                .encryptedAmount = issuerAmt,
-            });
-        }
-
-        std::optional auditorPubKey;
-        if (auditorAmt)
-        {
-            if (!auditor_)
-                Throw("Auditor not registered");
-
-            auditorPubKey = getPubKey(*auditor_);
-            if (auditorPubKey)
-            {
-                recipients.push_back({
-                    .publicKey = Slice(*auditorPubKey),
-                    .encryptedAmount = *auditorAmt,
-                });
-            }
-        }
-
-        std::optional proof;
-
-        // Skip proof generation if encrypted balance is missing (e.g.,
-        // feature disabled), when the sender and destination are the same
-        // (malformed case causing pcm to be zero), or when spending balance
-        // is 0
-        if (arg.account != arg.dest && prevSenderSpendingEncrypted && *prevSenderSpending > 0)
-        {
-            proof = getConfidentialSendProof(
-                *arg.account,
-                *arg.amt,
-                recipients,
-                blindingFactor,
-                ctxHash,
-                {
-                    .pedersenCommitment = amountCommitment,
-                    .amt = *arg.amt,
-                    .encryptedAmt = senderAmt,
-                    .blindingFactor = blindingFactor,
-                },
-                {
-                    .pedersenCommitment = balanceCommitment,
-                    .amt = *prevSenderSpending,
-                    .encryptedAmt = *prevSenderSpendingEncrypted,
-                    .blindingFactor = balanceBlindingFactor,
-                });
-        }
-
-        if (proof)
-        {
-            jv[sfZKProof.jsonName] = strHex(*proof);
-        }
-        else
-        {
-            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength));
-        }
-    }
-
-    auto const senderPubAmt = getBalance(*arg.account);
-    auto const destPubAmt = getBalance(*arg.dest);
+    auto const senderPubAmt = getBalance(account);
+    auto const destPubAmt = getBalance(dest);
     auto const prevCOA = getIssuanceConfidentialBalance();
     auto const prevOA = getIssuanceOutstandingBalance();
 
@@ -1576,24 +1425,24 @@ MPTTester::send(MPTConfidentialSend const& arg)
         auto const postOA = getIssuanceOutstandingBalance();
 
         // Sender's post confidential state
-        auto const postSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-        auto const postSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-        auto const postSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+        auto const postSenderInbox = getDecryptedBalance(account, holderEncryptedInbox);
+        auto const postSenderSpending = getDecryptedBalance(account, holderEncryptedSpending);
+        auto const postSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance);
 
         if (!postSenderInbox || !postSenderSpending || !postSenderIssuer)
             Throw("Failed to get Post-send balance");
 
         // Destination's post confidential state
-        auto const postDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox);
-        auto const postDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending);
-        auto const postDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance);
+        auto const postDestInbox = getDecryptedBalance(dest, holderEncryptedInbox);
+        auto const postDestSpending = getDecryptedBalance(dest, holderEncryptedSpending);
+        auto const postDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance);
 
         if (!postDestInbox || !postDestSpending || !postDestIssuer)
             Throw("Failed to get Post-send balance");
 
         // Public balances unchanged
-        env_.require(MptBalance(*this, *arg.account, senderPubAmt));
-        env_.require(MptBalance(*this, *arg.dest, destPubAmt));
+        env_.require(MptBalance(*this, account, senderPubAmt));
+        env_.require(MptBalance(*this, dest, destPubAmt));
 
         // OA and COA unchanged
         env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; }));
@@ -1601,21 +1450,18 @@ MPTTester::send(MPTConfidentialSend const& arg)
 
         // Verify sender changes
         env_.require(RequireAny([&]() -> bool {
-            return *prevSenderSpending >= *arg.amt &&
-                *postSenderSpending == *prevSenderSpending - *arg.amt;
+            return *prevSenderSpending >= amt && *postSenderSpending == *prevSenderSpending - amt;
         }));
         env_.require(RequireAny([&]() -> bool { return postSenderInbox == prevSenderInbox; }));
         env_.require(RequireAny([&]() -> bool {
-            return *prevSenderIssuer >= *arg.amt &&
-                *postSenderIssuer == *prevSenderIssuer - *arg.amt;
+            return *prevSenderIssuer >= amt && *postSenderIssuer == *prevSenderIssuer - amt;
         }));
 
         // Verify destination changes
-        env_.require(
-            RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + *arg.amt; }));
+        env_.require(RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + amt; }));
         env_.require(RequireAny([&]() -> bool { return *postDestSpending == *prevDestSpending; }));
         env_.require(
-            RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + *arg.amt; }));
+            RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + amt; }));
 
         // Cross checks
         env_.require(RequireAny(
@@ -1625,15 +1471,14 @@ MPTTester::send(MPTConfidentialSend const& arg)
 
         // Version: sender increments by 1; receiver version is unchanged by incoming sends
         env_.require(RequireAny(
-            [&]() -> bool { return getMPTokenVersion(*arg.account) == prevSenderVersion + 1; }));
+            [&]() -> bool { return getMPTokenVersion(account) == prevSenderVersion + 1; }));
         env_.require(
-            RequireAny([&]() -> bool { return getMPTokenVersion(*arg.dest) == prevDestVersion; }));
+            RequireAny([&]() -> bool { return getMPTokenVersion(dest) == prevDestVersion; }));
 
         if (arg.auditorEncryptedAmt || auditor_)
         {
-            auto const postSenderAuditor =
-                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
-            auto const postDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance);
+            auto const postSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance);
+            auto const postDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance);
             if (!postSenderAuditor || !postDestAuditor)
                 Throw("Failed to get Post-send balance");
 
@@ -1644,13 +1489,12 @@ MPTTester::send(MPTConfidentialSend const& arg)
 
             // verify sender
             env_.require(RequireAny([&]() -> bool {
-                return prevSenderAuditor >= *arg.amt &&
-                    *postSenderAuditor == *prevSenderAuditor - *arg.amt;
+                return *prevSenderAuditor >= amt && *postSenderAuditor == *prevSenderAuditor - amt;
             }));
 
             // verify dest
-            env_.require(RequireAny(
-                [&]() -> bool { return *postDestAuditor == *prevDestAuditor + *arg.amt; }));
+            env_.require(
+                RequireAny([&]() -> bool { return *postDestAuditor == *prevDestAuditor + amt; }));
         }
     }
 }
@@ -1664,49 +1508,21 @@ MPTTester::sendJV(
     json::Value jv;
     jv[jss::TransactionType] = jss::ConfidentialMPTSend;
 
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
+    Account const& account = setAccountField(jv, arg.account);
+    Account const& dest = setDestinationField(jv, arg.dest);
+    auto const amt = requireValue(arg.amt, "amt");
 
-    if (arg.dest)
-    {
-        jv[sfDestination] = arg.dest->human();
-    }
-    else
-    {
-        Throw("Destination not specified");
-    }
-
-    if (!arg.amt)
-        Throw("Amount not specified for testing purposes");
-
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    setIssuanceIdField(jv, arg.id);
 
     Buffer const blindingFactor =
         arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor();
 
-    auto const senderAmt = arg.senderEncryptedAmt
-        ? *arg.senderEncryptedAmt
-        : encryptAmount(*arg.account, *arg.amt, blindingFactor);
-    auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt
-                                              : encryptAmount(*arg.dest, *arg.amt, blindingFactor);
-    auto const issuerAmt = arg.issuerEncryptedAmt
-        ? *arg.issuerEncryptedAmt
-        : encryptAmount(issuer_, *arg.amt, blindingFactor);
+    auto const senderAmt = arg.senderEncryptedAmt ? *arg.senderEncryptedAmt
+                                                  : encryptAmount(account, amt, blindingFactor);
+    auto const destAmt =
+        arg.destEncryptedAmt ? *arg.destEncryptedAmt : encryptAmount(dest, amt, blindingFactor);
+    auto const issuerAmt = arg.issuerEncryptedAmt ? *arg.issuerEncryptedAmt
+                                                  : encryptAmount(issuer_, amt, blindingFactor);
 
     std::optional auditorAmt;
     if (arg.auditorEncryptedAmt)
@@ -1715,8 +1531,7 @@ MPTTester::sendJV(
     }
     else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false))
     {
-        auditorAmt = encryptAmount(
-            requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor);
+        auditorAmt = encryptAmount(requireValue(auditor_, "auditor"), amt, blindingFactor);
     }
 
     jv[sfSenderEncryptedAmount] = strHex(senderAmt);
@@ -1743,12 +1558,12 @@ MPTTester::sendJV(
     }
     else
     {
-        auto const ledgerSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const ledgerSpending = getDecryptedBalance(account, holderEncryptedSpending);
         if (!ledgerSpending)
             Throw("Failed to get sender spending balance");
         prevSenderSpending = *ledgerSpending;
-        prevEncryptedSenderSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending);
-        version = getMPTokenVersion(*arg.account);
+        prevEncryptedSenderSpending = getEncryptedBalance(account, holderEncryptedSpending);
+        version = getMPTokenVersion(account);
     }
 
     // The amount commitment must use the same blinding factor as the tx ElGamal
@@ -1760,7 +1575,7 @@ MPTTester::sendJV(
     }
     else
     {
-        amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor);
+        amountCommitment = getPedersenCommitment(amt, blindingFactor);
     }
 
     jv[sfAmountCommitment] = strHex(amountCommitment);
@@ -1783,17 +1598,13 @@ MPTTester::sendJV(
     }
     else
     {
-        auto const ctxHash = getSendContextHash(
-            requireValue(arg.account, "account").id(),
-            issuanceID(),
-            seq,
-            requireValue(arg.dest, "dest").id(),
-            version);
+        auto const ctxHash =
+            getSendContextHash(account.id(), issuanceID(), seq, dest.id(), version);
 
         std::vector recipients;
 
-        auto const senderPubKey = getPubKey(*arg.account);
-        auto const destPubKey = getPubKey(*arg.dest);
+        auto const senderPubKey = getPubKey(account);
+        auto const destPubKey = getPubKey(dest);
         auto const issuerPubKey = getPubKey(issuer_);
 
         if (senderPubKey)
@@ -1836,17 +1647,17 @@ MPTTester::sendJV(
         std::optional proof;
 
         // Skip proof generation when spending balance is 0
-        if (arg.account != arg.dest && prevEncryptedSenderSpending && prevSenderSpending > 0)
+        if (prevEncryptedSenderSpending && prevSenderSpending > 0)
         {
             proof = getConfidentialSendProof(
-                *arg.account,
-                *arg.amt,
+                account,
+                amt,
                 recipients,
                 blindingFactor,
                 ctxHash,
                 {
                     .pedersenCommitment = amountCommitment,
-                    .amt = *arg.amt,
+                    .amt = amt,
                     .encryptedAmt = senderAmt,
                     .blindingFactor = blindingFactor,
                 },
@@ -1858,14 +1669,7 @@ MPTTester::sendJV(
                 });
         }
 
-        if (proof)
-        {
-            jv[sfZKProof.jsonName] = strHex(*proof);
-        }
-        else
-        {
-            jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength));
-        }
+        setProofOrDummy(jv, proof, kEcSendProofLength);
     }
 
     return jv;
@@ -1928,31 +1732,14 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg)
     auto const account = arg.account ? *arg.account : issuer_;
     jv[sfAccount] = account.human();
 
-    if (arg.holder)
-    {
-        jv[sfHolder] = arg.holder->human();
-    }
-    else
-    {
-        Throw("Holder not specified");
-    }
+    Account const& holder = requireValue(arg.holder, "holder");
+    jv[sfHolder] = holder.human();
 
     jv[jss::TransactionType] = jss::ConfidentialMPTClawback;
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else if (id_)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
-    else
-    {
-        Throw("MPT has not been created");
-    }
+    setIssuanceIdField(jv, arg.id);
 
-    if (arg.amt)
-        jv[sfMPTAmount] = std::to_string(*arg.amt);
+    auto const amt = requireValue(arg.amt, "amt");
+    jv[sfMPTAmount] = std::to_string(amt);
 
     if (arg.proof)
     {
@@ -1961,69 +1748,56 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg)
     else
     {
         auto const seq = arg.ticketSeq ? *arg.ticketSeq : env_.seq(account);
-        auto const contextHash = getClawbackContextHash(
-            account.id(), issuanceID(), seq, requireValue(arg.holder, "holder").id());
+        auto const contextHash =
+            getClawbackContextHash(account.id(), issuanceID(), seq, holder.id());
 
         auto const privKey = getPrivKey(account);
         if (!privKey || privKey->size() != kEcPrivKeyLength)
             Throw("Failed to get clawback private key");
 
-        auto const proof = getClawbackProof(
-            requireValue(arg.holder, "holder"),
-            requireValue(arg.amt, "amt"),
-            requireValue(privKey, "privKey"),
-            contextHash);
+        auto const proof =
+            getClawbackProof(holder, amt, requireValue(privKey, "privKey"), contextHash);
 
-        if (proof)
-        {
-            jv[sfZKProof] = strHex(*proof);
-        }
-        else
-        {
-            jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcClawbackProofLength));
-        }
+        setProofOrDummy(jv, proof, kEcClawbackProofLength);
     }
 
-    auto const holderPubAmt = getBalance(*arg.holder);
+    auto const holderPubAmt = getBalance(holder);
     auto const prevCOA = getIssuanceConfidentialBalance();
     auto const prevOA = getIssuanceOutstandingBalance();
-    auto const prevVersion = getMPTokenVersion(*arg.holder);
+    auto const prevVersion = getMPTokenVersion(holder);
 
     if (submit(arg, jv) == tesSUCCESS)
     {
         auto const postCOA = getIssuanceConfidentialBalance();
         auto const postOA = getIssuanceOutstandingBalance();
-        auto const postVersion = getMPTokenVersion(*arg.holder);
+        auto const postVersion = getMPTokenVersion(holder);
 
         // Verify holder's public balance is unchanged
-        env_.require(MptBalance(*this, *arg.holder, holderPubAmt));
+        env_.require(MptBalance(*this, holder, holderPubAmt));
 
         // Verify COA and OA are reduced correctly
-        env_.require(RequireAny(
-            [&]() -> bool { return prevCOA >= *arg.amt && postCOA == prevCOA - *arg.amt; }));
+        env_.require(
+            RequireAny([&]() -> bool { return prevCOA >= amt && postCOA == prevCOA - amt; }));
         env_.require(RequireAny([&]() -> bool {
-            return prevOA && postOA && *prevOA >= *arg.amt && *postOA == *prevOA - *arg.amt;
+            return prevOA && postOA && *prevOA >= amt && *postOA == *prevOA - amt;
         }));
 
         // Verify holder's confidential balances are zeroed out
         env_.require(RequireAny(
-            [&]() -> bool { return getDecryptedBalance(*arg.holder, holderEncryptedInbox) == 0; }));
-        env_.require(RequireAny([&]() -> bool {
-            return getDecryptedBalance(*arg.holder, holderEncryptedSpending) == 0;
-        }));
-        env_.require(RequireAny([&]() -> bool {
-            return getDecryptedBalance(*arg.holder, issuerEncryptedBalance) == 0;
-        }));
-        env_.require(RequireAny([&]() -> bool {
-            return getDecryptedBalance(*arg.holder, auditorEncryptedBalance) == 0;
-        }));
+            [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedInbox) == 0; }));
+        env_.require(RequireAny(
+            [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedSpending) == 0; }));
+        env_.require(RequireAny(
+            [&]() -> bool { return getDecryptedBalance(holder, issuerEncryptedBalance) == 0; }));
+        env_.require(RequireAny(
+            [&]() -> bool { return getDecryptedBalance(holder, auditorEncryptedBalance) == 0; }));
 
         // Verify version is incremented
         env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; }));
     }
 }
 
-void
+std::uint32_t
 MPTTester::generateKeyPair(Account const& account)
 {
     unsigned char privKey[kEcPrivKeyLength];
@@ -2041,26 +1815,23 @@ MPTTester::generateKeyPair(Account const& account)
         Throw("failed to serialize public key");
     }
 
-    pubKeys_.insert({account.id(), Buffer{compressedPubKey, kEcPubKeyLength}});
-    privKeys_.insert({account.id(), Buffer{privKey, kEcPrivKeyLength}});
+    auto& pubKeyEpochs = pubKeys_[account.id()];
+    pubKeyEpochs.emplace_back(compressedPubKey, kEcPubKeyLength);
+    privKeys_[account.id()].emplace_back(privKey, kEcPrivKeyLength);
+
+    return static_cast(pubKeyEpochs.size() - 1);
 }
 
 std::optional
-MPTTester::getPubKey(Account const& account) const
+MPTTester::getPubKey(Account const& account, std::optional epoch) const
 {
-    if (auto const it = pubKeys_.find(account.id()); it != pubKeys_.end())
-        return it->second;
-
-    return std::nullopt;
+    return keyAtEpoch(pubKeys_, account.id(), epoch);
 }
 
 std::optional
-MPTTester::getPrivKey(Account const& account) const
+MPTTester::getPrivKey(Account const& account, std::optional epoch) const
 {
-    if (auto const it = privKeys_.find(account.id()); it != privKeys_.end())
-        return it->second;
-
-    return std::nullopt;
+    return keyAtEpoch(privKeys_, account.id(), epoch);
 }
 
 Buffer
@@ -2079,7 +1850,10 @@ MPTTester::encryptAmount(Account const& account, uint64_t const amt, Buffer cons
 }
 
 std::optional
-MPTTester::decryptAmount(Account const& account, Buffer const& amt) const
+MPTTester::decryptAmount(
+    Account const& account,
+    Buffer const& amt,
+    std::optional epoch) const
 {
     if (amt.size() != kEcGamalEncryptedTotalLength)
         return std::nullopt;
@@ -2088,7 +1862,7 @@ MPTTester::decryptAmount(Account const& account, Buffer const& amt) const
     if (!pair)
         return std::nullopt;
 
-    auto const privKey = getPrivKey(account);
+    auto const privKey = getPrivKey(account, epoch);
     if (!privKey || privKey->size() != kEcPrivKeyLength)
         return std::nullopt;
 
@@ -2120,42 +1894,32 @@ MPTTester::getDecryptedBalance(Account const& account, EncryptedBalanceType bala
 
     Account decryptor = account;
 
+    // A mirror stays encrypted under the key it was written with, so a rotation
+    // leaves it readable only by that generation of the key, not the latest one.
+    std::optional epoch;
+
     if (balanceType == issuerEncryptedBalance)
     {
         decryptor = issuer_;
+        epoch = getMirrorEpoch(account, sfIssuerKeyMirrorEpoch).value_or(0);
     }
     else if (balanceType == auditorEncryptedBalance)
     {
         if (!auditor_)
             return std::nullopt;
         decryptor = *auditor_;
+        epoch = getMirrorEpoch(account, sfAuditorKeyMirrorEpoch).value_or(0);
     }
 
-    return decryptAmount(decryptor, *encryptedAmt);
-};
+    return decryptAmount(decryptor, *encryptedAmt, epoch);
+}
 
 json::Value
 MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const
 {
     json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    setAccountField(jv, arg.account);
+    setIssuanceIdField(jv, arg.id);
     jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox;
     return jv;
 }
@@ -2163,36 +1927,18 @@ MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const
 void
 MPTTester::mergeInbox(MPTMergeInbox const& arg)
 {
-    json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    json::Value const jv = mergeInboxJV(arg);
+    Account const& account = requireValue(arg.account, "account");
 
-    jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox;
-    auto const holderPubAmt = getBalance(*arg.account);
+    auto const holderPubAmt = getBalance(account);
     auto const prevCOA = getIssuanceConfidentialBalance();
     auto const prevOA = getIssuanceOutstandingBalance();
-    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
-    auto const prevIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance);
-    auto const prevAuditorEncrypted = getEncryptedBalance(*arg.account, auditorEncryptedBalance);
-    auto const prevVersion = getMPTokenVersion(*arg.account);
+    auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
+    auto const prevIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance);
+    auto const prevAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance);
+    auto const prevVersion = getMPTokenVersion(account);
 
     if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
         Throw("Failed to get pre-mergeInbox balances");
@@ -2201,20 +1947,19 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg)
     {
         auto const postCOA = getIssuanceConfidentialBalance();
         auto const postOA = getIssuanceOutstandingBalance();
-        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
-        auto const postInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox);
-        auto const postIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance);
-        auto const postAuditorEncrypted =
-            getEncryptedBalance(*arg.account, auditorEncryptedBalance);
-        auto const postVersion = getMPTokenVersion(*arg.account);
+        auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+        auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
+        auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
+        auto const postInboxEncrypted = getEncryptedBalance(account, holderEncryptedInbox);
+        auto const postIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance);
+        auto const postAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance);
+        auto const postVersion = getMPTokenVersion(account);
 
         if (!postInboxBalance || !postSpendingBalance || !postIssuerBalance ||
             !prevIssuerEncrypted || !postInboxEncrypted || !postIssuerEncrypted)
             Throw("Failed to get post-mergeInbox balances");
 
-        env_.require(MptBalance(*this, *arg.account, holderPubAmt));
+        env_.require(MptBalance(*this, account, holderPubAmt));
         env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; }));
         env_.require(RequireAny([&]() -> bool { return prevCOA == postCOA; }));
 
@@ -2226,14 +1971,12 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg)
         env_.require(
             RequireAny([&]() -> bool { return *prevIssuerBalance == *postIssuerBalance; }));
 
-        auto const holderPubKey = getPubKey(*arg.account);
+        auto const holderPubKey = getPubKey(account);
         if (!holderPubKey)
             Throw("Failed to get holder public key");
 
         auto const expectedInbox = encryptCanonicalZeroAmount(
-            requireValue(holderPubKey, "holderPubKey"),
-            requireValue(arg.account, "account").id(),
-            issuanceID());
+            requireValue(holderPubKey, "holderPubKey"), account.id(), issuanceID());
         if (!expectedInbox)
             Throw("Failed to get canonical zero encryption");
 
@@ -2285,158 +2028,74 @@ MPTTester::getMPTokenVersion(Account const account) const
 void
 MPTTester::convertBack(MPTConvertBack const& arg)
 {
-    json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
+    json::Value const jv = convertBackJV(arg, ticketOrSeq(arg.ticketSeq, arg.account));
 
-    jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack;
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    Account const& account = requireValue(arg.account, "account");
+    auto const amt = requireValue(arg.amt, "amt");
 
-    if (arg.amt)
-        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
-
-    Buffer holderCiphertext;
-    Buffer issuerCiphertext;
-    std::optional auditorCiphertext;
-    Buffer blindingFactor;
-
-    fillConversionCiphertexts(
-        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
-
-    jv[sfBlindingFactor] = strHex(blindingFactor);
-
-    auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
-    auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
+    auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+    auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
+    auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
 
     if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance)
         Throw("Failed to get Pre-convertBack balance");
 
-    Buffer pedersenCommitment;
-    Buffer const pcBlindingFactor = generateBlindingFactor();
-    if (arg.pedersenCommitment)
-    {
-        pedersenCommitment = *arg.pedersenCommitment;
-    }
-    else
-    {
-        pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor);
-    }
-
-    jv[sfBalanceCommitment] = strHex(pedersenCommitment);
-
-    if (arg.proof)
-    {
-        jv[sfZKProof.jsonName] = strHex(*arg.proof);
-    }
-    else
-    {
-        auto const version = getMPTokenVersion(*arg.account);
-
-        // if the caller generated ciphertexts themselves, they should also
-        // generate the proof themselves from the blinding factor
-        auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account));
-        auto const contextHash = getConvertBackContextHash(
-            requireValue(arg.account, "account").id(), issuanceID(), seq, version);
-        auto const prevEncryptedSpendingBalance =
-            getEncryptedBalance(*arg.account, holderEncryptedSpending);
-
-        Buffer proof;
-        // generate a dummy proof if no encrypted amount field, so that other
-        // preflight/preclaim are checked
-        if (!prevEncryptedSpendingBalance)
-        {
-            proof = gMakeZeroBuffer(kEcConvertBackProofLength);
-        }
-        else
-        {
-            proof = getConvertBackProof(
-                *arg.account,
-                requireValue(arg.amt, "amt"),
-                contextHash,
-                {
-                    .pedersenCommitment = pedersenCommitment,
-                    .amt = *prevSpendingBalance,
-                    .encryptedAmt = *prevEncryptedSpendingBalance,
-                    .blindingFactor = pcBlindingFactor,
-                });
-        }
-        jv[sfZKProof] = strHex(proof);
-    }
-
-    auto const holderAmt = getBalance(*arg.account);
+    auto const holderAmt = getBalance(account);
     auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance();
 
     std::optional prevAuditorBalance;
     if (arg.auditorEncryptedAmt || auditor_)
     {
-        prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+        prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance);
         if (!prevAuditorBalance)
             Throw("Failed to get Pre-convertBack balance");
     }
 
     auto const prevOutstanding = getIssuanceOutstandingBalance();
-    auto const prevVersion = getMPTokenVersion(*arg.account);
+    auto const prevVersion = getMPTokenVersion(account);
 
     if (submit(arg, jv) == tesSUCCESS)
     {
         auto const postConfidentialOutstanding = getIssuanceConfidentialBalance();
         auto const postOutstanding = getIssuanceOutstandingBalance();
-        auto const postVersion = getMPTokenVersion(*arg.account);
-        env_.require(MptBalance(
-            *this, requireValue(arg.account, "account"), holderAmt + requireValue(arg.amt, "amt")));
+        auto const postVersion = getMPTokenVersion(account);
+        env_.require(MptBalance(*this, account, holderAmt + amt));
         env_.require(RequireAny([&]() -> bool {
             return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding;
         }));
         env_.require(RequireAny([&]() -> bool {
-            return prevConfidentialOutstanding - *arg.amt == postConfidentialOutstanding;
+            return prevConfidentialOutstanding - amt == postConfidentialOutstanding;
         }));
 
-        auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox);
-        auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance);
-        auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+        auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox);
+        auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance);
+        auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
 
         if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance)
             Throw("Failed to get post-convertBack balance");
 
         if (arg.auditorEncryptedAmt || auditor_)
         {
-            auto const postAuditorBalance =
-                getDecryptedBalance(*arg.account, auditorEncryptedBalance);
+            auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance);
 
             if (!postAuditorBalance)
                 Throw("Failed to get post-convertBack balance");
 
             // auditor's encrypted balance is updated correctly
             env_.require(RequireAny(
-                [&]() -> bool { return *prevAuditorBalance - *arg.amt == *postAuditorBalance; }));
+                [&]() -> bool { return *prevAuditorBalance - amt == *postAuditorBalance; }));
         }
 
         // inbox balance should not change
         env_.require(RequireAny([&]() -> bool { return *postInboxBalance == *prevInboxBalance; }));
 
         // issuer's encrypted balance is updated correctly
-        env_.require(RequireAny(
-            [&]() -> bool { return *prevIssuerBalance - *arg.amt == *postIssuerBalance; }));
+        env_.require(
+            RequireAny([&]() -> bool { return *prevIssuerBalance - amt == *postIssuerBalance; }));
 
         // holder's spending balance is updated correctly
         env_.require(RequireAny(
-            [&]() -> bool { return *prevSpendingBalance - *arg.amt == *postSpendingBalance; }));
+            [&]() -> bool { return *prevSpendingBalance - amt == *postSpendingBalance; }));
 
         // holder's confidential balance version is updated correctly
         env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; }));
@@ -2453,41 +2112,17 @@ json::Value
 MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq)
 {
     json::Value jv;
-    if (arg.account)
-    {
-        jv[sfAccount] = arg.account->human();
-    }
-    else
-    {
-        Throw("Account not specified");
-    }
+    Account const& account = setAccountField(jv, arg.account);
 
     jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack;
-    if (arg.id)
-    {
-        jv[sfMPTokenIssuanceID] = to_string(*arg.id);
-    }
-    else
-    {
-        if (!id_)
-            Throw("MPT has not been created");
-        jv[sfMPTokenIssuanceID] = to_string(*id_);
-    }
+    setIssuanceIdField(jv, arg.id);
 
-    if (arg.amt)
-        jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt);
+    auto const amt = requireValue(arg.amt, "amt");
+    jv[sfMPTAmount.jsonName] = std::to_string(amt);
 
-    Buffer holderCiphertext;
-    Buffer issuerCiphertext;
-    std::optional auditorCiphertext;
-    Buffer blindingFactor;
+    fillConversionCiphertexts(arg, jv, account, amt);
 
-    fillConversionCiphertexts(
-        arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor);
-
-    jv[sfBlindingFactor] = strHex(blindingFactor);
-
-    auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending);
+    auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending);
     if (!prevSpendingBalance)
         Throw("convertBackJV: failed to read spending balance from ledger");
 
@@ -2510,21 +2145,17 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq)
     }
     else
     {
-        auto const version = getMPTokenVersion(*arg.account);
-        auto const prevEncSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending);
-        auto const contextHash = getConvertBackContextHash(
-            requireValue(arg.account, "account").id(), issuanceID(), seq, version);
+        auto const version = getMPTokenVersion(account);
+        auto const prevEncSpending = getEncryptedBalance(account, holderEncryptedSpending);
+        auto const contextHash =
+            getConvertBackContextHash(account.id(), issuanceID(), seq, version);
 
-        Buffer proof;
-        if (!prevEncSpending)
-        {
-            proof = gMakeZeroBuffer(kEcConvertBackProofLength);
-        }
-        else
+        std::optional proof;
+        if (prevEncSpending)
         {
             proof = getConvertBackProof(
-                *arg.account,
-                requireValue(arg.amt, "amt"),
+                account,
+                amt,
                 contextHash,
                 {
                     .pedersenCommitment = pedersenCommitment,
@@ -2534,10 +2165,39 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq)
                 });
         }
 
-        jv[sfZKProof] = strHex(proof);
+        setProofOrDummy(jv, proof, kEcConvertBackProofLength);
     }
 
     return jv;
 }
 
+void
+MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg)
+{
+    json::Value jv;
+    jv[jss::TransactionType] = jss::ConfidentialMPTMirrorUpdate;
+
+    setAccountField(jv, arg.account);
+    setIssuanceIdField(jv, arg.id);
+
+    if (arg.holder)
+        jv[sfHolder] = arg.holder->human();
+    if (arg.issuerEncryptedAmount)
+        jv[sfIssuerEncryptedAmount] = strHex(*arg.issuerEncryptedAmount);
+    if (arg.auditorEncryptedAmount)
+        jv[sfAuditorEncryptedAmount] = strHex(*arg.auditorEncryptedAmount);
+
+    // Placeholder for proof, the logic will be added in the future
+    if (arg.zkProof)
+    {
+        jv[sfZKProof] = strHex(*arg.zkProof);
+    }
+    else
+    {
+        jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcEqualityProofLength));
+    }
+
+    submit(arg, jv);
+}
+
 }  // namespace xrpl::test::jtx
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/sponsor.cpp b/src/test/jtx/impl/sponsor.cpp
index cdf68800f5..453ccebcb9 100644
--- a/src/test/jtx/impl/sponsor.cpp
+++ b/src/test/jtx/impl/sponsor.cpp
@@ -21,18 +21,18 @@ namespace xrpl::test::jtx::sponsor {
 json::Value
 set(jtx::Account const& account,
     uint32_t flags,
-    std::optional const reserveCount,
-    std::optional const feeAmount,
+    std::optional const reserveCountDelta,
+    std::optional const feeAmountDelta,
     std::optional const maxFee)
 {
     json::Value jv;
     jv[jss::TransactionType] = jss::SponsorshipSet;
     jv[jss::Account] = account.human();
     jv[sfFlags.jsonName] = flags;
-    if (reserveCount)
-        jv[sfRemainingOwnerCount.jsonName] = *reserveCount;
-    if (feeAmount)
-        jv[sfFeeAmount.jsonName] = feeAmount->getJson(JsonOptions::Values::None);
+    if (reserveCountDelta)
+        jv[sfRemainingOwnerCountDelta.jsonName] = *reserveCountDelta;
+    if (feeAmountDelta)
+        jv[sfFeeAmountDelta.jsonName] = feeAmountDelta->getJson(JsonOptions::Values::None);
     if (maxFee)
         jv[sfMaxFee.jsonName] = maxFee->getJson(JsonOptions::Values::None);
     return jv;
diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp
index c298cee684..6b2c9b69b9 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()});
@@ -66,7 +78,7 @@ fillFee(json::Value& jv, ReadView const& view)
     auto const txType = jv[jss::TransactionType].asString();
     if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack ||
         txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox ||
-        txType == jss::ConfidentialMPTClawback)
+        txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate)
     {
         jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1));
     }
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index 7084347763..5bf8ac9981 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -3,32 +3,68 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
 std::tuple
 Vault::create(CreateArgs const& args) const
 {
-    auto keylet = keylet::vault(args.owner.id(), env.seq(args.owner));
+    auto const seqProxy = SeqProxy::rawSequence(env.seq(args.owner));
+    auto keylet = keylet::vault(args.owner.id(), seqProxy);
     json::Value jv;
     jv[jss::TransactionType] = jss::VaultCreate;
     jv[jss::Account] = args.owner.human();
     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/ledgerStateFix.h b/src/test/jtx/ledgerStateFix.h
index 2fe5c8accc..4ae22f891e 100644
--- a/src/test/jtx/ledgerStateFix.h
+++ b/src/test/jtx/ledgerStateFix.h
@@ -8,7 +8,7 @@
 /**
  * LedgerStateFix operations.
  */
-namespace xrpl::test::jtx::ledgerStateFix {
+namespace xrpl::test::jtx::ledger_state_fix {
 
 /**
  * Repair the links in an NFToken directory.
@@ -22,4 +22,4 @@ nftPageLinks(jtx::Account const& acct, jtx::Account const& owner);
 json::Value
 bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir);
 
-}  // namespace xrpl::test::jtx::ledgerStateFix
+}  // namespace xrpl::test::jtx::ledger_state_fix
diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h
index c6532ab14a..a737e76320 100644
--- a/src/test/jtx/mpt.h
+++ b/src/test/jtx/mpt.h
@@ -20,6 +20,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -147,7 +148,7 @@ struct MPTCreate
     // if empty vector then pay to either authorize or all holders.
     std::optional, std::uint64_t>> pay = std::nullopt;
     std::optional flags = {0};
-    std::optional mutableFlags = std::nullopt;
+    std::optional immutableFlags = std::nullopt;
     bool authHolder = false;
     std::optional domainID = std::nullopt;
     std::optional err = std::nullopt;
@@ -183,7 +184,7 @@ struct MPTInitDef
     std::uint16_t transferFee = 0;
     std::optional pay = std::nullopt;
     std::uint32_t flags = kMptDexFlags;
-    std::optional mutableFlags = std::nullopt;
+    std::optional immutableFlags = std::nullopt;
     bool authHolder = false;
     bool fund = false;
     bool close = true;
@@ -229,7 +230,7 @@ struct MPTSet
     std::optional ownerCount = std::nullopt;
     std::optional holderCount = std::nullopt;
     std::optional flags = std::nullopt;
-    std::optional mutableFlags = std::nullopt;
+    std::optional immutableFlags = std::nullopt;
     std::optional transferFee = std::nullopt;
     std::optional metadata = std::nullopt;
     std::optional delegate = std::nullopt;
@@ -361,6 +362,24 @@ struct MPTConfidentialClawback
     std::optional err = std::nullopt;
 };
 
+/**
+ * @brief Arguments for building a ConfidentialMPTMirrorUpdate test transaction.
+ */
+struct MPTMirrorUpdate
+{
+    std::optional account = std::nullopt;
+    std::optional holder = std::nullopt;
+    std::optional id = std::nullopt;
+    std::optional issuerEncryptedAmount = std::nullopt;
+    std::optional auditorEncryptedAmount = std::nullopt;
+    std::optional zkProof = std::nullopt;
+    std::optional fee = std::nullopt;
+    std::optional flags = std::nullopt;
+    std::optional ownerCount = std::nullopt;
+    std::optional holderCount = std::nullopt;
+    std::optional err = std::nullopt;
+};
+
 /**
  * @brief Stores the parameters that are exclusively used to generate a
  * Pedersen linkage proof.
@@ -451,8 +470,10 @@ class MPTTester
     std::optional const auditor_;
     std::optional id_;
     bool close_;
-    std::unordered_map pubKeys_;
-    std::unordered_map privKeys_;
+    // Keys generated for each account. Buffer vector's index is the key epoch: index 0 is
+    // the initial pair and each rotation appends.
+    std::unordered_map> pubKeys_;
+    std::unordered_map> privKeys_;
 
 public:
     enum class EncryptedBalanceType {
@@ -581,6 +602,9 @@ public:
     void
     confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{});
 
+    void
+    mirrorUpdate(MPTMirrorUpdate const& arg = MPTMirrorUpdate{});
+
     [[nodiscard]] bool
     checkDomainID(std::optional expected) const;
 
@@ -609,6 +633,33 @@ public:
     [[nodiscard]] bool
     isTransferFeePresent() const;
 
+    [[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 both mirror epochs on a holder's MPToken. Pass std::nullopt for an
+    // epoch that is expected to be absent, which means the mirror was written
+    // under the issuance's epoch 0 key.
+    [[nodiscard]] bool
+    checkMirrorEpochs(
+        Account const& holder,
+        std::optional issuerKeyMirrorEpoch,
+        std::optional auditorKeyMirrorEpoch) 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
     {
@@ -660,20 +711,31 @@ public:
 
     operator Asset() const;
 
-    void
+    // Generates the account's next key pair and returns the key epoch it landed
+    // at, leaving the earlier ones retrievable.
+    std::uint32_t
     generateKeyPair(Account const& account);
 
+    // Returns the account's public key at the given key epoch, or its latest key when
+    // no epoch is given.
     [[nodiscard]] std::optional
-    getPubKey(Account const& account) const;
+    getPubKey(Account const& account, std::optional epoch = std::nullopt) const;
 
+    // Returns the account's private key at the given key epoch, or its latest key when
+    // no epoch is given.
     [[nodiscard]] std::optional
-    getPrivKey(Account const& account) const;
+    getPrivKey(Account const& account, std::optional epoch = std::nullopt) const;
 
     [[nodiscard]] Buffer
     encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor) const;
 
+    // Decrypts with the account's key at the given key epoch, or its latest key
+    // when no epoch is given.
     [[nodiscard]] std::optional
-    decryptAmount(Account const& account, Buffer const& amt) const;
+    decryptAmount(
+        Account const& account,
+        Buffer const& amt,
+        std::optional epoch = std::nullopt) const;
 
     [[nodiscard]] std::optional
     getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const;
@@ -701,7 +763,7 @@ public:
         PedersenProofParams const& amountParams,
         PedersenProofParams const& balanceParams) const;
 
-    [[nodiscard]] Buffer
+    [[nodiscard]] std::optional
     getConvertBackProof(
         Account const& holder,
         std::uint64_t const amount,
@@ -727,6 +789,10 @@ private:
         std::function const& cb,
         std::optional const& holder = std::nullopt) const;
 
+    // Reads one of the holder's mirror key epochs off their MPToken.
+    [[nodiscard]] std::optional
+    getMirrorEpoch(Account const& holder, SF_UINT32 const& field) const;
+
     template 
     TER
     submit(A const& arg, json::Value jv)
@@ -796,15 +862,28 @@ private:
     [[nodiscard]] std::uint32_t
     getFlags(std::optional const& holder) const;
 
+    /**
+     * @brief Sets sfMPTokenIssuanceID on jv, falling back to id_ if arg's id is
+     *        not set.
+     *
+     * @param jv The JSON object to set the field on.
+     * @param id The explicit issuance ID override from the caller, if any.
+     */
+    void
+    setIssuanceIdField(json::Value& jv, std::optional const& id) const;
+
+    [[nodiscard]] std::uint32_t
+    ticketOrSeq(
+        std::optional const& ticketSeq,
+        std::optional const& account) const;
+
     template 
     void
     fillConversionCiphertexts(
         T const& arg,
         json::Value& jv,
-        Buffer& holderCiphertext,
-        Buffer& issuerCiphertext,
-        std::optional& auditorCiphertext,
-        Buffer& blindingFactor) const;
+        Account const& account,
+        std::uint64_t const amount) const;
 };
 
 }  // namespace xrpl::test::jtx
diff --git a/src/test/jtx/rpc.h b/src/test/jtx/rpc.h
index 9bd99c15f8..7fd550563c 100644
--- a/src/test/jtx/rpc.h
+++ b/src/test/jtx/rpc.h
@@ -48,7 +48,7 @@ public:
         jt.ter = telENV_RPC_FAILED;
         if (code_)
         {
-            auto const& errorInfo = RPC::getErrorInfo(*code_);
+            auto const& errorInfo = rpc::getErrorInfo(*code_);
             // When an RPC request returns an error code ('error_code'), it
             // always includes an error message ('error_message'), and sometimes
             // includes an error token ('error'). If it does, the error token is
diff --git a/src/test/jtx/sponsor.h b/src/test/jtx/sponsor.h
index 43d55d7246..f87a13c462 100644
--- a/src/test/jtx/sponsor.h
+++ b/src/test/jtx/sponsor.h
@@ -18,24 +18,24 @@ namespace xrpl::test::jtx::sponsor {
 json::Value
 set(jtx::Account const& account,
     std::uint32_t flags,
-    std::optional const reserveCount = std::nullopt,
-    std::optional const feeAmount = std::nullopt,
+    std::optional const reserveCountDelta = std::nullopt,
+    std::optional const feeAmountDelta = std::nullopt,
     std::optional const maxFee = std::nullopt);
 
 inline json::Value
 set_fee(
     jtx::Account const& account,
     std::uint32_t flags,
-    STAmount feeAmount,
+    STAmount feeAmountDelta,
     std::optional maxFee = std::nullopt)
 {
-    return set(account, flags, std::nullopt, std::move(feeAmount), std::move(maxFee));
+    return set(account, flags, std::nullopt, std::move(feeAmountDelta), std::move(maxFee));
 }
 
 inline json::Value
-set_reserve(jtx::Account const& account, std::uint32_t flags, std::uint32_t reserveCount)
+set_reserve(jtx::Account const& account, std::uint32_t flags, std::int32_t reserveCountDelta)
 {
-    return set(account, flags, reserveCount);
+    return set(account, flags, reserveCountDelta);
 }
 
 inline json::Value
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/CapturePeer.h b/src/test/overlay/CapturePeer.h
new file mode 100644
index 0000000000..28a26d01e7
--- /dev/null
+++ b/src/test/overlay/CapturePeer.h
@@ -0,0 +1,252 @@
+#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 
+#include 
+#include 
+
+namespace xrpl::test {
+
+/**
+ * A real `PeerImp` that captures the messages it would have sent.
+ *
+ * Only `send` and `run` are overridden, so `onMessage` runs production code.
+ * Derive from this to reach a `protected` `PeerImp` member.
+ */
+class CapturePeer : public PeerImp
+{
+public:
+    using MiddleType = boost::beast::tcp_stream;
+    using StreamType = boost::beast::ssl_stream;
+    using SocketType = boost::asio::ip::tcp::socket;
+
+    /**
+     * Takes `PeerImp`'s two rvalue-reference parameters by value instead, so a
+     * derived double can inherit this constructor without a never-moved-from
+     * warning.
+     *
+     * @param app      The application owning the peer.
+     * @param id       The connection id, unique among the overlay's peers.
+     * @param slot     The peer finder slot; must be seated.
+     * @param request  The handshake request.
+     * @param publicKey  The peer's node public key.
+     * @param protocol   The negotiated protocol version.
+     * @param consumer   The resource manager endpoint for the peer.
+     * @param streamPtr  The connection's ssl stream.
+     * @param overlay    The overlay to register with.
+     */
+    CapturePeer(
+        Application& app,
+        Peer::id_t id,
+        std::shared_ptr const& slot,
+        http_request_type request,
+        PublicKey const& publicKey,
+        ProtocolVersion protocol,
+        resource::Consumer consumer,
+        std::unique_ptr streamPtr,
+        OverlayImpl& overlay)
+        : PeerImp(
+              app,
+              id,
+              slot,
+              std::move(request),
+              publicKey,
+              protocol,
+              consumer,  // copy-only, so `std::move` would be a copy anyway
+              std::move(streamPtr),
+              overlay)
+    {
+    }
+
+    ~CapturePeer() override = default;
+
+    /**
+     * Does nothing, so the peer stays registered. The real `run()` reaches
+     * `PeerImp::doAccept`, which fails on an unconnected socket and detaches.
+     */
+    void
+    run() override
+    {
+    }
+
+    /**
+     * Captures the message instead of writing it, so replies are observable.
+     */
+    void
+    send(std::shared_ptr const& m) override
+    {
+        sent_.push_back(m);
+    }
+
+    /**
+     * @return Every message sent to this peer, in order.
+     */
+    std::vector> const&
+    sent() const
+    {
+        return sent_;
+    }
+
+    /**
+     * @return The most recent message sent, or null if there was none.
+     */
+    std::shared_ptr
+    lastSent() const
+    {
+        return sent_.empty() ? nullptr : sent_.back();
+    }
+
+    /**
+     * Reads the accumulated charge without draining it through `charge()`.
+     *
+     * @return The charge accumulated on the peer so far.
+     */
+    resource::Charge
+    feeCharge() const
+    {
+        return currentFeeCharge();
+    }
+
+private:
+    std::vector> sent_;
+};
+
+namespace detail {
+
+// `inline` so the functions below name one entity across translation units.
+inline constexpr std::uint16_t kCapturePeerPort = 51235;
+
+/**
+ * Non-template, so all `makeCapturePeer` instantiations share one counter. A
+ * per-instantiation counter would give two peer types the same id, and
+ * `addActive` would silently drop the second from `ids_`.
+ *
+ * @return The next unused connection id.
+ */
+inline Peer::id_t
+nextCapturePeerId()
+{
+    static Peer::id_t id{0};
+    return ++id;
+}
+
+/**
+ * Non-template for the same reason as `nextCapturePeerId`. Each peer needs its
+ * own address, not just its own port: the peer finder caps inbound connections
+ * per address at `ipLimit`, which is at most 2 unless configured.
+ *
+ * @return The next unused remote endpoint.
+ */
+inline beast::ip::Endpoint
+nextCapturePeerRemote()
+{
+    // From 172.2.0.1 upward, so ~900k fit before reaching 172.16/12, where the
+    // peer finder would treat them as private rather than as real inbound.
+    static std::uint32_t next{0xAC020001};
+    return beast::ip::Endpoint(boost::asio::ip::address_v4(next++), kCapturePeerPort);
+}
+
+/**
+ * Outlives every peer, whose stream keeps a reference to it. `PeerImp::charge`
+ * posts a handler holding the peer, so a peer can outlive its caller's scope.
+ *
+ * @return The ssl context every test peer's stream is built on.
+ */
+inline boost::asio::ssl::context&
+capturePeerSslContext()
+{
+    static std::shared_ptr const kContext{makeSslContext("")};
+    return *kContext;
+}
+
+}  // namespace detail
+
+/**
+ * Build an active `CapturePeer` and register it with the overlay.
+ *
+ * @tparam PeerType  The peer class to build; must derive from `CapturePeer` and
+ *                   inherit its constructor.
+ * @param env      The environment owning the overlay.
+ * @param key      The peer's node public key, or unseated for a fresh random
+ *                 one.
+ * @param request  The handshake request. `PeerImp` reads its `X-Protocol-Ctl`
+ *                 header in the constructor to negotiate features.
+ * @return The peer, already registered with the overlay. Throws if the peer
+ *         finder refused a slot.
+ */
+template 
+std::shared_ptr
+makeCapturePeer(
+    jtx::Env& env,
+    std::optional key = std::nullopt,
+    http_request_type request = {})
+{
+    auto& overlay = dynamic_cast(env.app().getOverlay());
+    auto streamPtr = std::make_unique(
+        CapturePeer::SocketType(env.app().getIOContext()), detail::capturePeerSslContext());
+
+    beast::ip::Endpoint const local(
+        boost::asio::ip::make_address("172.1.1.1"), detail::kCapturePeerPort);
+    auto const remote = detail::nextCapturePeerRemote();
+
+    auto consumer = overlay.resourceManager().newInboundEndpoint(remote);
+    auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote);
+
+    // Unseated when the endpoint is already connected or at the per-address
+    // limit. `PeerImp` dereferences the slot, so fail here, not there.
+    if (!slot)
+    {
+        Throw("makeCapturePeer: no slot for " + to_string(remote));
+    }
+
+    if (!key)
+        key = PublicKey(std::get<0>(randomKeyPair(KeyType::Ed25519)));
+
+    auto peer = std::make_shared(
+        env.app(),
+        detail::nextCapturePeerId(),
+        slot,
+        std::move(request),
+        *key,
+        // An unsupported version fails every `supportsFeature` test, so a
+        // version-gated reply would only ever take its legacy branch.
+        newestSupportedProtocolVersion(),
+        consumer,
+        std::move(streamPtr),
+        overlay);
+
+    overlay.addActive(peer);
+    return peer;
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/ProtocolMessage_test.cpp b/src/test/overlay/ProtocolMessage_test.cpp
new file mode 100644
index 0000000000..08e039f606
--- /dev/null
+++ b/src/test/overlay/ProtocolMessage_test.cpp
@@ -0,0 +1,296 @@
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class ProtocolMessage_test : public beast::unit_test::Suite
+{
+    struct TestHandler
+    {
+        bool compression = false;
+        int beginCount = 0;
+        int messageCount = 0;
+        int endCount = 0;
+        int unknownCount = 0;
+        std::uint16_t lastType = 0;
+
+        [[nodiscard]] bool
+        compressionEnabled() const
+        {
+            return compression;
+        }
+
+        void
+        onMessageUnknown(std::uint16_t type)
+        {
+            ++unknownCount;
+            lastType = type;
+        }
+
+        void
+        onMessageBegin(
+            std::uint16_t type,
+            std::shared_ptr<::google::protobuf::Message> const&,
+            std::size_t,
+            std::size_t,
+            bool)
+        {
+            ++beginCount;
+            lastType = type;
+        }
+
+        template 
+        void
+        onMessage(std::shared_ptr const&)
+        {
+            ++messageCount;
+        }
+
+        void
+        onMessageEnd(std::uint16_t, std::shared_ptr<::google::protobuf::Message> const&)
+        {
+            ++endCount;
+        }
+
+        [[nodiscard]] static std::size_t
+        maxManifestsMessageSize()
+        {
+            return std::numeric_limits::max();
+        }
+    };
+
+    // Wire bytes: `type` (2 bytes) + unknown field tag (2 bytes) + length varint (2 bytes, as these
+    // tests all use unknownFieldSize >= 128).
+    static constexpr std::size_t kPingProtoOverheadWithUnknownLen = 6;
+    static constexpr std::size_t kMinimumPingSizeWithEmptyUnknownField =
+        compression::kHeaderBytes + kPingProtoOverheadWithUnknownLen;
+    static constexpr std::size_t kMinimumPingSizeCompressedWithEmptyUnknownField =
+        compression::kHeaderBytesCompressed + kPingProtoOverheadWithUnknownLen;
+
+    static std::vector
+    makePingBuffer(std::size_t unknownFieldSize, bool compressed = false)
+    {
+        auto ping = protocol::TMPing{};
+        ping.set_type(protocol::TMPing::ptPING);
+        if (unknownFieldSize > 0)
+        {
+            ping.mutable_unknown_fields()->AddLengthDelimited(
+                42, std::string(unknownFieldSize, 'A'));
+        }
+
+        if (!compressed)
+        {
+            auto m = Message{ping, protocol::mtPING};
+            return m.getBuffer(compression::Compressed::Off);
+        }
+
+        // Message::compress() refuses to compress pings (mtPING is not in its
+        // allow-list), so getBuffer(Compressed::On) would just return the
+        // uncompressed bytes. Roll it by hand here to get a compressed
+        // ping message on the wire.
+        auto payload = std::string{};
+        ping.SerializeToString(&payload);
+
+        auto deflated = std::vector{};
+        auto const deflatedSize = compression::compress(
+            payload.data(),
+            payload.size(),
+            [&](std::size_t sz) {
+                deflated.resize(sz);
+                return deflated.data();
+            },
+            compression::Algorithm::LZ4);
+        deflated.resize(deflatedSize);
+
+        auto const type = static_cast(protocol::mtPING);
+        auto buffer = std::vector{};
+        auto pack = [&buffer](std::uint32_t value) {
+            buffer.push_back(static_cast((value >> 24) & 0x0F));
+            buffer.push_back(static_cast((value >> 16) & 0xFF));
+            buffer.push_back(static_cast((value >> 8) & 0xFF));
+            buffer.push_back(static_cast(value & 0xFF));
+        };
+
+        pack(static_cast(deflated.size()));  // compressed payload size
+        buffer.push_back(static_cast((type >> 8) & 0xFF));
+        buffer.push_back(static_cast(type & 0xFF));
+        pack(static_cast(payload.size()));  // uncompressed size
+        buffer[0] |= static_cast(compression::Algorithm::LZ4);
+
+        buffer.insert(buffer.end(), deflated.begin(), deflated.end());
+        return buffer;
+    }
+
+    static std::optional
+    declaredPingSize(std::vector const& buffer)
+    {
+        auto ec = boost::system::error_code{};
+        auto const seq = std::array{boost::asio::buffer(buffer)};
+        if (auto const header = xrpl::detail::parseMessageHeader(ec, seq, buffer.size()))
+        {
+            return header->uncompressedSize + header->headerSize;
+        }
+        return std::nullopt;
+    }
+
+    static std::pair
+    invoke(std::vector const& buffer, TestHandler& handler)
+    {
+        auto const seq = std::array{boost::asio::buffer(buffer)};
+        auto hint = 0uz;
+        return invokeProtocolMessage(seq, handler, hint);
+    }
+
+    void
+    testOversizedPingRejected()
+    {
+        testcase("oversized ping rejected before dispatch");
+
+        auto runLocalTest = [&](std::size_t size, bool compressed = false) {
+            auto const buffer = makePingBuffer(size, compressed);
+            auto const declared = declaredPingSize(buffer);
+            if (BEAST_EXPECT(declared.has_value()))
+                BEAST_EXPECT(*declared > kMaximumPingMessageSize);
+            BEAST_EXPECT(buffer.size() < kMaximumMessageSize);
+
+            auto handler = TestHandler{};
+            handler.compression = compressed;
+            auto const [bytes, ec] = invoke(buffer, handler);
+
+            BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size));
+            BEAST_EXPECT(bytes == 0);
+            BEAST_EXPECT(handler.beginCount == 0);
+            BEAST_EXPECT(handler.messageCount == 0);
+            BEAST_EXPECT(handler.endCount == 0);
+        };
+        // Just over the cap, and comfortably over it.
+        runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest(
+            kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+        runLocalTest(
+            (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+    }
+
+    void
+    testOversizedPingRejectedFromHeaderAlone()
+    {
+        testcase("oversized ping rejected from header alone");
+
+        auto runLocalTest = [&](std::size_t size, bool compressed = false) {
+            auto const full = makePingBuffer(size, compressed);
+            auto const headerSize =
+                compressed ? compression::kHeaderBytesCompressed : compression::kHeaderBytes;
+
+            // Only the header has arrived; the declared payload is still in flight.
+            auto const headerOnly =
+                std::vector{full.begin(), full.begin() + headerSize};
+            BEAST_EXPECT(headerOnly.size() < full.size());
+
+            auto handler = TestHandler{};
+            handler.compression = compressed;
+            auto const [bytes, ec] = invoke(headerOnly, handler);
+
+            BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size));
+            BEAST_EXPECT(bytes == 0);
+            BEAST_EXPECT(handler.beginCount == 0);
+            BEAST_EXPECT(handler.messageCount == 0);
+            BEAST_EXPECT(handler.endCount == 0);
+        };
+        runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest(
+            kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+        runLocalTest(
+            (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+    }
+
+    void
+    testNormalPingDispatched()
+    {
+        testcase("normal ping dispatched");
+
+        auto runLocalTest = [&](std::size_t size, bool compressed = false) {
+            auto const buffer = makePingBuffer(size, compressed);
+            auto const declared = declaredPingSize(buffer);
+            if (BEAST_EXPECT(declared.has_value()))
+                BEAST_EXPECT(*declared <= kMaximumPingMessageSize);
+
+            auto handler = TestHandler{};
+            handler.compression = compressed;
+            auto const [bytes, ec] = invoke(buffer, handler);
+
+            BEAST_EXPECT(!ec);
+            BEAST_EXPECT(bytes == buffer.size());
+            BEAST_EXPECT(handler.beginCount == 1);
+            BEAST_EXPECT(handler.messageCount == 1);
+            BEAST_EXPECT(handler.endCount == 1);
+        };
+        runLocalTest(0);
+        runLocalTest(0, true);
+    }
+
+    void
+    testPingWithSmallUnknownFieldDispatched()
+    {
+        testcase("ping with small unknown field still dispatched");
+
+        auto runLocalTest = [&](std::size_t size, bool compressed = false) {
+            auto const buffer = makePingBuffer(size, compressed);
+            auto const declared = declaredPingSize(buffer);
+            if (BEAST_EXPECT(declared.has_value()))
+                BEAST_EXPECT(*declared <= kMaximumPingMessageSize);
+
+            auto handler = TestHandler{};
+            handler.compression = compressed;
+            auto const [bytes, ec] = invoke(buffer, handler);
+
+            BEAST_EXPECT(!ec);
+            BEAST_EXPECT(bytes == buffer.size());
+            BEAST_EXPECT(handler.beginCount == 1);
+            BEAST_EXPECT(handler.messageCount == 1);
+            BEAST_EXPECT(handler.endCount == 1);
+        };
+        // Well under the cap, one byte under it, and exactly at it.
+        runLocalTest((kMaximumPingMessageSize / 2) - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest(kMaximumPingMessageSize - 1 - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest(kMaximumPingMessageSize - kMinimumPingSizeWithEmptyUnknownField);
+        runLocalTest(
+            (kMaximumPingMessageSize / 2) - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+        runLocalTest(
+            kMaximumPingMessageSize - 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+        runLocalTest(
+            kMaximumPingMessageSize - kMinimumPingSizeCompressedWithEmptyUnknownField, true);
+    }
+
+    void
+    run() override
+    {
+        testOversizedPingRejected();
+        testOversizedPingRejectedFromHeaderAlone();
+        testNormalPingDispatched();
+        testPingWithSmallUnknownFieldDispatched();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(ProtocolMessage, overlay, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/ProtocolVersion_test.cpp b/src/test/overlay/ProtocolVersion_test.cpp
index 2fc8e4447d..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.2, XRPL/2.3, XRPL/999.999") ==
-                makeProtocol(2, 2));
-            BEAST_EXPECT(negotiateProtocolVersion("XRPL/999.999, WebSocket/1.0") == std::nullopt);
-            BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt);
+                negotiateProtocolVersion(
+                    "RTXP/1.2, XRPL/2.1, XRPL/2.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") ==
+                makeProtocol(2, 3));
         }
     }
 };
diff --git a/src/test/overlay/TMGetLedger_test.cpp b/src/test/overlay/TMGetLedger_test.cpp
new file mode 100644
index 0000000000..eac1a24e24
--- /dev/null
+++ b/src/test/overlay/TMGetLedger_test.cpp
@@ -0,0 +1,135 @@
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+using namespace jtx;
+
+class TMGetLedger_test : public beast::unit_test::Suite
+{
+    /**
+     * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is
+     * visible through `lastSent()`.
+     */
+    class GetLedgerPeer : public CapturePeer
+    {
+    public:
+        using CapturePeer::CapturePeer;
+
+        void
+        runProcessLedgerRequest(
+            std::shared_ptr const& m,
+            std::vector nodeIDs)
+        {
+            processLedgerRequest(m, std::move(nodeIDs));
+        }
+    };
+
+    // Build a well-formed TMGetLedger node request carrying `numNodeIds` node
+    // IDs.
+    static std::shared_ptr
+    createRequest(std::size_t const numNodeIds)
+    {
+        auto request = std::make_shared();
+        request->set_itype(protocol::liTX_NODE);
+
+        // A uint256-sized ledger hash, as a well-formed request carries.
+        uint256 const ledgerHash{1};
+        request->set_ledgerhash(ledgerHash.data(), ledgerHash.size());
+
+        // Valid, deserializable SHAMap node IDs.
+        auto const rootNodeId = SHAMapNodeID{}.getRawString();
+        for (std::size_t i = 0; i < numNodeIds; ++i)
+        {
+            request->add_nodeids(rootNodeId);
+        }
+
+        return request;
+    }
+
+    void
+    testNodeIdCountAccepted(std::size_t const numNodeIds, bool const expectRejected)
+    {
+        testcase("Node ID Count Accepted");
+
+        Env env{*this};
+
+        auto peer = makeCapturePeer(env);
+        peer->onMessage(createRequest(numNodeIds));
+
+        // A request outside the accepted node-ID count is charged kFeeInvalidData; one inside
+        // it is not. The JobQueue handler may run concurrently and update the fee in the
+        // accepted case.
+        BEAST_EXPECT(
+            expectRejected ? (peer->feeCharge() == resource::kFeeInvalidData)
+                           : !(peer->feeCharge() == resource::kFeeInvalidData));
+    }
+
+    void
+    testProcessLedgerRequestNodeCount(std::size_t const numNodeIds)
+    {
+        testcase("Process Ledger Request Node Count");
+
+        Env env{*this};
+        env.close();
+
+        auto peer = makeCapturePeer(env);
+
+        // Ask for the account-state root node of the closed ledger.
+        auto request = createRequest(numNodeIds);
+        request->clear_ledgerhash();
+        request->set_itype(protocol::liAS_NODE);
+        request->set_ltype(protocol::ltCLOSED);
+
+        peer->runProcessLedgerRequest(request, std::vector(numNodeIds));
+
+        auto sentMessage = peer->lastSent();
+        BEAST_EXPECT(sentMessage != nullptr);
+        if (!sentMessage)
+        {
+            return;
+        }
+
+        auto const& buffer = sentMessage->getBuffer(compression::Compressed::Off);
+        BEAST_EXPECT(buffer.size() > 6);
+
+        // Skip the message header (6 bytes: 4 for size, 2 for type).
+        protocol::TMLedgerData reply;
+        BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true);
+
+        BEAST_EXPECT(reply.type() == protocol::liAS_NODE);
+        BEAST_EXPECT(reply.nodes_size() > 0);
+        BEAST_EXPECT(reply.nodes_size() <= static_cast(tuning::kHardMaxReplyNodes));
+    }
+
+    void
+    run() override
+    {
+        auto const limit = static_cast(tuning::kHardMaxReplyNodes);
+        testNodeIdCountAccepted(limit + 1, true);
+        testNodeIdCountAccepted(limit, false);
+        testNodeIdCountAccepted(limit - 1, false);
+        testProcessLedgerRequestNodeCount(limit + 1);
+        testProcessLedgerRequestNodeCount(limit);
+        testProcessLedgerRequestNodeCount(limit - 1);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(TMGetLedger, overlay, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp
index f52220a90c..1aafd77f2f 100644
--- a/src/test/overlay/TMGetObjectByHash_test.cpp
+++ b/src/test/overlay/TMGetObjectByHash_test.cpp
@@ -1,33 +1,16 @@
 #include 
+#include 
 
 #include 
 #include 
-#include 
-#include 
 #include 
-#include 
-#include 
 #include 
 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
-#include 
-#include 
-#include 
-#include 
 #include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
 
 #include 
 
@@ -41,119 +24,30 @@ namespace xrpl::test {
 using namespace jtx;
 
 /**
- * Test for TMGetObjectByHash reply size limiting.
+ * Coverage for the TMGetObjectByHash object-count bound.
  *
- * This verifies the fix that limits TMGetObjectByHash replies to
- * Tuning::hardMaxReplyNodes to prevent excessive memory usage and
- * potential DoS attacks from peers requesting large numbers of objects.
+ * A generic query names some number of objects; the number of entries the
+ * reply carries is bounded by tuning::kHardMaxReplyNodes. These cases pin that
+ * bound at and either side of its boundary.
  */
 class TMGetObjectByHash_test : public beast::unit_test::Suite
 {
-    using middle_type = boost::beast::tcp_stream;
-    using stream_type = boost::beast::ssl_stream;
-    using socket_type = boost::asio::ip::tcp::socket;
-    using shared_context = std::shared_ptr;
     /**
-     * Test peer that captures sent messages for verification.
+     * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is
+     * visible through `sent()`.
      */
-    class PeerTest : public PeerImp
+    class GetObjectPeer : public CapturePeer
     {
     public:
-        PeerTest(
-            Application& app,
-            std::shared_ptr const& slot,
-            http_request_type&& request,
-            PublicKey const& publicKey,
-            ProtocolVersion protocol,
-            Resource::Consumer consumer,
-            std::unique_ptr&& streamPtr,
-            OverlayImpl& overlay)
-            : PeerImp(
-                  app,
-                  id++,
-                  slot,
-                  std::move(request),
-                  publicKey,
-                  protocol,
-                  consumer,
-                  std::move(streamPtr),
-                  overlay)
-        {
-        }
+        using CapturePeer::CapturePeer;
 
-        ~PeerTest() override = default;
-
-        void
-        run() override
-        {
-        }
-
-        void
-        send(std::shared_ptr const& m) override
-        {
-            lastSentMessage_ = m;
-        }
-
-        std::shared_ptr
-        getLastSentMessage() const
-        {
-            return lastSentMessage_;
-        }
-
-        // Synchronous test access to the JobQueue-dispatched processor.
-        // The production path runs this on JtLedgerReq; tests need a
-        // synchronous entry point to inspect the reply via send().
-        // PeerImp::processGetObjectByHash is `protected` so the derived
-        // test subclass can call it directly.
         void
         runProcessGetObjectByHash(std::shared_ptr const& m)
         {
             processGetObjectByHash(m);
         }
-
-        static void
-        resetId()
-        {
-            id = 0;
-        }
-
-    private:
-        inline static Peer::id_t id = 0;
-        std::shared_ptr lastSentMessage_;
     };
 
-    shared_context context_{makeSslContext("")};
-    ProtocolVersion protocolVersion_{1, 7};
-
-    std::shared_ptr
-    createPeer(jtx::Env& env)
-    {
-        auto& overlay = dynamic_cast(env.app().getOverlay());
-        boost::beast::http::request request;
-        auto streamPtr =
-            std::make_unique(socket_type(env.app().getIOContext()), *context_);
-
-        beast::IP::Endpoint const local(boost::asio::ip::make_address("172.1.1.1"), 51235);
-        beast::IP::Endpoint const remote(boost::asio::ip::make_address("172.1.1.2"), 51235);
-
-        PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519)));
-        auto consumer = overlay.resourceManager().newInboundEndpoint(remote);
-        auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote);
-
-        auto peer = std::make_shared(
-            env.app(),
-            slot,
-            std::move(request),
-            key,
-            protocolVersion_,
-            consumer,
-            std::move(streamPtr),
-            overlay);
-
-        overlay.addActive(peer);
-        return peer;
-    }
-
     static std::shared_ptr
     createRequest(size_t const numObjects, Env& env)
     {
@@ -173,7 +67,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
                 NodeObjectType::Ledger, std::move(data), hash, nodeStore.earliestLedgerSeq());
         }
 
-        // Create a request with more objects than hardMaxReplyNodes
+        // Name every stored object in a single generic query.
         auto request = std::make_shared();
         request->set_type(protocol::TMGetObjectByHash_ObjectType_otLEDGER);
         request->set_query(true);
@@ -188,28 +82,25 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
     }
 
     /**
-     * Test that reply is limited to hardMaxReplyNodes when more objects
-     * are requested than the limit allows.
+     * Check the object count a generic-query reply carries.
      *
      * `onMessage(TMGetObjectByHash)` dispatches the generic-query path
      * to the JobQueue, so tests invoke the synchronous processor
      * directly via `runProcessGetObjectByHash`.
      */
     void
-    testReplyLimit(size_t const numObjects, int const expectedReplySize)
+    testReplyObjectCount(size_t const numObjects, int const expectedReplySize)
     {
-        testcase("Reply Limit");
+        testcase("Reply Object Count");
 
         Env env(*this);
-        PeerTest::resetId();
-
-        auto peer = createPeer(env);
+        auto peer = makeCapturePeer(env);
 
         auto request = createRequest(numObjects, env);
         peer->runProcessGetObjectByHash(request);
 
         // Verify that a reply was sent
-        auto sentMessage = peer->getLastSentMessage();
+        auto sentMessage = peer->lastSent();
         BEAST_EXPECT(sentMessage != nullptr);
 
         // Parse the reply message
@@ -220,17 +111,17 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
         protocol::TMGetObjectByHash reply;
         BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true);
 
-        // Verify the reply is limited to expectedReplySize
+        // The reply carries the expected number of objects.
         BEAST_EXPECT(reply.objects_size() == expectedReplySize);
     }
 
     void
     run() override
     {
-        int const limit = static_cast(Tuning::kHardMaxReplyNodes);
-        testReplyLimit(limit + 1, limit);
-        testReplyLimit(limit, limit);
-        testReplyLimit(limit - 1, limit - 1);
+        int const limit = static_cast(tuning::kHardMaxReplyNodes);
+        testReplyObjectCount(limit + 1, limit);
+        testReplyObjectCount(limit, limit);
+        testReplyObjectCount(limit - 1, limit - 1);
     }
 };
 
diff --git a/src/test/overlay/TMTransaction_test.cpp b/src/test/overlay/TMTransaction_test.cpp
new file mode 100644
index 0000000000..5a23e25005
--- /dev/null
+++ b/src/test/overlay/TMTransaction_test.cpp
@@ -0,0 +1,45 @@
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+using namespace jtx;
+
+class TMTransaction_test : public beast::unit_test::Suite
+{
+    void
+    testFailureDeserializingTransactionIsCharged()
+    {
+        testcase("Undeserializable Transaction Is Charged");
+
+        Env env{*this, envconfig()};
+
+        auto peer = makeCapturePeer(env);
+        auto tx = std::make_shared();
+        tx->set_status(protocol::tsNEW);
+
+        // Bytes that are not a serialized transaction, so deserialization fails.
+        tx->set_rawtransaction("\x01\x02\x03", 3);
+
+        peer->onMessage(tx);
+        BEAST_EXPECT(peer->feeCharge() == resource::kFeeInvalidData);
+    }
+
+    void
+    run() override
+    {
+        testFailureDeserializingTransactionIsCharged();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(TMTransaction, overlay, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/TMTransactions_test.cpp b/src/test/overlay/TMTransactions_test.cpp
new file mode 100644
index 0000000000..87c09498f2
--- /dev/null
+++ b/src/test/overlay/TMTransactions_test.cpp
@@ -0,0 +1,82 @@
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+using namespace jtx;
+
+class TMTransactions_test : public beast::unit_test::Suite
+{
+    static std::shared_ptr
+    createRequest(std::size_t const numTransactions)
+    {
+        auto request = std::make_shared();
+        for (std::size_t i = 0; i < numTransactions; ++i)
+        {
+            request->mutable_transactions()->Add(protocol::TMTransaction{});
+        }
+        return request;
+    }
+
+    void
+    testTransactionCountAccepted(std::size_t const numTransactions, bool const expectRejected)
+    {
+        testcase("Transaction Count Accepted");
+
+        static constexpr auto kLimitExceededMessage = "TMTransactions: transaction list too large";
+        auto foundExpectedLog = false;
+        Env env{
+            *this,
+            envconfig(),
+            std::make_unique(kLimitExceededMessage, &foundExpectedLog)};
+
+        // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from
+        // the config and the handshake header, so set this first.
+        env.app().config().txReduceRelayEnable = true;
+        http_request_type request;
+        request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false));
+
+        auto peer = makeCapturePeer(env, std::nullopt, std::move(request));
+        peer->onMessage(createRequest(numTransactions));
+
+        auto fee = peer->feeCharge();
+        if (expectRejected)
+        {
+            BEAST_EXPECT(fee == resource::kFeeMalformedRequest);
+            BEAST_EXPECT(foundExpectedLog);
+        }
+        else
+        {
+            BEAST_EXPECT(!foundExpectedLog);
+        }
+    }
+
+    void
+    run() override
+    {
+        auto const limit = reduce_relay::kMaxTxQueueSize;
+        testTransactionCountAccepted(limit + 1, true);
+        testTransactionCountAccepted(limit, false);
+        testTransactionCountAccepted(limit - 1, false);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(TMTransactions, overlay, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/cluster_test.cpp b/src/test/overlay/cluster_test.cpp
index 0a51f98594..06df4fb73a 100644
--- a/src/test/overlay/cluster_test.cpp
+++ b/src/test/overlay/cluster_test.cpp
@@ -13,6 +13,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -96,6 +97,29 @@ public:
             }
         }
 
+        {
+            testcase("Membership: isMember agrees with member");
+
+            // Number of network nodes that also belong to the cluster.
+            std::size_t const overlapCount = 16;
+
+            // Total size of the cluster once padded with non-network nodes.
+            std::size_t const clusterSize = 32;
+
+            std::vector cluster(network.begin(), network.begin() + overlapCount);
+
+            while (cluster.size() != clusterSize)
+                cluster.push_back(randomNode());
+
+            auto c = create(cluster);
+
+            for (auto const& n : cluster)
+                BEAST_EXPECT(c->isMember(n));
+
+            for (auto const& n : network)
+                BEAST_EXPECT(c->isMember(n) == static_cast(c->member(n)));
+        }
+
         {
             testcase("Membership: Non-empty cluster and all present");
 
diff --git a/src/test/overlay/compression_test.cpp b/src/test/overlay/compression_test.cpp
index 60cc69a14f..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,
@@ -413,7 +383,7 @@ public:
             return env;
         };
         auto handshake = [&](int outboundEnable, int inboundEnable) {
-            beast::IP::Address const addr = boost::asio::ip::make_address("172.1.1.100");
+            beast::ip::Address const addr = boost::asio::ip::make_address("172.1.1.100");
 
             auto env = getEnv(outboundEnable);
             auto request = xrpl::makeRequest(
diff --git a/src/test/overlay/overlay_limit_test.cpp b/src/test/overlay/overlay_limit_test.cpp
new file mode 100644
index 0000000000..11bbdc2377
--- /dev/null
+++ b/src/test/overlay/overlay_limit_test.cpp
@@ -0,0 +1,83 @@
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+using namespace jtx;
+
+/**
+ * Tests for `Overlay::limit()`, the configured peer allowance reported once
+ * `OverlayImpl::start()` applies the computed `peer_finder::Config`.
+ *
+ * `ApplicationImp::fdRequired()` runs before `OverlayImpl::start()` does, so it
+ * always sees the peer finder manager's default-constructed configuration and
+ * never this value; `Overlay::limit()` instead surfaces through the PeerFinder
+ * property stream and other post-startup callers.
+ *
+ * `jtx::Env` runs standalone, and `ServerHandler` strips the `peer` protocol
+ * from every configured port under `config.standalone()`, so the peer port
+ * declared here is never bound and incoming connections are disabled
+ * throughout; every limit in this suite is an outbound-only allowance. The
+ * inbound cases live alongside `peer_finder::Config::makeConfig`, which takes
+ * the port as a parameter.
+ */
+class OverlayLimit_test : public beast::unit_test::Suite
+{
+    void
+    testLegacyPeersMax()
+    {
+        testcase("Legacy peers_max is reported");
+
+        auto config = jtx::envconfig();
+        config->peersMax = 40;
+
+        Env env(*this, std::move(config));
+        BEAST_EXPECT(env.app().getOverlay().limit() == 40);
+    }
+
+    void
+    testPerDirectionPeerLimits()
+    {
+        testcase("Per-direction peer limits are reported");
+
+        // With incoming connections disabled the 50 inbound slots are dropped
+        // and only the outbound allowance remains, so neither zero (the value
+        // `maxPeers` used to hold in this branch of makeConfig) nor 70 (the
+        // unconditional sum of both directions) is correct.
+        auto config = jtx::envconfig();
+        config->peersInMax = 50;
+        config->peersOutMax = 20;
+
+        Env env(*this, std::move(config));
+        BEAST_EXPECT(env.app().getOverlay().limit() == 20);
+    }
+
+    void
+    testDefaultConfig()
+    {
+        testcase("A default configuration reports the default limit");
+
+        Env env(*this);
+        BEAST_EXPECT(env.app().getOverlay().limit() == peer_finder::tuning::kDefaultMaxPeers);
+    }
+
+    void
+    run() override
+    {
+        testLegacyPeersMax();
+        testPerDirectionPeerLimits();
+        testDefaultConfig();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(OverlayLimit, overlay, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp
index 2f42313037..ff3eb51a4d 100644
--- a/src/test/overlay/reduce_relay_test.cpp
+++ b/src/test/overlay/reduce_relay_test.cpp
@@ -1,4 +1,5 @@
 #include 
+#include 
 #include 
 
 #include 
@@ -12,10 +13,8 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -27,7 +26,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -67,16 +65,16 @@ static constexpr std::uint32_t kMaxMessages = 200000;
 /**
  * Simulate two entities - peer directly connected to the server
  * (via squelch in PeerSim) and PeerImp (via Overlay)
+ *
+ * `PeerStub` supplies the rest of the `Peer` interface as no-ops.
  */
-class PeerPartial : public Peer
+class PeerPartial : public PeerStub
 {
 public:
-    PeerPartial() : nodePublicKey(derivePublicKey(KeyType::Ed25519, randomSecretKey()))
-    {
-    }
+    using PeerStub::PeerStub;
+    // Keep the base overload visible; the one below would otherwise hide it.
+    using PeerStub::send;
 
-    PublicKey nodePublicKey;
-    ~PeerPartial() override = default;
     virtual void
     onMessage(MessageSPtr const& m, SquelchCB f) = 0;
     virtual void
@@ -86,111 +84,6 @@ public:
     {
         onMessage(squelch);
     }
-
-    // dummy implementation
-    void
-    send(std::shared_ptr const& m) override
-    {
-    }
-    [[nodiscard]] beast::IP::Endpoint
-    getRemoteAddress() const override
-    {
-        return {};
-    }
-    void
-    charge(Resource::Charge const& fee, std::string const& context = {}) override
-    {
-    }
-    [[nodiscard]] bool
-    cluster() const override
-    {
-        return false;
-    }
-    [[nodiscard]] bool
-    isHighLatency() const override
-    {
-        return false;
-    }
-    [[nodiscard]] int
-    getScore(bool) const override
-    {
-        return 0;
-    }
-    [[nodiscard]] PublicKey const&
-    getNodePublic() const override
-    {
-        return nodePublicKey;
-    }
-    json::Value
-    json() override
-    {
-        return {};
-    }
-    [[nodiscard]] bool
-    supportsFeature(ProtocolFeature f) const override
-    {
-        return false;
-    }
-    [[nodiscard]] std::optional
-    publisherListSequence(PublicKey const&) const override
-    {
-        return {};
-    }
-    void
-    setPublisherListSequence(PublicKey const&, std::size_t const) override
-    {
-    }
-    [[nodiscard]] uint256 const&
-    getClosedLedgerHash() const override
-    {
-        static uint256 const kHash{};
-        return kHash;
-    }
-    [[nodiscard]] bool
-    hasLedger(uint256 const& hash, std::uint32_t seq) const override
-    {
-        return false;
-    }
-    void
-    ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override
-    {
-    }
-    [[nodiscard]] bool
-    hasTxSet(uint256 const& hash) const override
-    {
-        return false;
-    }
-    void
-    cycleStatus() override
-    {
-    }
-    bool
-    hasRange(std::uint32_t uMin, std::uint32_t uMax) override
-    {
-        return false;
-    }
-    [[nodiscard]] bool
-    compressionEnabled() const override
-    {
-        return false;
-    }
-    [[nodiscard]] bool
-    txReduceRelayEnabled() const override
-    {
-        return false;
-    }
-    void
-    sendTxQueue() override
-    {
-    }
-    void
-    addTxQueue(uint256 const&) override
-    {
-    }
-    void
-    removeTxQueue(uint256 const&) override
-    {
-    }
 };
 
 /**
@@ -466,24 +359,13 @@ class PeerSim : public PeerPartial, public std::enable_shared_from_this
 {
 public:
     using id_t = Peer::id_t;
-    PeerSim(Overlay& overlay, beast::Journal journal) : overlay_(overlay), squelch_(journal)
+    PeerSim(Overlay& overlay, beast::Journal journal)
+        : PeerPartial(sid++), overlay_(overlay), squelch_(journal)
     {
     }
 
     ~PeerSim() override = default;
 
-    id_t
-    id() const override
-    {
-        return id_;
-    }
-
-    std::string const&
-    fingerprint() const override
-    {
-        return fingerprint_;
-    }
-
     static void
     resetId()
     {
@@ -525,8 +407,6 @@ public:
 
 private:
     inline static id_t sid = 0;
-    std::string fingerprint_;
-    id_t id_{sid++};
     Overlay& overlay_;
     reduce_relay::Squelch squelch_;
 };
@@ -1610,7 +1490,7 @@ vp_base_squelch_max_selected_peers=2
                 env_.app().config().compression = c.compression;
             };
             auto handshake = [&](int outboundEnable, int inboundEnable) {
-                beast::IP::Address const addr = boost::asio::ip::make_address("172.1.1.100");
+                beast::ip::Address const addr = boost::asio::ip::make_address("172.1.1.100");
 
                 setEnv(outboundEnable);
                 auto request = xrpl::makeRequest(
diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp
index 43f6ef2506..e97fba88e5 100644
--- a/src/test/overlay/tx_reduce_relay_test.cpp
+++ b/src/test/overlay/tx_reduce_relay_test.cpp
@@ -1,38 +1,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 
-#include 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -47,13 +33,6 @@ namespace xrpl::test {
 
 class tx_reduce_relay_test : public beast::unit_test::Suite
 {
-public:
-    using socket_type = boost::asio::ip::tcp::socket;
-    using middle_type = boost::beast::tcp_stream;
-    using stream_type = boost::beast::ssl_stream;
-    using shared_context = std::shared_ptr;
-
-private:
     void
     doTest(std::string const& msg, bool log, std::function f)
     {
@@ -116,107 +95,83 @@ private:
         });
     }
 
-    class PeerTest : public PeerImp
+    /**
+     * Counts queued transaction hashes. Relayed messages are counted through
+     * the inherited `sent()`.
+     */
+    class TxReducePeer : public CapturePeer
     {
     public:
-        PeerTest(
-            Application& app,
-            std::shared_ptr const& slot,
-            http_request_type&& request,
-            PublicKey const& publicKey,
-            ProtocolVersion protocol,
-            Resource::Consumer consumer,
-            std::unique_ptr&& streamPtr,
-            OverlayImpl& overlay)
-            : PeerImp(
-                  app,
-                  sid,
-                  slot,
-                  std::move(request),
-                  publicKey,
-                  protocol,
-                  consumer,
-                  std::move(streamPtr),
-                  overlay)
-        {
-            sid++;
-        }
-        ~PeerTest() override = default;
+        using CapturePeer::CapturePeer;
 
         void
-        run() override
+        addTxQueue(uint256 const&) override
         {
+            ++queued_;
         }
-        void
-        send(std::shared_ptr const&) override
+
+        /**
+         * @return The number of transaction hashes queued for this peer.
+         */
+        std::size_t
+        queued() const
         {
-            sendTx++;
+            return queued_;
         }
-        void
-        addTxQueue(uint256 const& hash) override
-        {
-            queueTx++;
-        }
-        static void
-        init()
-        {
-            queueTx = 0;
-            sendTx = 0;
-            sid = 0;
-        }
-        inline static std::size_t sid = 0;
-        inline static std::uint16_t queueTx = 0;
-        inline static std::uint16_t sendTx = 0;
+
+    private:
+        std::size_t queued_{0};
     };
 
-    std::uint16_t lid_{0};
-    std::uint16_t rid_{1};
-    shared_context context_;
-    ProtocolVersion protocolVersion_;
-    boost::beast::multi_buffer readBuf_;
-
-public:
-    tx_reduce_relay_test() : context_(makeSslContext("")), protocolVersion_{1, 7}
-    {
-    }
-
-private:
+    /**
+     * Build one peer and register it with the overlay.
+     *
+     * The first `nDisabled` peers get no `X-Protocol-Ctl` header, which leaves
+     * tx reduce-relay disabled on them. Built first, they sit at the front of
+     * `peers`, where `testRelay`'s skip set expects them.
+     *
+     * @param env        The environment owning the overlay.
+     * @param peers      Receives the peer; the overlay holds only a weak
+     *                   pointer, so the caller keeps it alive.
+     * @param nDisabled  How many more peers to leave disabled; decremented
+     *                   per peer built.
+     */
     void
-    addPeer(jtx::Env& env, std::vector>& peers, std::uint16_t& nDisabled)
+    addPeer(
+        jtx::Env& env,
+        std::vector>& peers,
+        std::uint16_t& nDisabled)
     {
         auto& overlay = dynamic_cast(env.app().getOverlay());
-        boost::beast::http::request request;
-        (nDisabled == 0)
-            ? request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false))
-            : (void)nDisabled--;
-        auto streamPtr = std::make_unique(
-            socket_type(std::forward(env.app().getIOContext())),
-            *context_);
-        beast::IP::Endpoint const local(
-            boost::asio::ip::make_address("172.1.1." + std::to_string(lid_)));
-        beast::IP::Endpoint const remote(
-            boost::asio::ip::make_address("172.1.1." + std::to_string(rid_)));
         PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519)));
-        auto consumer = overlay.resourceManager().newInboundEndpoint(remote);
-        auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote);
-        auto const peer = std::make_shared(
-            env.app(),
-            slot,
-            std::move(request),
-            key,
-            protocolVersion_,
-            consumer,
-            std::move(streamPtr),
-            overlay);
+
+        bool const disabled = nDisabled > 0;
+        if (disabled)
+            --nDisabled;
+
+        http_request_type request;
+        if (!disabled)
+            request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false));
+
         BEAST_EXPECT(overlay.findPeerByPublicKey(key) == std::shared_ptr{});
-        overlay.addActive(peer);
+        auto const peer = makeCapturePeer(env, key, std::move(request));
         BEAST_EXPECT(overlay.findPeerByPublicKey(key) == peer);
-        peers.emplace_back(peer);  // overlay stores week ptr to PeerImp
-        lid_ += 2;
-        rid_ += 2;
-        assert(lid_ <= 254);
+        peers.emplace_back(peer);
     }
 
+    /**
+     * Relay one transaction to `nPeers` peers and check the split.
+     *
+     * @param test       The testcase name.
+     * @param txRREnabled  The `tx_enable` config value.
+     * @param nPeers     How many peers to attach to the overlay.
+     * @param nDisabled  How many of those peers have reduce-relay disabled.
+     * @param minPeers   The `tx_min_peers` config value.
+     * @param relayPercentage  The `tx_relay_percentage` config value.
+     * @param expectRelay  The expected number of peers relayed to.
+     * @param expectQueue  The expected number of peers queued for.
+     * @param nSkip      How many of the first-built peers to skip.
+     */
     void
     testRelay(
         std::string const& test,
@@ -227,20 +182,30 @@ private:
         std::uint16_t relayPercentage,
         std::uint16_t expectRelay,
         std::uint16_t expectQueue,
-        std::set const& toSkip = {})
+        std::size_t nSkip = 0)
     {
         testcase(test);
         jtx::Env env(*this);
-        std::vector> peers;
+        std::vector> peers;
+        // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from
+        // the config and the handshake header, so set these first.
         env.app().config().txReduceRelayEnable = txRREnabled;
         env.app().config().txReduceRelayMinPeers = minPeers;
         env.app().config().txRelayPercentage = relayPercentage;
-        PeerTest::init();
-        lid_ = 0;
-        rid_ = 0;
         for (int i = 0; i < nPeers; i++)
             addPeer(env, peers, nDisabled);
 
+        // An under-filled skip set would also fail the relay counts below, for
+        // a reason that looks unrelated.
+        if (!BEAST_EXPECT(nSkip <= peers.size()))
+            return;
+
+        // Skip the peers built first, so the skip set overlaps the disabled
+        // peers as the expected counts assume.
+        std::set toSkip;
+        for (std::size_t i = 0; i < nSkip; ++i)
+            toSkip.insert(peers[i]->id());
+
         auto const jtx = env.jt(noop(env.master));
         if (BEAST_EXPECT(jtx.stx))
         {
@@ -251,7 +216,15 @@ private:
             m.set_deferred(false);
             m.set_status(protocol::TransactionStatus::tsNEW);
             env.app().getOverlay().relay(uint256{0}, m, toSkip);
-            BEAST_EXPECT(PeerTest::sendTx == expectRelay && PeerTest::queueTx == expectQueue);
+
+            std::size_t sendTx = 0;
+            std::size_t queueTx = 0;
+            for (auto const& peer : peers)
+            {
+                sendTx += peer->sent().size();
+                queueTx += peer->queued();
+            }
+            BEAST_EXPECT(sendTx == expectRelay && queueTx == expectQueue);
         }
     }
 
@@ -259,12 +232,11 @@ private:
     run() override
     {
         bool const log = false;
-        std::set skip = {0, 1, 2, 3, 4};
         testConfig(log);
         // relay to all peers, no hash queue
         testRelay("feature disabled", false, 10, 0, 10, 25, 10, 0);
         // relay to nPeers - skip (10-5=5)
-        testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, skip);
+        testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, 5);
         // relay to all peers because min is greater than nPeers
         testRelay("relay all 1", true, 10, 0, 20, 25, 10, 0);
         // relay to all peers because min + disabled is greater thant nPeers
@@ -275,24 +247,22 @@ private:
         // relay to minPeers + 25% of (nPeers - nPeers) - skip
         // (20+0.25*(60-20)-5=25), queue the rest, skip counts towards relayed
         // (60-25-5=30)
-        testRelay("skip", true, 60, 0, 20, 25, 25, 30, skip);
+        testRelay("skip", true, 60, 0, 20, 25, 25, 30, 5);
         // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled)
         // (20+10+0.25*(70-20-10)=40), queue the rest (30)
         testRelay("disabled", true, 70, 10, 20, 25, 40, 30);
         // relay to minPeers + disabled-not-in-skip + 25% of (nPeers - minPeers
         // - disabled) (20+5+0.25*(70-20-10)=35), queue the rest, skip counts
         // towards relayed (70-35-5=30))
-        testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, skip);
+        testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, 5);
         // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled)
         // - skip (10+5+0.25*(15-10-5)-10=5), queue the rest, skip counts
         // towards relayed (15-5-10=0)
-        skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
-        testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, skip);
+        testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, 10);
         // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled)
         // - skip (10+2+0.25*(20-10-2)-14=0), queue the rest, skip counts
         // towards relayed (20-14=6)
-        skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13};
-        testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, skip);
+        testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, 14);
     }
 };
 
diff --git a/src/test/protocol/ApiVersion_test.cpp b/src/test/protocol/ApiVersion_test.cpp
deleted file mode 100644
index c41fa6f6c0..0000000000
--- a/src/test/protocol/ApiVersion_test.cpp
+++ /dev/null
@@ -1,41 +0,0 @@
-#include 
-#include 
-
-namespace xrpl::test {
-struct ApiVersion_test : beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        {
-            testcase("API versions invariants");
-
-            static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumSupportedVersion);
-            static_assert(RPC::kApiMinimumSupportedVersion <= RPC::kApiMaximumValidVersion);
-            static_assert(RPC::kApiMaximumSupportedVersion <= RPC::kApiMaximumValidVersion);
-            static_assert(RPC::kApiBetaVersion <= RPC::kApiMaximumValidVersion);
-
-            BEAST_EXPECT(true);
-        }
-
-        {
-            // Update when we change versions
-            testcase("API versions");
-
-            static_assert(RPC::kApiMinimumSupportedVersion >= 1);
-            static_assert(RPC::kApiMinimumSupportedVersion < 2);
-            static_assert(RPC::kApiMaximumSupportedVersion >= 2);
-            static_assert(RPC::kApiMaximumSupportedVersion < 3);
-            static_assert(RPC::kApiMaximumValidVersion >= 3);
-            static_assert(RPC::kApiMaximumValidVersion < 4);
-            static_assert(RPC::kApiBetaVersion >= 3);
-            static_assert(RPC::kApiBetaVersion < 4);
-
-            BEAST_EXPECT(true);
-        }
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(ApiVersion, protocol, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/protocol/BuildInfo_test.cpp b/src/test/protocol/BuildInfo_test.cpp
index 1741f45938..a669e3e292 100644
--- a/src/test/protocol/BuildInfo_test.cpp
+++ b/src/test/protocol/BuildInfo_test.cpp
@@ -11,7 +11,7 @@ public:
     {
         testcase("EncodeSoftwareVersion");
 
-        auto encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.3-b7");
+        auto encodedVersion = build_info::encodeSoftwareVersion("1.2.3-b7");
 
         // the first two bytes identify the particular implementation, 0x183B
         BEAST_EXPECT((encodedVersion & 0xFFFF'0000'0000'0000LLU) == 0x183B'0000'0000'0000LLU);
@@ -25,15 +25,15 @@ public:
             // 01 if a beta
             BEAST_EXPECT((encodedVersion & 0x0000'0000'00C0'0000LLU) >> 22 == 0b01);
             // 10 if an RC
-            encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.4-rc7");
+            encodedVersion = build_info::encodeSoftwareVersion("1.2.4-rc7");
             BEAST_EXPECT((encodedVersion & 0x0000'0000'00C0'0000LLU) >> 22 == 0b10);
             // 11 if neither an RC nor a beta
-            encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.5");
+            encodedVersion = build_info::encodeSoftwareVersion("1.2.5");
             BEAST_EXPECT((encodedVersion & 0x0000'0000'00C0'0000LLU) >> 22 == 0b11);
         }
 
         // the next six bits: rc/beta number (1-63)
-        encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.6-b63");
+        encodedVersion = build_info::encodeSoftwareVersion("1.2.6-b63");
         BEAST_EXPECT((encodedVersion & 0x0000'0000'003F'0000LLU) >> 16 == 63);
 
         // the last two bytes are zeros
@@ -41,14 +41,14 @@ public:
 
         // Test some version strings with wrong formats:
         // no rc/beta number
-        encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.3-b");
+        encodedVersion = build_info::encodeSoftwareVersion("1.2.3-b");
         BEAST_EXPECT((encodedVersion & 0x0000'0000'00FF'0000LLU) == 0);
         // rc/beta number out of range
-        encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.3-b64");
+        encodedVersion = build_info::encodeSoftwareVersion("1.2.3-b64");
         BEAST_EXPECT((encodedVersion & 0x0000'0000'00FF'0000LLU) == 0);
 
         // Check that the rc/beta number of a release is 0:
-        encodedVersion = BuildInfo::encodeSoftwareVersion("1.2.6");
+        encodedVersion = build_info::encodeSoftwareVersion("1.2.6");
         BEAST_EXPECT((encodedVersion & 0x0000'0000'003F'0000LLU) == 0);
     }
 
@@ -57,9 +57,9 @@ public:
     {
         testcase("IsXrpldVersion");
         auto vFF = 0xFFFF'FFFF'FFFF'FFFFLLU;
-        BEAST_EXPECT(!BuildInfo::isXrpldVersion(vFF));
+        BEAST_EXPECT(!build_info::isXrpldVersion(vFF));
         auto vXrpld = 0x183B'0000'0000'0000LLU;
-        BEAST_EXPECT(BuildInfo::isXrpldVersion(vXrpld));
+        BEAST_EXPECT(build_info::isXrpldVersion(vXrpld));
     }
 
     void
@@ -67,16 +67,16 @@ public:
     {
         testcase("IsNewerVersion");
         auto vFF = 0xFFFF'FFFF'FFFF'FFFFLLU;
-        BEAST_EXPECT(!BuildInfo::isNewerVersion(vFF));
+        BEAST_EXPECT(!build_info::isNewerVersion(vFF));
 
-        auto v159 = BuildInfo::encodeSoftwareVersion("1.5.9");
-        BEAST_EXPECT(!BuildInfo::isNewerVersion(v159));
+        auto v159 = build_info::encodeSoftwareVersion("1.5.9");
+        BEAST_EXPECT(!build_info::isNewerVersion(v159));
 
-        auto vCurrent = BuildInfo::getEncodedVersion();
-        BEAST_EXPECT(!BuildInfo::isNewerVersion(vCurrent));
+        auto vCurrent = build_info::getEncodedVersion();
+        BEAST_EXPECT(!build_info::isNewerVersion(vCurrent));
 
-        auto vMax = BuildInfo::encodeSoftwareVersion("255.255.255");
-        BEAST_EXPECT(BuildInfo::isNewerVersion(vMax));
+        auto vMax = build_info::encodeSoftwareVersion("255.255.255");
+        BEAST_EXPECT(build_info::isNewerVersion(vMax));
     }
 
     void
diff --git a/src/test/protocol/Hooks_test.cpp b/src/test/protocol/Hooks_test.cpp
deleted file mode 100644
index 082507aca7..0000000000
--- a/src/test/protocol/Hooks_test.cpp
+++ /dev/null
@@ -1,189 +0,0 @@
-
-
-#include   // IWYU pragma: keep
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl {
-
-class Hooks_test : public beast::unit_test::Suite
-{
-    /**
-     * This unit test was requested here:
-     * https://github.com/XRPLF/rippled/pull/4089#issuecomment-1050274539
-     * These are tests that exercise facilities that are reserved for when Hooks
-     * is merged in the future.
-     **/
-
-    void
-    testHookFields()
-    {
-        testcase("Test Hooks fields");
-
-        using namespace test::jtx;
-
-        std::vector> const fieldsToTest = {
-            sfHookResult,
-            sfHookStateChangeCount,
-            sfHookEmitCount,
-            sfHookExecutionIndex,
-            sfHookApiVersion,
-            sfHookStateCount,
-            sfEmitGeneration,
-            sfHookOn,
-            sfHookInstructionCount,
-            sfEmitBurden,
-            sfHookReturnCode,
-            sfReferenceCount,
-            sfEmitParentTxnID,
-            sfEmitNonce,
-            sfEmitHookHash,
-            sfHookStateKey,
-            sfHookHash,
-            sfHookNamespace,
-            sfHookSetTxnID,
-            sfHookStateData,
-            sfHookReturnString,
-            sfHookParameterName,
-            sfHookParameterValue,
-            sfEmitCallback,
-            sfHookAccount,
-            sfEmittedTxn,
-            sfHook,
-            sfHookDefinition,
-            sfHookParameter,
-            sfHookGrant,
-            sfEmitDetails,
-            sfHookExecutions,
-            sfHookExecution,
-            sfHookParameters,
-            sfHooks,
-            sfHookGrants};
-
-        for (auto const& rf : fieldsToTest)
-        {
-            SField const& f = rf.get();
-
-            STObject dummy{sfGeneric};
-
-            BEAST_EXPECT(!dummy.isFieldPresent(f));
-
-            switch (f.fieldType)
-            {
-                case STI_UINT8: {
-                    dummy.setFieldU8(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU8(f) == 0);
-
-                    dummy.setFieldU8(f, 255);
-                    BEAST_EXPECT(dummy.getFieldU8(f) == 255);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT16: {
-                    dummy.setFieldU16(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU16(f) == 0);
-
-                    dummy.setFieldU16(f, 0xFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU16(f) == 0xFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT32: {
-                    dummy.setFieldU32(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU32(f) == 0);
-
-                    dummy.setFieldU32(f, 0xFFFFFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU32(f) == 0xFFFFFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT64: {
-                    dummy.setFieldU64(f, 0);
-                    BEAST_EXPECT(dummy.getFieldU64(f) == 0);
-
-                    dummy.setFieldU64(f, 0xFFFFFFFFFFFFFFFFU);
-                    BEAST_EXPECT(dummy.getFieldU64(f) == 0xFFFFFFFFFFFFFFFFU);
-
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_UINT256: {
-                    uint256 const u = uint256::fromVoid(
-                        "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBE"
-                        "EFDEADBEEF");
-                    dummy.setFieldH256(f, u);
-                    BEAST_EXPECT(dummy.getFieldH256(f) == u);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_VL: {
-                    std::vector const v{1, 2, 3};
-                    dummy.setFieldVL(f, v);
-                    BEAST_EXPECT(dummy.getFieldVL(f) == v);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_ACCOUNT: {
-                    // NOLINTBEGIN(bugprone-unchecked-optional-access)
-                    AccountID const id =
-                        *parseBase58("rwfSjJNK2YQuN64bSWn7T2eY9FJAyAPYJT");
-                    // NOLINTEND(bugprone-unchecked-optional-access)
-                    dummy.setAccountID(f, id);
-                    BEAST_EXPECT(dummy.getAccountID(f) == id);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_OBJECT: {
-                    dummy.emplaceBack(STObject{f});
-                    BEAST_EXPECT(dummy.getField(f).getFName() == f);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                case STI_ARRAY: {
-                    STArray dummy2{f, 2};
-                    dummy2.pushBack(STObject{sfGeneric});
-                    dummy2.pushBack(STObject{sfGeneric});
-                    dummy.setFieldArray(f, dummy2);
-                    BEAST_EXPECT(dummy.getFieldArray(f) == dummy2);
-                    BEAST_EXPECT(dummy.isFieldPresent(f));
-                    break;
-                }
-
-                default:
-                    BEAST_EXPECT(false);
-            }
-        }
-    }
-
-public:
-    void
-    run() override
-    {
-        using namespace test::jtx;
-        testHookFields();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Hooks, protocol, xrpl);
-
-}  // namespace xrpl
diff --git a/src/test/protocol/InnerObjectFormats_test.cpp b/src/test/protocol/InnerObjectFormats_test.cpp
index 5154153ecf..73a283da39 100644
--- a/src/test/protocol/InnerObjectFormats_test.cpp
+++ b/src/test/protocol/InnerObjectFormats_test.cpp
@@ -5,7 +5,7 @@
 #include 
 #include   // json::Reader
 #include 
-#include     // RPC::containsError
+#include     // rpc::containsError
 #include   // STParsedJSONObject
 
 #include 
@@ -13,7 +13,7 @@
 
 namespace xrpl {
 
-namespace InnerObjectFormatsUnitTestDetail {
+namespace inner_object_formats_unit_test_detail {
 
 struct TestJSONTxt
 {
@@ -149,7 +149,7 @@ static TestJSONTxt const kTestArray[] = {
 
 };
 
-}  // namespace InnerObjectFormatsUnitTestDetail
+}  // namespace inner_object_formats_unit_test_detail
 
 class InnerObjectFormatsParsedJSON_test : public beast::unit_test::Suite
 {
@@ -157,7 +157,7 @@ public:
     void
     run() override
     {
-        using namespace InnerObjectFormatsUnitTestDetail;
+        using namespace inner_object_formats_unit_test_detail;
 
         // Instantiate a jtx::Env so debugLog writes are exercised.
         test::jtx::Env const env(*this);
@@ -166,7 +166,7 @@ public:
         {
             json::Value req;
             json::Reader().parse(test.txt, req);
-            if (RPC::containsError(req))
+            if (rpc::containsError(req))
             {
                 Throw(
                     "Internal InnerObjectFormatsParsedJSON error.  Bad JSON.");
diff --git a/src/test/protocol/MultiApiJson_test.cpp b/src/test/protocol/MultiApiJson_test.cpp
index c6f844a206..2f0d4cb3ec 100644
--- a/src/test/protocol/MultiApiJson_test.cpp
+++ b/src/test/protocol/MultiApiJson_test.cpp
@@ -62,35 +62,35 @@ struct MultiApiJson_test : beast::unit_test::Suite
             // Some static data for test inputs
             static int const kPrimes[] = {2,  3,  5,  7,  11, 13, 17, 19, 23, 29, 31, 37, 41,
                                           43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97};
-            static_assert(std::size(kPrimes) > RPC::kApiMaximumValidVersion);
+            static_assert(std::size(kPrimes) > rpc::kApiMaximumValidVersion);
 
             MultiApiJson<1, 3> s1{};
             static_assert(
-                s1.kSize == RPC::kApiMaximumValidVersion + 1 - RPC::kApiMinimumSupportedVersion);
+                s1.kSize == rpc::kApiMaximumValidVersion + 1 - rpc::kApiMinimumSupportedVersion);
 
             int productAllVersions = 1;
-            for (unsigned i = RPC::kApiMinimumSupportedVersion; i <= RPC::kApiMaximumValidVersion;
+            for (unsigned i = rpc::kApiMinimumSupportedVersion; i <= rpc::kApiMaximumValidVersion;
                  ++i)
             {
-                auto const index = i - RPC::kApiMinimumSupportedVersion;
+                auto const index = i - rpc::kApiMinimumSupportedVersion;
                 BEAST_EXPECT(index == s1.index(i));
                 BEAST_EXPECT(s1.valid(i));
                 s1.val[index] = makeJson("value", kPrimes[i]);
                 productAllVersions *= kPrimes[i];
             }
             BEAST_EXPECT(!s1.valid(0));
-            BEAST_EXPECT(!s1.valid(RPC::kApiMaximumValidVersion + 1));
+            BEAST_EXPECT(!s1.valid(rpc::kApiMaximumValidVersion + 1));
             BEAST_EXPECT(!s1.valid(
-                std::numeric_limits::max()));
+                std::numeric_limits::max()));
 
             int result = 1;
-            static_assert(RPC::kApiMinimumSupportedVersion + 1 <= RPC::kApiMaximumValidVersion);
-            forApiVersions(
+            static_assert(rpc::kApiMinimumSupportedVersion + 1 <= rpc::kApiMaximumValidVersion);
+            forApiVersions(
                 std::as_const(s1).visit(),
                 [this](json::Value const& json, unsigned int version, int* result) {
                     BEAST_EXPECT(
-                        version >= RPC::kApiMinimumSupportedVersion &&
-                        version <= RPC::kApiMinimumSupportedVersion + 1);
+                        version >= rpc::kApiMinimumSupportedVersion &&
+                        version <= rpc::kApiMinimumSupportedVersion + 1);
                     if (BEAST_EXPECT(json.isMember("value")))
                     {
                         *result *= json["value"].asInt();
@@ -99,8 +99,8 @@ struct MultiApiJson_test : beast::unit_test::Suite
                 &result);
             BEAST_EXPECT(
                 result ==
-                kPrimes[RPC::kApiMinimumSupportedVersion] *
-                    kPrimes[RPC::kApiMinimumSupportedVersion + 1]);
+                kPrimes[rpc::kApiMinimumSupportedVersion] *
+                    kPrimes[rpc::kApiMinimumSupportedVersion + 1]);
 
             // Check all the values with mutable data
             forAllApiVersions(s1.visit(), [&s1, this](json::Value& json, auto version) {
@@ -116,8 +116,8 @@ struct MultiApiJson_test : beast::unit_test::Suite
                 std::as_const(s1).visit(),
                 [this](json::Value const& json, unsigned int version, int* result) {
                     BEAST_EXPECT(
-                        version >= RPC::kApiMinimumSupportedVersion &&
-                        version <= RPC::kApiMaximumValidVersion);
+                        version >= rpc::kApiMinimumSupportedVersion &&
+                        version <= rpc::kApiMaximumValidVersion);
                     if (BEAST_EXPECT(json.isMember("value")))
                     {
                         *result *= json["value"].asInt();
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/STIssue_test.cpp b/src/test/protocol/STIssue_test.cpp
index b7cc944e6b..41517b38f3 100644
--- a/src/test/protocol/STIssue_test.cpp
+++ b/src/test/protocol/STIssue_test.cpp
@@ -7,17 +7,22 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -273,6 +278,54 @@ public:
         }
     }
 
+    void
+    testMPTSerialization()
+    {
+        testcase("MPT serialization");
+        using namespace jtx;
+        Account const alice{"alice"};
+
+        // 0x01020304 pins canonical MPTID bytes 01 02 03 04 and
+        // preserved STIssue wire bytes 04 03 02 01 on BE and LE.
+        auto const sequences = std::to_array({0x00000001, 0x01020304, 0xa1b2c3d4});
+
+        for (auto const vector : sequences)
+        {
+            MPTID const mptID = makeMptID(vector, alice);
+            MPTIssue const issue{mptID};
+            STIssue const stIssue(sfAsset, Asset{issue});
+
+            Serializer actual;
+            stIssue.add(actual);
+
+            // STIssue preserves the existing little-endian validator ledger bytes.
+            Serializer expected;
+            expected.addBitString(alice.id());
+            expected.addBitString(noAccount());
+            {
+                std::array const bytes{
+                    static_cast(vector),
+                    static_cast(vector >> 8),
+                    static_cast(vector >> 16),
+                    static_cast(vector >> 24)};
+                expected.addRaw(bytes.data(), bytes.size());
+            }
+
+            BEAST_EXPECTS(strHex(actual) == strHex(expected), strHex(actual));
+
+            // Decoding the preserved wire format must recover the canonical MPTID.
+            SerialIter iter(expected.slice());
+            STIssue const decoded(iter, sfAsset);
+            BEAST_EXPECT(decoded.holds());
+            BEAST_EXPECT(decoded.value().get().getMptID() == mptID);
+
+            // A decoded ledger value must serialize back to the same bytes.
+            Serializer roundTrip;
+            decoded.add(roundTrip);
+            BEAST_EXPECTS(strHex(roundTrip) == strHex(expected), strHex(roundTrip));
+        }
+    }
+
     void
     run() override
     {
@@ -283,6 +336,7 @@ public:
         testNoAccountIssuer();
         testXrpAccountIssuerRpc();
         testXrpAccountIssuer();
+        testMPTSerialization();
     }
 };
 
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/protocol/STValidation_test.cpp b/src/test/protocol/STValidation_test.cpp
index e42411bd3f..eb9aefd0ed 100644
--- a/src/test/protocol/STValidation_test.cpp
+++ b/src/test/protocol/STValidation_test.cpp
@@ -153,7 +153,10 @@ public:
             SerialIter sit{kPayload8};
 
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = true, .requireCanonicalOrder = false});
 
             BEAST_EXPECT(val);
             BEAST_EXPECT(val->isFieldPresent(sfLedgerSequence));
@@ -174,7 +177,10 @@ public:
         {
             SerialIter sit{kPayload1};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("An exception should have been thrown");
         }
         catch (std::exception const& ex)
@@ -186,7 +192,10 @@ public:
         {
             SerialIter sit{kPayload2};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("An exception should have been thrown");
         }
         catch (std::exception const& ex)
@@ -198,7 +207,10 @@ public:
         {
             SerialIter sit{kPayload3};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("An exception should have been thrown");
         }
         catch (std::exception const& ex)
@@ -210,7 +222,10 @@ public:
         {
             SerialIter sit{kPayload4};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("An exception should have been thrown");
         }
         catch (std::exception const& ex)
@@ -224,7 +239,10 @@ public:
         {
             SerialIter sit{kPayload5};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("Expected exception not thrown from validation");
         }
         catch (std::exception const& ex)
@@ -236,7 +254,10 @@ public:
         {
             SerialIter sit{kPayload6};
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
             fail("Expected exception not thrown from validation");
         }
         catch (std::exception const& ex)
@@ -249,7 +270,10 @@ public:
             SerialIter sit{kPayload7};
 
             auto val = std::make_shared(
-                sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false);
+                sit,
+                [](PublicKey const& pk) { return calcNodeID(pk); },
+                STValidation::DeserializeOptions{
+                    .checkSignature = false, .requireCanonicalOrder = false});
 
             fail("Expected exception not thrown from validation");
         }
@@ -279,7 +303,10 @@ public:
                 SerialIter sit{makeSlice(v2)};
 
                 auto val = std::make_shared(
-                    sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true);
+                    sit,
+                    [](PublicKey const& pk) { return calcNodeID(pk); },
+                    STValidation::DeserializeOptions{
+                        .checkSignature = true, .requireCanonicalOrder = false});
 
                 fail("Mutated validation signature checked out: offset=" + std::to_string(i));
             }
diff --git a/src/test/protocol/Serializer_test.cpp b/src/test/protocol/Serializer_test.cpp
deleted file mode 100644
index b490e0476b..0000000000
--- a/src/test/protocol/Serializer_test.cpp
+++ /dev/null
@@ -1,52 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl {
-
-struct Serializer_test : public beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        {
-            std::initializer_list const values = {
-                std::numeric_limits::min(),
-                -1,
-                0,
-                1,
-                std::numeric_limits::max()};
-            for (std::int32_t const value : values)
-            {
-                Serializer s;
-                s.add32(value);
-                BEAST_EXPECT(s.size() == 4);
-                SerialIter sit(s.slice());
-                BEAST_EXPECT(sit.geti32() == value);
-            }
-        }
-        {
-            std::initializer_list const values = {
-                std::numeric_limits::min(),
-                -1,
-                0,
-                1,
-                std::numeric_limits::max()};
-            for (std::int64_t const value : values)
-            {
-                Serializer s;
-                s.add64(value);
-                BEAST_EXPECT(s.size() == 8);
-                SerialIter sit(s.slice());
-                BEAST_EXPECT(sit.geti64() == value);
-            }
-        }
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Serializer, protocol, xrpl);
-
-}  // namespace xrpl
diff --git a/src/test/rpc/AccountLines_test.cpp b/src/test/rpc/AccountLines_test.cpp
index 8d55c5e19d..3de2bdefa3 100644
--- a/src/test/rpc/AccountLines_test.cpp
+++ b/src/test/rpc/AccountLines_test.cpp
@@ -34,7 +34,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 class AccountLines_test : public beast::unit_test::Suite
 {
@@ -51,7 +51,7 @@ public:
             auto const lines = env.rpc("json", "account_lines", "{ }");
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::missingFieldError(jss::account)[jss::error_message]);
+                rpc::missingFieldError(jss::account)[jss::error_message]);
         }
         {
             // account_lines with a malformed account.
@@ -60,7 +60,7 @@ public:
             auto const lines = env.rpc("json", "account_lines", to_string(params));
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::makeError(RpcActMalformed)[jss::error_message]);
+                rpc::makeError(RpcActMalformed)[jss::error_message]);
         }
         {
             // test account non-string
@@ -87,13 +87,31 @@ public:
             auto const lines = env.rpc("json", "account_lines", to_string(params));
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::makeError(RpcActNotFound)[jss::error_message]);
+                rpc::makeError(RpcActNotFound)[jss::error_message]);
         }
         env.fund(XRP(10000), alice);
         env.close();
         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;
@@ -250,7 +268,7 @@ public:
             auto const lines = env.rpc("json", "account_lines", to_string(params));
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::makeError(RpcActMalformed)[jss::error_message]);
+                rpc::makeError(RpcActMalformed)[jss::error_message]);
         }
         {
             // A negative limit should fail.
@@ -260,7 +278,7 @@ public:
             auto const lines = env.rpc("json", "account_lines", to_string(params));
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
         }
         {
             // Limit the response to 1 trust line.
@@ -297,7 +315,7 @@ public:
             auto const linesD = env.rpc("json", "account_lines", to_string(paramsD));
             BEAST_EXPECT(
                 linesD[jss::result][jss::error_message] ==
-                RPC::makeError(RpcInvalidParams)[jss::error_message]);
+                rpc::makeError(RpcInvalidParams)[jss::error_message]);
         }
         {
             // A non-string marker should also fail.
@@ -307,7 +325,7 @@ public:
             auto const lines = env.rpc("json", "account_lines", to_string(params));
             BEAST_EXPECT(
                 lines[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::marker, "string"));
+                rpc::expectedFieldMessage(jss::marker, "string"));
         }
         {
             // Check that the flags we expect from alice to gw2 are present.
@@ -496,7 +514,7 @@ public:
         auto const linesEnd = env.rpc("json", "account_lines", to_string(linesEndParams));
         BEAST_EXPECT(
             linesEnd[jss::result][jss::error_message] ==
-            RPC::makeError(RpcInvalidParams)[jss::error_message]);
+            rpc::makeError(RpcInvalidParams)[jss::error_message]);
     }
 
     void
@@ -728,7 +746,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::missingFieldError(jss::account)[jss::error_message]);
+                rpc::missingFieldError(jss::account)[jss::error_message]);
             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);
@@ -746,7 +764,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::makeError(RpcActMalformed)[jss::error_message]);
+                rpc::makeError(RpcActMalformed)[jss::error_message]);
             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);
@@ -765,7 +783,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::makeError(RpcActNotFound)[jss::error_message]);
+                rpc::makeError(RpcActNotFound)[jss::error_message]);
             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);
@@ -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;
@@ -998,7 +1045,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::makeError(RpcActMalformed)[jss::error_message]);
+                rpc::makeError(RpcActMalformed)[jss::error_message]);
             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);
@@ -1017,7 +1064,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
             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);
@@ -1090,7 +1137,7 @@ public:
             auto const linesD = env.rpc("json2", to_string(requestD));
             BEAST_EXPECT(
                 linesD[jss::error][jss::message] ==
-                RPC::makeError(RpcInvalidParams)[jss::error_message]);
+                rpc::makeError(RpcInvalidParams)[jss::error_message]);
             BEAST_EXPECT(linesD.isMember(jss::jsonrpc) && linesD[jss::jsonrpc] == "2.0");
             BEAST_EXPECT(linesD.isMember(jss::ripplerpc) && linesD[jss::ripplerpc] == "2.0");
             BEAST_EXPECT(linesD.isMember(jss::id) && linesD[jss::id] == 5);
@@ -1109,7 +1156,7 @@ public:
             auto const lines = env.rpc("json2", to_string(request));
             BEAST_EXPECT(
                 lines[jss::error][jss::message] ==
-                RPC::expectedFieldMessage(jss::marker, "string"));
+                rpc::expectedFieldMessage(jss::marker, "string"));
             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);
@@ -1266,7 +1313,7 @@ public:
         auto const linesEnd = env.rpc("json2", to_string(linesEndRequest));
         BEAST_EXPECT(
             linesEnd[jss::error][jss::message] ==
-            RPC::makeError(RpcInvalidParams)[jss::error_message]);
+            rpc::makeError(RpcInvalidParams)[jss::error_message]);
         BEAST_EXPECT(linesEnd.isMember(jss::jsonrpc) && linesEnd[jss::jsonrpc] == "2.0");
         BEAST_EXPECT(linesEnd.isMember(jss::ripplerpc) && linesEnd[jss::ripplerpc] == "2.0");
         BEAST_EXPECT(linesEnd.isMember(jss::id) && linesEnd[jss::id] == 5);
@@ -1286,4 +1333,4 @@ public:
 
 BEAST_DEFINE_TESTSUITE(AccountLines, rpc, xrpl);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp
index c656c97a4c..1450709f59 100644
--- a/src/test/rpc/AccountObjects_test.cpp
+++ b/src/test/rpc/AccountObjects_test.cpp
@@ -28,6 +28,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -1546,7 +1547,7 @@ public:
             env(check::create(owner, dest, XRP(1)));
             env.close();
 
-            auto const checkId = keylet::check(owner, checkSeq);
+            auto const checkId = keylet::check(owner, SeqProxy::rawSequence(checkSeq));
             if (!BEAST_EXPECT(env.le(checkId)))
                 return;
 
diff --git a/src/test/rpc/AccountTx_test.cpp b/src/test/rpc/AccountTx_test.cpp
index f1fbc2871b..735c318b21 100644
--- a/src/test/rpc/AccountTx_test.cpp
+++ b/src/test/rpc/AccountTx_test.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -199,7 +200,7 @@ class AccountTx_test : public beast::unit_test::Suite
 
         auto isErr = [](json::Value const& j, ErrorCodeI code) {
             return j.isMember(jss::result) && j[jss::result].isMember(jss::error) &&
-                j[jss::result][jss::error] == RPC::getErrorInfo(code).token;
+                j[jss::result][jss::error] == rpc::getErrorInfo(code).token;
         };
 
         json::Value jParams;
@@ -425,56 +426,56 @@ class AccountTx_test : public beast::unit_test::Suite
             p[jss::limit] = 1.2;
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = "10" should fail (string instead of integer)
             p[jss::limit] = "10";
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = true should fail (boolean instead of integer)
             p[jss::limit] = true;
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = false should fail (boolean instead of integer)
             p[jss::limit] = false;
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = -1 should fail (negative number)
             p[jss::limit] = -1;
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = [] should fail (array instead of integer)
             p[jss::limit] = json::Value(json::ValueType::Array);
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = {} should fail (object instead of integer)
             p[jss::limit] = json::Value(json::ValueType::Object);
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = "malformed" should fail (malformed string)
             p[jss::limit] = "malformed";
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = ["limit"] should fail (array with string)
             p[jss::limit] = json::Value(json::ValueType::Array);
             p[jss::limit].append("limit");
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = {"limit": 10} should fail (object with
             // property)
@@ -482,7 +483,7 @@ class AccountTx_test : public beast::unit_test::Suite
             p[jss::limit][jss::limit] = 10;
             BEAST_EXPECT(
                 env.rpc("json", "account_tx", to_string(p))[jss::result][jss::error_message] ==
-                RPC::expectedFieldMessage(jss::limit, "unsigned integer"));
+                rpc::expectedFieldMessage(jss::limit, "unsigned integer"));
 
             // Test case: limit = 10 should succeed (valid integer)
             p[jss::limit] = 10;
@@ -592,7 +593,8 @@ class AccountTx_test : public beast::unit_test::Suite
             env(payChanCreate, Sig(alie));
             env.close();
 
-            std::string const payChanIndex{strHex(keylet::payChannel(alice, gw, payChanSeq).key)};
+            std::string const payChanIndex{
+                strHex(keylet::payChannel(alice, gw, SeqProxy::rawSequence(payChanSeq)).key)};
 
             {
                 json::Value payChanFund;
@@ -617,10 +619,11 @@ class AccountTx_test : public beast::unit_test::Suite
 
         // Check
         {
-            auto const aliceCheckId = keylet::check(alice, env.seq(alice)).key;
+            auto const aliceCheckId =
+                keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(check::create(alice, gw, XRP(300)), Sig(alie));
 
-            auto const gwCheckId = keylet::check(gw, env.seq(gw)).key;
+            auto const gwCheckId = keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key;
             env(check::create(gw, alice, XRP(200)));
             env.close();
 
@@ -1355,7 +1358,7 @@ class AccountTx_test : public beast::unit_test::Suite
         checkTx(sponsor, jss::SponsorshipSet);
 
         // create an object with sponsor
-        auto const checkId = keylet::check(alice, env.seq(alice)).key;
+        auto const checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key;
         env(check::create(alice, sponsor, XRP(1)), sponsor::As(sponsor, spfSponsorReserve));
         env.close();
         checkTx(alice, jss::CheckCreate);
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/Book_test.cpp b/src/test/rpc/Book_test.cpp
index 83f7b64b4b..646cf190ff 100644
--- a/src/test/rpc/Book_test.cpp
+++ b/src/test/rpc/Book_test.cpp
@@ -1548,7 +1548,7 @@ public:
 
         auto usd = gw["USD"];
 
-        for (auto i = 0; i <= RPC::Tuning::kBookOffers.rmax; i++)
+        for (auto i = 0; i <= rpc::tuning::kBookOffers.rmax; i++)
             env(offer(gw, XRP(50 + (1 * i)), usd(1.0 + (0.1 * i))));
 
         if (asAdmin)
@@ -1565,15 +1565,15 @@ public:
         BEAST_EXPECT(jrr[jss::offers].size() == (asAdmin ? 1u : 0u));
         // NOTE - a marker field is not returned for this method
 
-        jvParams[jss::limit] = RPC::Tuning::kBookOffers.rmax + 1;
+        jvParams[jss::limit] = rpc::tuning::kBookOffers.rmax + 1;
         jrr = env.rpc("json", "book_offers", to_string(jvParams))[jss::result];
         BEAST_EXPECT(jrr[jss::offers].isArray());
-        BEAST_EXPECT(jrr[jss::offers].size() == (asAdmin ? RPC::Tuning::kBookOffers.rmax + 1 : 0u));
+        BEAST_EXPECT(jrr[jss::offers].size() == (asAdmin ? rpc::tuning::kBookOffers.rmax + 1 : 0u));
 
         jvParams[jss::limit] = json::ValueType::Null;
         jrr = env.rpc("json", "book_offers", to_string(jvParams))[jss::result];
         BEAST_EXPECT(jrr[jss::offers].isArray());
-        BEAST_EXPECT(jrr[jss::offers].size() == (asAdmin ? RPC::Tuning::kBookOffers.rDefault : 0u));
+        BEAST_EXPECT(jrr[jss::offers].size() == (asAdmin ? rpc::tuning::kBookOffers.rDefault : 0u));
     }
 
     void
diff --git a/src/test/rpc/Feature_test.cpp b/src/test/rpc/Feature_test.cpp
index a36e51cb6f..1e2504bf7f 100644
--- a/src/test/rpc/Feature_test.cpp
+++ b/src/test/rpc/Feature_test.cpp
@@ -187,13 +187,13 @@ class Feature_test : public beast::unit_test::Suite
         using namespace test::jtx;
         Env env{*this};
 
-        std::string const name = "fixAMMOverflowOffer";
+        std::string const name = "fixCleanup3_1_3";
         auto jrr = env.rpc("feature", name)[jss::result];
         BEAST_EXPECTS(jrr[jss::status] == jss::success, "status");
         jrr.removeMember(jss::status);
         BEAST_EXPECT(jrr.size() == 1);
         auto const expected = to_string(sha512Half(Slice(name.data(), name.size())));
-        char const sha[] = "12523DF04B553A0B1AD74F42DDB741DE8DC06A03FC089A0EF197E2A87F1D8107";
+        char const sha[] = "303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC";
         BEAST_EXPECT(expected == sha);
         BEAST_EXPECT(jrr.isMember(expected));
         auto feature = *(jrr.begin());
@@ -475,7 +475,7 @@ class Feature_test : public beast::unit_test::Suite
 
         using namespace test::jtx;
         Env env{*this, FeatureBitset{featurePriceOracle}};
-        static constexpr char const* kFeatureName = "fixAMMOverflowOffer";
+        static constexpr char const* kFeatureName = "fixCleanup3_1_3";
 
         auto jrr = env.rpc("feature", kFeatureName)[jss::result];
         if (!BEAST_EXPECTS(jrr[jss::status] == jss::success, "status"))
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/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp
index 3e0bfa1fd3..58c2e8b996 100644
--- a/src/test/rpc/GetAggregatePrice_test.cpp
+++ b/src/test/rpc/GetAggregatePrice_test.cpp
@@ -320,6 +320,48 @@ public:
             BEAST_EXPECT(ret[jss::median] == "74");
             BEAST_EXPECT(ret[jss::time] == 946695000);
         }
+
+        // Duplicate oracle entries should be deduplicated.
+        // Two separate oracles with different prices give size=2.
+        // Listing the first oracle twice in the query must not
+        // inflate the size to 3.
+        {
+            Env env(*this);
+            auto const baseFee = static_cast(env.current()->fees().base.drops());
+
+            Account const owner1{"owner1"};
+            Account const owner2{"owner2"};
+            env.fund(XRP(1'000), owner1);
+            env.fund(XRP(1'000), owner2);
+            Oracle const oracle1(
+                env, {.owner = owner1, .series = {{"XRP", "USD", 740, 1}}, .fee = baseFee});
+            Oracle const oracle2(
+                env, {.owner = owner2, .series = {{"XRP", "USD", 840, 1}}, .fee = baseFee});
+
+            // Query with both oracles listed once
+            OraclesData const single = {
+                {owner1, oracle1.documentID()}, {owner2, oracle2.documentID()}};
+            auto const retSingle = Oracle::aggregatePrice(env, "XRP", "USD", single);
+
+            // Query with oracle1 listed twice
+            OraclesData const duplicated = {
+                {owner1, oracle1.documentID()},
+                {owner1, oracle1.documentID()},
+                {owner2, oracle2.documentID()}};
+            auto const retDup = Oracle::aggregatePrice(env, "XRP", "USD", duplicated);
+
+            // Results should be identical - duplicates must not be
+            // double-counted
+            BEAST_EXPECT(
+                retSingle[jss::entire_set][jss::size] == retDup[jss::entire_set][jss::size]);
+            BEAST_EXPECT(retDup[jss::entire_set][jss::size].asUInt() == 2);
+            BEAST_EXPECT(
+                retSingle[jss::entire_set][jss::mean] == retDup[jss::entire_set][jss::mean]);
+            BEAST_EXPECT(
+                retSingle[jss::entire_set][jss::standard_deviation] ==
+                retDup[jss::entire_set][jss::standard_deviation]);
+            BEAST_EXPECT(retSingle[jss::median] == retDup[jss::median]);
+        }
     }
 
     void
diff --git a/src/test/rpc/Handler_test.cpp b/src/test/rpc/Handler_test.cpp
index e900b92fc3..16838d3f5a 100644
--- a/src/test/rpc/Handler_test.cpp
+++ b/src/test/rpc/Handler_test.cpp
@@ -1,9 +1,8 @@
 
-#include 
-
 #include 
 
 #include 
+#include 
 
 #include 
 #include 
@@ -12,7 +11,11 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
+#include 
+#include 
 #include 
 #include 
 // cspell: words stdev
@@ -88,21 +91,50 @@ class Handler_test : public beast::unit_test::Suite
         std::random_device dev;
         std::ranlux48 prng(dev());
 
-        std::vector names = test::jtx::makeVector(xrpl::RPC::getHandlerNames());
+        // The lowest version still served. Outside the supported range getHandler()
+        // returns at its bounds check without searching, so the benchmark would
+        // time that check instead of a lookup.
+        constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion;
+
+        // Only the names that answer at kVersion, so that every timed call does a
+        // whole lookup: a method served from a later version only would not.
+        // Contiguous, so that picking one by index costs nothing.
+        std::vector names;
+        std::ranges::copy_if(
+            rpc::getHandlerNames(), std::back_inserter(names), [](std::string_view name) {
+                return rpc::getHandler(kVersion, false, name) != nullptr;
+            });
+
+        if (!BEAST_EXPECTS(
+                !names.empty(),
+                "no handler answers at API version " + std::to_string(kVersion) +
+                    ", so there is nothing to measure"))
+            return;
 
         std::uniform_int_distribution distr{0, names.size() - 1};
 
         std::size_t dummy = 0;
+        std::size_t misses = 0;
         auto const [mean, stdev, n] = time(
             1'000'000,
             [&](std::size_t i) {
-                auto const d = RPC::getHandler(1, false, names[i]);
+                auto const d = rpc::getHandler(kVersion, false, names[i]);
+                if (d == nullptr)
+                {
+                    ++misses;
+                    return;
+                }
                 dummy = dummy + i + (int)d->role;
             },
             [&]() -> std::size_t { return distr(prng); });
 
         std::cout << "mean=" << mean << " stdev=" << stdev << " N=" << n << '\n';
 
+        // Every name answered once already, so a miss here cannot happen.
+        BEAST_EXPECTS(
+            misses == 0,
+            std::to_string(misses) + " of " + std::to_string(n) + " lookups at API version " +
+                std::to_string(kVersion) + " found no handler, so nothing was measured");
         BEAST_EXPECT(dummy != 0);
     }
 
@@ -114,6 +146,125 @@ public:
     }
 };
 
+// Manual: the suite only reports a timing, which says nothing on a CI runner.
+// The table invariants are static_asserts in Handler.cpp.
 BEAST_DEFINE_TESTSUITE_MANUAL(Handler, rpc, xrpl);
 
+// What getHandler() answers, as opposed to how fast it answers. A lookup needs no
+// Application, so these cases run as an automatic suite.
+//
+// The bounds check they cover is unreachable from a request: getAPIVersionNumber()
+// applies the same predicate first, and every caller rejects an invalid version
+// before it asks for a handler. That is why it is checked here directly, and why
+// it is worth checking at all rather than deleting as unreachable.
+class HandlerLookup_test : public beast::unit_test::Suite
+{
+    /**
+     * Find a method that is served at a given API version.
+     *
+     * The name comes from the table, so a case below does not name a method that a
+     * later API version may retire.
+     *
+     * @param version The API version to answer at.
+     * @param betaEnabled Whether the beta API version is enabled.
+     * @return A name that answers, or nullopt if none does.
+     */
+    static std::optional
+    nameServedAt(unsigned version, bool betaEnabled)
+    {
+        for (std::string_view name : rpc::getHandlerNames())
+        {
+            if (rpc::getHandler(version, betaEnabled, name) != nullptr)
+                return name;
+        }
+
+        return std::nullopt;
+    }
+
+    void
+    testUnservedVersion()
+    {
+        testcase("An unserved API version has no handler");
+
+        // A name the table certainly holds, so that a null answer below can only
+        // come from the version and not from the name.
+        auto const name = nameServedAt(rpc::kApiMinimumSupportedVersion, false);
+        if (!BEAST_EXPECTS(
+                name.has_value(),
+                "no handler answers at API version " +
+                    std::to_string(rpc::kApiMinimumSupportedVersion) +
+                    ", so there is no name to ask about"))
+            return;
+
+        // Below the minimum, which no setting serves.
+        BEAST_EXPECT(
+            rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, false, *name) == nullptr);
+        BEAST_EXPECT(rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, true, *name) == nullptr);
+
+        // Above the maximum each setting serves. Both values stay outside the
+        // served range however the version constants move, so neither case can
+        // become vacuous.
+        BEAST_EXPECT(
+            rpc::getHandler(rpc::kApiMaximumSupportedVersion + 1, false, *name) == nullptr);
+        BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion + 1, true, *name) == nullptr);
+    }
+
+    void
+    testBetaVersionGate()
+    {
+        testcase("The beta API version is served only where it is enabled");
+
+        // Between betas the beta version is the maximum supported one, leaving the
+        // two settings nothing to tell apart. Compiled out rather than asserted, so
+        // that the case arms itself again when a later beta version arrives.
+        if constexpr (rpc::kApiBetaVersion > rpc::kApiMaximumSupportedVersion)
+        {
+            auto const name = nameServedAt(rpc::kApiBetaVersion, true);
+            if (!BEAST_EXPECTS(
+                    name.has_value(),
+                    "no handler answers at API version " + std::to_string(rpc::kApiBetaVersion) +
+                        ", so there is nothing for the gate to reject"))
+                return;
+
+            // The handler serves this version, so only the server's own range can
+            // turn the answer into a null one.
+            BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, true, *name) != nullptr);
+            BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, false, *name) == nullptr);
+        }
+        else
+        {
+            log << "the beta API version is the maximum supported version, so no gate "
+                   "separates them\n";
+            pass();
+        }
+    }
+
+    void
+    testUnknownMethod()
+    {
+        testcase("An unknown method has no handler");
+
+        constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion;
+
+        BEAST_EXPECT(rpc::getHandler(kVersion, false, "no such method") == nullptr);
+        BEAST_EXPECT(rpc::getHandler(kVersion, false, "") == nullptr);
+
+        // A method name holds lowercase letters and underscores, so a tilde sorts
+        // after every entry. This runs the search off the end of the table, which
+        // no other case here does.
+        BEAST_EXPECT(rpc::getHandler(kVersion, false, "~") == nullptr);
+    }
+
+public:
+    void
+    run() override
+    {
+        testUnservedVersion();
+        testBetaVersionGate();
+        testUnknownMethod();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(HandlerLookup, rpc, xrpl);
+
 }  // namespace xrpl::test
diff --git a/src/test/rpc/JSONRPC_test.cpp b/src/test/rpc/JSONRPC_test.cpp
index e18974e7e7..efba4075c7 100644
--- a/src/test/rpc/JSONRPC_test.cpp
+++ b/src/test/rpc/JSONRPC_test.cpp
@@ -36,7 +36,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 struct TxnTestData
 {
@@ -2248,7 +2248,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == baseFee);
         }
@@ -2268,7 +2268,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == baseFee);
         }
@@ -2285,7 +2285,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2306,7 +2306,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2325,7 +2325,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2344,7 +2344,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2400,7 +2400,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == 10);
         }
 
@@ -2422,7 +2422,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == 10);
         }
 
@@ -2450,7 +2450,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == 8889);
         }
@@ -2473,7 +2473,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2496,7 +2496,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
             BEAST_EXPECT(!req[jss::tx_json].isMember(jss::Fee));
         }
 
@@ -2519,7 +2519,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 req[jss::tx_json].isMember(jss::Fee) && req[jss::tx_json][jss::Fee] == 8889);
         }
@@ -2542,7 +2542,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
         }
 
         {
@@ -2563,7 +2563,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
         }
 
         {
@@ -2585,7 +2585,7 @@ public:
                 env.app().getTxQ(),
                 env.app());
 
-            BEAST_EXPECT(RPC::containsError(result));
+            BEAST_EXPECT(rpc::containsError(result));
         }
 
         env.close();
@@ -2598,7 +2598,7 @@ public:
             auto rpcResult = env.rpc("json", "sign", to_string(toSign));
             auto result = rpcResult[jss::result];
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 result[jss::tx_json].isMember(jss::Fee) && result[jss::tx_json][jss::Fee] == "10");
             BEAST_EXPECT(
@@ -2624,7 +2624,7 @@ public:
             auto rpcResult = env.rpc("json", "sign", to_string(toSign));
             auto result = rpcResult[jss::result];
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 result[jss::tx_json].isMember(jss::Fee) &&
                 result[jss::tx_json][jss::Fee] == "7813");
@@ -2651,7 +2651,7 @@ public:
             auto rpcResult = env.rpc("json", "sign", to_string(toSign));
             auto result = rpcResult[jss::result];
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 result[jss::tx_json].isMember(jss::Fee) && result[jss::tx_json][jss::Fee] == "47");
             BEAST_EXPECT(
@@ -2682,7 +2682,7 @@ public:
             auto rpcResult = env.rpc("json", "sign", to_string(toSign));
             auto result = rpcResult[jss::result];
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 result[jss::tx_json].isMember(jss::Fee) &&
                 result[jss::tx_json][jss::Fee] == "6806");
@@ -2711,7 +2711,7 @@ public:
             auto rpcResult = env.rpc("json", "sign", to_string(toSign));
             auto result = rpcResult[jss::result];
 
-            BEAST_EXPECT(!RPC::containsError(result));
+            BEAST_EXPECT(!rpc::containsError(result));
             BEAST_EXPECT(
                 result[jss::tx_json].isMember(jss::NetworkID) &&
                 result[jss::tx_json][jss::NetworkID] == 1025);
@@ -2791,7 +2791,7 @@ public:
             {
                 json::Value req;
                 json::Reader().parse(txnTest.json, req);
-                if (RPC::containsError(req))
+                if (rpc::containsError(req))
                     Throw("Internal JSONRPC_test error.  Bad test JSON.");
 
                 static Role const kTestedRoles[] = {
@@ -2815,7 +2815,7 @@ public:
                     }
 
                     std::string errStr;
-                    if (RPC::containsError(result))
+                    if (rpc::containsError(result))
                         errStr = result["error_message"].asString();
 
                     if (errStr == txnTest.expMsg[get<3>(testFunc)])
@@ -2848,4 +2848,4 @@ public:
 
 BEAST_DEFINE_TESTSUITE(JSONRPC, rpc, xrpl);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/test/rpc/KeyGeneration_test.cpp b/src/test/rpc/KeyGeneration_test.cpp
index aafe6f75a5..2b056fc6d2 100644
--- a/src/test/rpc/KeyGeneration_test.cpp
+++ b/src/test/rpc/KeyGeneration_test.cpp
@@ -15,7 +15,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 struct KeyStrings
 {
@@ -800,4 +800,4 @@ public:
 
 BEAST_DEFINE_TESTSUITE(WalletPropose, rpc, xrpl);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/test/rpc/LedgerEntry_test.cpp b/src/test/rpc/LedgerEntry_test.cpp
index 7adb5a4518..24dde05ce1 100644
--- a/src/test/rpc/LedgerEntry_test.cpp
+++ b/src/test/rpc/LedgerEntry_test.cpp
@@ -46,6 +46,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -349,7 +350,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 json::Value const jrr = env.rpc(
                     apiVersion, "json", "ledger_entry", to_string(correctRequest))[jss::result];
                 auto const expectedErrMsg =
-                    RPC::expectedFieldMessage(fieldName, getTypeName(typeID));
+                    rpc::expectedFieldMessage(fieldName, getTypeName(typeID));
                 checkErrorValue(jrr, expectedError, expectedErrMsg, location);
             };
 
@@ -383,13 +384,13 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 json::Value const jrr = env.rpc(
                     apiVersion, "json", "ledger_entry", to_string(correctRequest))[jss::result];
                 checkErrorValue(
-                    jrr, "malformedRequest", RPC::missingFieldMessage(fieldName.cStr()), location);
+                    jrr, "malformedRequest", rpc::missingFieldMessage(fieldName.cStr()), location);
 
                 correctRequest[parentFieldName][fieldName] = json::ValueType::Null;
                 json::Value const jrr2 = env.rpc(
                     apiVersion, "json", "ledger_entry", to_string(correctRequest))[jss::result];
                 checkErrorValue(
-                    jrr2, "malformedRequest", RPC::missingFieldMessage(fieldName.cStr()), location);
+                    jrr2, "malformedRequest", rpc::missingFieldMessage(fieldName.cStr()), location);
             }
             auto tryField = [&](json::Value fieldValue) -> void {
                 correctRequest[parentFieldName][fieldName] = fieldValue;
@@ -399,7 +400,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 checkErrorValue(
                     jrr,
                     expectedError,
-                    RPC::expectedFieldMessage(fieldName, getTypeName(typeID)),
+                    rpc::expectedFieldMessage(fieldName, getTypeName(typeID)),
                     location);
             };
 
@@ -807,7 +808,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         env.fund(XRP(10000), alice);
         env.close();
 
-        auto const checkId = keylet::check(env.master, env.seq(env.master));
+        auto const checkId = keylet::check(env.master, SeqProxy::rawSequence(env.seq(env.master)));
 
         env(check::create(env.master, alice, XRP(100)));
         env.close();
@@ -1083,8 +1084,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 json::Value const jrr =
                     env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
                 auto const expectedErrMsg = fieldValue.isNull()
-                    ? RPC::missingFieldMessage(jss::issuer.cStr())
-                    : RPC::expectedFieldMessage(jss::issuer, "AccountID");
+                    ? rpc::missingFieldMessage(jss::issuer.cStr())
+                    : rpc::expectedFieldMessage(jss::issuer, "AccountID");
                 checkErrorValue(jrr, "malformedAuthorizedCredentials", expectedErrMsg);
             };
 
@@ -1114,7 +1115,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
             checkErrorValue(
                 jrr[jss::result],
                 "malformedAuthorizedCredentials",
-                RPC::expectedFieldMessage(jss::authorized_credentials, "array"));
+                rpc::expectedFieldMessage(jss::authorized_credentials, "array"));
         }
 
         {
@@ -1134,8 +1135,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 json::Value const jrr =
                     env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
                 auto const expectedErrMsg = fieldValue.isNull()
-                    ? RPC::missingFieldMessage(jss::credential_type.cStr())
-                    : RPC::expectedFieldMessage(jss::credential_type, "hex string");
+                    ? rpc::missingFieldMessage(jss::credential_type.cStr())
+                    : rpc::expectedFieldMessage(jss::credential_type, "hex string");
                 checkErrorValue(jrr, "malformedAuthorizedCredentials", expectedErrMsg);
             };
 
@@ -1527,7 +1528,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
         uint256 const nftokenID0 = token::getNextID(env, issuer, 0, tfTransferable);
         env(token::mint(issuer, 0), Txflags(tfTransferable));
         env.close();
-        uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key;
+        uint256 const offerID =
+            keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key;
         env(token::createOffer(issuer, nftokenID0, drops(1)),
             token::Destination(buyer),
             Txflags(tfSellNFToken));
@@ -1711,7 +1713,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
 
         std::string const ledgerHash{to_string(env.closed()->header().hash)};
 
-        uint256 const payChanIndex{keylet::payChannel(alice, env.master, env.seq(alice) - 1).key};
+        uint256 const payChanIndex{
+            keylet::payChannel(alice, env.master, SeqProxy::rawSequence(env.seq(alice) - 1)).key};
         {
             // Request the payment channel using its index.
             json::Value jvParams;
@@ -1836,7 +1839,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
                         checkErrorValue(
                             jrr,
                             "malformedAddress",
-                            RPC::expectedFieldMessage(jss::accounts, "array of Accounts"));
+                            rpc::expectedFieldMessage(jss::accounts, "array of Accounts"));
                     }
 
                     {
@@ -1851,7 +1854,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
                         checkErrorValue(
                             jrr,
                             "malformedAddress",
-                            RPC::expectedFieldMessage(jss::accounts, "array of Accounts"));
+                            rpc::expectedFieldMessage(jss::accounts, "array of Accounts"));
                     }
                 };
 
@@ -1949,7 +1952,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         env.close();
 
         // Create two tickets.
-        std::uint32_t const tkt1{env.seq(env.master) + 1};
+        SeqProxy tkt1 = SeqProxy::rawTicket(env.seq(env.master));
         env(ticket::create(env.master, 2));
         env.close();
 
@@ -1960,7 +1963,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         {
             // Not a valid ticket requested by index.
             json::Value jvParams;
-            jvParams[jss::ticket] = to_string(getTicketIndex(env.master, tkt1 - 1));
+            jvParams[jss::ticket] = to_string(keylet::ticket(env.master, tkt1).key);
             jvParams[jss::ledger_hash] = ledgerHash;
             json::Value const jrr =
                 env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
@@ -1969,31 +1972,34 @@ class LedgerEntry_test : public beast::unit_test::Suite
         {
             // First real ticket requested by index.
             json::Value jvParams;
-            jvParams[jss::ticket] = to_string(getTicketIndex(env.master, tkt1));
+            tkt1.advanceBy(1);
+            jvParams[jss::ticket] = to_string(keylet::ticket(env.master, tkt1).key);
             jvParams[jss::ledger_hash] = ledgerHash;
             json::Value const jrr =
                 env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
             BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Ticket);
-            BEAST_EXPECT(jrr[jss::node][sfTicketSequence.jsonName] == tkt1);
+            BEAST_EXPECT(jrr[jss::node][sfTicketSequence.jsonName] == tkt1.value());
         }
         {
             // Second real ticket requested by account and sequence.
+            tkt1.advanceBy(1);
             json::Value jvParams;
             jvParams[jss::ticket] = json::ValueType::Object;
             jvParams[jss::ticket][jss::account] = env.master.human();
-            jvParams[jss::ticket][jss::ticket_seq] = tkt1 + 1;
+            jvParams[jss::ticket][jss::ticket_seq] = tkt1.value();
             jvParams[jss::ledger_hash] = ledgerHash;
             json::Value const jrr =
                 env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
             BEAST_EXPECT(
-                jrr[jss::node][jss::index] == to_string(getTicketIndex(env.master, tkt1 + 1)));
+                jrr[jss::node][jss::index] == to_string(keylet::ticket(env.master, tkt1).key));
         }
         {
             // Not a valid ticket requested by account and sequence.
+            tkt1.advanceBy(1);
             json::Value jvParams;
             jvParams[jss::ticket] = json::ValueType::Object;
             jvParams[jss::ticket][jss::account] = env.master.human();
-            jvParams[jss::ticket][jss::ticket_seq] = tkt1 + 2;
+            jvParams[jss::ticket][jss::ticket_seq] = tkt1.value();
             jvParams[jss::ledger_hash] = ledgerHash;
             json::Value const jrr =
                 env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
@@ -2253,7 +2259,8 @@ class LedgerEntry_test : public beast::unit_test::Suite
                 jv[jss::result][jss::node][sfLedgerEntryType.jsonName] == jss::PermissionedDomain);
 
             std::string const pdIdx = jv[jss::result][jss::index].asString();
-            BEAST_EXPECT(strHex(keylet::permissionedDomain(alice, seq).key) == pdIdx);
+            BEAST_EXPECT(
+                strHex(keylet::permissionedDomain(alice, SeqProxy::rawSequence(seq)).key) == pdIdx);
 
             params.clear();
             params[jss::ledger_index] = jss::validated;
@@ -2703,7 +2710,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
         env.fund(XRP(10000), alice);
         env.close();
 
-        auto const checkId = keylet::check(env.master, env.seq(env.master));
+        auto const checkId = keylet::check(env.master, SeqProxy::rawSequence(env.seq(env.master)));
 
         env(check::create(env.master, alice, XRP(100)));
         env.close();
diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp
index 3a2c957691..deb19e3a3f 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 
@@ -12,16 +13,21 @@
 #include 
 
 #include 
+#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -158,7 +164,7 @@ class LedgerRPC_test : public beast::unit_test::Suite
         {
             // Request a ledger with a very large (double) sequence.
             auto const ret = env.rpc("json", "ledger", "{ \"ledger_index\" : 2e15 }");
-            BEAST_EXPECT(RPC::containsError(ret));
+            BEAST_EXPECT(rpc::containsError(ret));
             BEAST_EXPECT(ret[jss::error_message] == "Invalid parameters.");
         }
 
@@ -258,6 +264,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
@@ -709,6 +811,95 @@ class LedgerRPC_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testLedgerExpandedTransactionsCTID()
+    {
+        testcase("Expanded Transactions CTID");
+        using namespace test::jtx;
+
+        Env env{*this};
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        env.close();
+
+        uint32_t const netID = env.app().getNetworkIDService().getNetworkID();
+
+        // API v2 non-binary: CTID present
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = "validated";
+            jvParams[jss::transactions] = true;
+            jvParams[jss::expand] = true;
+            jvParams[jss::api_version] = 2;
+            auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+            auto const& txns = jrr[jss::ledger][jss::transactions];
+            BEAST_EXPECT(txns.isArray() && txns.size() > 0);
+            for (auto const& txn : txns)
+            {
+                BEAST_EXPECT(txn.isMember(jss::ctid));
+                auto const expectedCtid = rpc::encodeCTID(
+                    jrr[jss::ledger][jss::ledger_index].asUInt(),
+                    txn[jss::meta][sfTransactionIndex.jsonName].asUInt(),
+                    netID);
+                // NOLINTBEGIN(bugprone-unchecked-optional-access)
+                if (BEAST_EXPECT(expectedCtid.has_value()))
+                    BEAST_EXPECT(txn[jss::ctid] == expectedCtid.value());
+                // NOLINTEND(bugprone-unchecked-optional-access)
+            }
+        }
+
+        // API v1 non-binary: CTID present
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = "validated";
+            jvParams[jss::transactions] = true;
+            jvParams[jss::expand] = true;
+            auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+            auto const& txns = jrr[jss::ledger][jss::transactions];
+            BEAST_EXPECT(txns.isArray() && txns.size() > 0);
+            for (auto const& txn : txns)
+            {
+                BEAST_EXPECT(txn.isMember(jss::ctid));
+            }
+        }
+
+        // Binary expanded: CTID present
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = "validated";
+            jvParams[jss::transactions] = true;
+            jvParams[jss::expand] = true;
+            jvParams[jss::binary] = true;
+            jvParams[jss::api_version] = 2;
+            auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+            auto const& txns = jrr[jss::ledger][jss::transactions];
+            BEAST_EXPECT(txns.isArray() && txns.size() > 0);
+            for (auto const& txn : txns)
+            {
+                BEAST_EXPECT(txn.isMember(jss::ctid));
+            }
+        }
+
+        // Non-expanded: transactions are plain hash strings, no CTID
+        {
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = "validated";
+            jvParams[jss::transactions] = true;
+            jvParams[jss::api_version] = 2;
+            auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result];
+            BEAST_EXPECT(jrr[jss::status] == "success");
+            auto const& txns = jrr[jss::ledger][jss::transactions];
+            BEAST_EXPECT(txns.isArray() && txns.size() > 0);
+            for (auto const& txn : txns)
+            {
+                BEAST_EXPECT(txn.isString());
+            }
+        }
+    }
+
 public:
     void
     run() override
@@ -719,10 +910,12 @@ public:
         testLedgerFull();
         testLedgerFullNonAdmin();
         testLedgerAccounts();
+        testLedgerOwnerFundsMPTOffer();
         testLookupLedger();
         testNoQueue();
         testQueue();
         testLedgerAccountsOption();
+        testLedgerExpandedTransactionsCTID();
     }
 };
 
diff --git a/src/test/rpc/LedgerRequest_test.cpp b/src/test/rpc/LedgerRequest_test.cpp
index 93feee9497..98bde4e5a5 100644
--- a/src/test/rpc/LedgerRequest_test.cpp
+++ b/src/test/rpc/LedgerRequest_test.cpp
@@ -15,7 +15,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 class LedgerRequest_test : public beast::unit_test::Suite
 {
@@ -43,28 +43,28 @@ public:
             // arbitrary text is converted to 0.
             auto const result = env.rpc("ledger_request", "arbitrary_text");
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] == "Ledger index too small");
         }
 
         {
             auto const result = env.rpc("ledger_request", "-1");
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] == "Ledger index too small");
         }
 
         {
             auto const result = env.rpc("ledger_request", "0");
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] == "Ledger index too small");
         }
 
         {
             auto const result = env.rpc("ledger_request", "1");
             BEAST_EXPECT(
-                !RPC::containsError(result[jss::result]) &&
+                !rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::ledger_index] == 1 &&
                 result[jss::result].isMember(jss::ledger));
             BEAST_EXPECT(
@@ -75,7 +75,7 @@ public:
         {
             auto const result = env.rpc("ledger_request", "2");
             BEAST_EXPECT(
-                !RPC::containsError(result[jss::result]) &&
+                !rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::ledger_index] == 2 &&
                 result[jss::result].isMember(jss::ledger));
             BEAST_EXPECT(
@@ -86,7 +86,7 @@ public:
         {
             auto const result = env.rpc("ledger_request", "3");
             BEAST_EXPECT(
-                !RPC::containsError(result[jss::result]) &&
+                !rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::ledger_index] == 3 &&
                 result[jss::result].isMember(jss::ledger));
             BEAST_EXPECT(
@@ -98,7 +98,7 @@ public:
             {
                 auto const r = env.rpc("ledger_request", ledgerHash);
                 BEAST_EXPECT(
-                    !RPC::containsError(r[jss::result]) && r[jss::result][jss::ledger_index] == 3 &&
+                    !rpc::containsError(r[jss::result]) && r[jss::result][jss::ledger_index] == 3 &&
                     r[jss::result].isMember(jss::ledger));
                 BEAST_EXPECT(
                     r[jss::result][jss::ledger].isMember(jss::ledger_hash) &&
@@ -112,7 +112,7 @@ public:
             auto const result = env.rpc("ledger_request", ledgerHash);
 
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] ==
                     "Invalid field 'ledger_hash', not hex string.");
         }
@@ -123,21 +123,21 @@ public:
             auto const result = env.rpc("ledger_request", ledgerHash);
 
             BEAST_EXPECT(
-                !RPC::containsError(result[jss::result]) &&
+                !rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::have_header] == false);
         }
 
         {
             auto const result = env.rpc("ledger_request", "4");
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] == "Ledger index too large");
         }
 
         {
             auto const result = env.rpc("ledger_request", "5");
             BEAST_EXPECT(
-                RPC::containsError(result[jss::result]) &&
+                rpc::containsError(result[jss::result]) &&
                 result[jss::result][jss::error_message] == "Ledger index too large");
         }
     }
@@ -357,4 +357,4 @@ public:
 
 BEAST_DEFINE_TESTSUITE(LedgerRequest, rpc, xrpl);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp
index 9c17d291a1..3a4ddcf5c4 100644
--- a/src/test/rpc/NoRippleCheck_test.cpp
+++ b/src/test/rpc/NoRippleCheck_test.cpp
@@ -27,8 +27,6 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 
@@ -128,9 +126,16 @@ class NoRippleCheck_test : public beast::unit_test::Suite
             params[jss::account] = toBase58(TokenType::NodePrivate, alice.sk());
             params[jss::role] = "user";
             params[jss::ledger] = "current";
+            params[jss::transactions] = true;
             auto const result = env.rpc("json", "noripple_check", to_string(params))[jss::result];
             BEAST_EXPECT(result[jss::error] == "actMalformed");
             BEAST_EXPECT(result[jss::error_message] == "Account malformed.");
+            // The changelog promises malformed-account responses carry
+            // neither `transactions` nor any ledger metadata.
+            BEAST_EXPECT(!result.isMember(jss::transactions));
+            BEAST_EXPECT(!result.isMember(jss::ledger_hash));
+            BEAST_EXPECT(!result.isMember(jss::ledger_index));
+            BEAST_EXPECT(!result.isMember(jss::validated));
         }
 
         {
@@ -196,6 +201,7 @@ class NoRippleCheck_test : public beast::unit_test::Suite
         if (!BEAST_EXPECT(pa.isArray()))
             return;
 
+        BEAST_EXPECT(!result.isMember(jss::transactions));
         if (problems)
         {
             if (!BEAST_EXPECT(pa.size() == 2))
@@ -203,13 +209,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
@@ -221,12 +227,12 @@ class NoRippleCheck_test : public beast::unit_test::Suite
         // time.
         params[jss::transactions] = true;
         result = env.rpc("json", "noripple_check", to_string(params))[jss::result];
-        if (!BEAST_EXPECT(result[jss::transactions].isArray()))
-            return;
 
         auto const txs = result[jss::transactions];
         if (problems)
         {
+            if (!BEAST_EXPECT(result[jss::transactions].isArray()))
+                return;
             if (!BEAST_EXPECT(txs.size() == (user ? 1 : 2)))
                 return;
 
@@ -286,9 +292,9 @@ class NoRippleCheckLimits_test : public beast::unit_test::Suite
             // be better if we could add this functionality to Env somehow
             // or otherwise disable endpoint charging for certain test
             // cases.
-            using namespace xrpl::Resource;
+            using namespace xrpl::resource;
             using namespace std::chrono;
-            using namespace beast::IP;
+            using namespace beast::ip;
             auto c = env.app().getResourceManager().newInboundEndpoint(
                 Endpoint::fromString(test::getEnvLocalhostAddr()));
 
@@ -301,7 +307,7 @@ class NoRippleCheckLimits_test : public beast::unit_test::Suite
             }
         };
 
-        for (auto i = 0; i < xrpl::RPC::Tuning::kNoRippleCheck.rmax + 5; ++i)
+        for (auto i = 0; i < xrpl::rpc::tuning::kNoRippleCheck.rmax + 5; ++i)
         {
             if (!admin)
                 checkBalance();
diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp
index 4b5ab1f230..e09d95f99a 100644
--- a/src/test/rpc/RPCCall_test.cpp
+++ b/src/test/rpc/RPCCall_test.cpp
@@ -3,6 +3,9 @@
 #include 
 
 #include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
@@ -12,11 +15,14 @@
 
 #include 
 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -5855,8 +5861,8 @@ public:
     {
         testcase << "RPCCall API version " << apiVersion;
         if (!BEAST_EXPECT(
-                apiVersion >= RPC::kApiMinimumSupportedVersion &&
-                apiVersion <= RPC::kApiMaximumValidVersion))
+                apiVersion >= rpc::kApiMinimumSupportedVersion &&
+                apiVersion <= rpc::kApiMaximumValidVersion))
             return;
 
         test::jtx::Env const env(*this, makeNetworkConfig(11111));  // Used only for its Journal.
@@ -5870,8 +5876,8 @@ public:
             std::vector const args{rpcCallTest.args.begin(), rpcCallTest.args.end()};
 
             char const* const expVersioned =
-                (apiVersion - RPC::kApiMinimumSupportedVersion) < rpcCallTest.exp.size()
-                ? rpcCallTest.exp[apiVersion - RPC::kApiMinimumSupportedVersion]
+                (apiVersion - rpc::kApiMinimumSupportedVersion) < rpcCallTest.exp.size()
+                ? rpcCallTest.exp[apiVersion - rpc::kApiMinimumSupportedVersion]
                 : rpcCallTest.exp.back();
 
             // Note that, over the long term, kNone of these tests should
@@ -5923,10 +5929,67 @@ public:
         }
     }
 
+    // The command-line table and the dispatch table must agree.
+    //
+    // Forwards: every name the command line accepts must reach a handler at the
+    // version the command-line client requests. Presence in the dispatch table is
+    // not enough: a handler whose API range excludes kApiCommandLineVersion parses
+    // the command and then answers RpcUnknownCommand.
+    //
+    // Backwards: a handler that claims a command-line form must have one, and
+    // one that denies it must not, so that Handler::hasCommandLineForm cannot go
+    // stale.
+    //
+    // Three command-line names are exempt from the forward check because they
+    // are wrappers that forward a caller-supplied method rather than naming one
+    // themselves, so they have no handler of their own.
+    void
+    testCommandLineTableMatchesHandlers()
+    {
+        testcase("Command-line and dispatch tables agree");
+
+        static constexpr std::array kWrappers{
+            rpc::method::kInternal, rpc::method::kJson, rpc::method::kJson2};
+
+        auto const commandLine = commandLineMethodNames();
+        auto const handlers = rpc::getHandlerNames();
+        BEAST_EXPECT(!commandLine.empty());
+        BEAST_EXPECT(!handlers.empty());
+
+        // The command-line client always requests this version, so this is the
+        // only version at which its commands have to be dispatchable. Beta
+        // methods are off: a command must work against a stock server.
+        auto const handlerFor = [](std::string_view name) {
+            return rpc::getHandler(rpc::kApiCommandLineVersion, false, name);
+        };
+
+        for (auto const& name : commandLine)
+        {
+            if (std::ranges::find(kWrappers, name) != kWrappers.end())
+                continue;
+
+            auto const* handler = handlerFor(name);
+            if (BEAST_EXPECTS(handler != nullptr, std::string{name}))
+                BEAST_EXPECTS(handler->hasCommandLineForm, std::string{name});
+        }
+
+        for (auto const& name : handlers)
+        {
+            auto const* handler = handlerFor(name);
+            bool const claimsCommandLine = handler != nullptr && handler->hasCommandLineForm;
+
+            // Both name lists are sorted, so a binary search suffices.
+            BEAST_EXPECTS(
+                claimsCommandLine == std::ranges::binary_search(commandLine, name.view()),
+                std::string{name});
+        }
+    }
+
     void
     run() override
     {
         forAllApiVersions([this](unsigned apiVersion) { testRPCCall(apiVersion); });
+        testCommandLineTableMatchesHandlers();
     }
 };
 
diff --git a/src/test/rpc/RPCHelpers_test.cpp b/src/test/rpc/RPCHelpers_test.cpp
index 1458c0aa80..25368235a3 100644
--- a/src/test/rpc/RPCHelpers_test.cpp
+++ b/src/test/rpc/RPCHelpers_test.cpp
@@ -19,50 +19,50 @@ public:
 
         // Test no type.
         json::Value tx = json::ValueType::Object;
-        auto result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status::kOK);
+        auto result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status::kOK);
         BEAST_EXPECT(result.second == 0);
 
         // Test empty type.
         tx[jss::type] = "";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status{RpcInvalidParams});
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status{RpcInvalidParams});
         BEAST_EXPECT(result.second == 0);
 
         // Test type using canonical name in mixedcase.
         tx[jss::type] = "MPTokenIssuance";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status::kOK);
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status::kOK);
         BEAST_EXPECT(result.second == ltMPTOKEN_ISSUANCE);
 
         // Test type using canonical name in lowercase.
         tx[jss::type] = "mptokenissuance";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status::kOK);
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status::kOK);
         BEAST_EXPECT(result.second == ltMPTOKEN_ISSUANCE);
 
         // Test type using RPC name with exact match.
         tx[jss::type] = "mpt_issuance";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status::kOK);
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status::kOK);
         BEAST_EXPECT(result.second == ltMPTOKEN_ISSUANCE);
 
         // Test type using RPC name with inexact match.
         tx[jss::type] = "MPT_Issuance";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status{RpcInvalidParams});
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status{RpcInvalidParams});
         BEAST_EXPECT(result.second == 0);
 
         // Test invalid type.
         tx[jss::type] = 1234;
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status{RpcInvalidParams});
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status{RpcInvalidParams});
         BEAST_EXPECT(result.second == 0);
 
         // Test unknown type.
         tx[jss::type] = "unknown";
-        result = RPC::chooseLedgerEntryType(tx);
-        BEAST_EXPECT(result.first == RPC::Status{RpcInvalidParams});
+        result = rpc::chooseLedgerEntryType(tx);
+        BEAST_EXPECT(result.first == rpc::Status{RpcInvalidParams});
         BEAST_EXPECT(result.second == 0);
     }
 
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/rpc/Status_test.cpp b/src/test/rpc/Status_test.cpp
index c4f8544980..aaf696e9af 100644
--- a/src/test/rpc/Status_test.cpp
+++ b/src/test/rpc/Status_test.cpp
@@ -12,7 +12,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 class codeString_test : public beast::unit_test::Suite
 {
@@ -202,6 +202,6 @@ public:
     }
 };
 
-BEAST_DEFINE_TESTSUITE(fillJson, rpc, RPC);
+BEAST_DEFINE_TESTSUITE(fillJson, rpc, xrpl);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp
index 97c5290947..47c2245fa5 100644
--- a/src/test/rpc/Subscribe_test.cpp
+++ b/src/test/rpc/Subscribe_test.cpp
@@ -27,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -34,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -1452,12 +1454,14 @@ public:
             // Alice creates one sell offer for each NFT
             // Verify the offer indexes are correct in the NFTokenCreateOffer tx
             // meta
-            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -1471,7 +1475,8 @@ public:
 
             // Bobs creates a buy offer for nftId1
             // Verify the offer id is correct in the NFTokenCreateOffer tx meta
-            auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            auto const bobBuyOfferIndex =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(bobBuyOfferIndex);
@@ -1492,7 +1497,8 @@ public:
             verifyNFTokenID(nftId);
 
             // Alice creates sell offer and set broker as destination
-            uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const offerAliceToBroker =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)),
                 token::Destination(broker),
                 Txflags(tfSellNFToken));
@@ -1500,7 +1506,8 @@ public:
             verifyNFTokenOfferID(offerAliceToBroker);
 
             // Bob creates buy offer
-            uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key;
+            uint256 const offerBobToBroker =
+                keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key;
             env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(offerBobToBroker);
@@ -1521,12 +1528,14 @@ public:
             verifyNFTokenID(nftId);
 
             // Alice creates 2 sell offers for the same NFT
-            uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex1 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex1);
 
-            uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key;
+            uint256 const aliceOfferIndex2 =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceOfferIndex2);
@@ -1541,13 +1550,420 @@ public:
         if (features[featureNFTokenMintOffer])
         {
             uint256 const aliceMintWithOfferIndex1 =
-                keylet::nftokenOffer(alice, env.seq(alice)).key;
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
             env(token::mint(alice), token::Amount(XRP(0)));
             BEAST_EXPECT(env.syncClose());
             verifyNFTokenOfferID(aliceMintWithOfferIndex1);
         }
     }
 
+    // ----- Subscription limit / teardown verification ----------------------
+    //
+    // The helpers and tests below exercise:
+    //   * the per-connection subscription cap + proportional charge enforced
+    //     in doSubscribe (Subscribe.cpp), and
+    //   * the asynchronous, chunked teardown of a disconnecting connection's
+    //     account subscriptions (~InfoSub -> scheduleAccountCleanup -> JobQueue).
+    //
+    // The cap-exceeded error is rpcINVALID_PARAMS with the message "Too many
+    // subscriptions for this connection."; the tests assert that exactly.
+    //
+    // There is no public accessor for the server-side per-connection count, so
+    // the async cleanup is verified behaviorally: publishing still flows to a
+    // live subscriber, rather than by reading a count to zero.
+
+    // Build `count` distinct, valid, base58-encoded account strings cheaply by
+    // incrementing an AccountID. parseAccountIds dedups into a hash_set, so the
+    // strings MUST be distinct for the cap arithmetic to be exact; incrementing
+    // guarantees distinctness without deriving `count` keypairs.
+    static std::vector
+    makeAccountStrings(std::size_t count, std::uint32_t seed = 1)
+    {
+        std::vector out;
+        out.reserve(count);
+        // Start at `seed` so separate calls produce non-overlapping ranges,
+        // letting a test subscribe disjoint batches across requests.
+        AccountID id{static_cast(seed)};
+        for (std::size_t i = 0; i < count; ++i)
+        {
+            out.push_back(toBase58(id));
+            ++id;
+        }
+        return out;
+    }
+
+    // Append the given account strings as a jss::accounts array onto a fresh
+    // subscribe request object.
+    static json::Value
+    accountsRequest(std::vector const& accts)
+    {
+        json::Value jv{json::ValueType::Object};
+        jv[jss::accounts] = json::ValueType::Array;
+        for (auto const& a : accts)
+            jv[jss::accounts].append(a);
+        return jv;
+    }
+
+    // Append the given account strings as a jss::accounts_proposed array onto a
+    // fresh subscribe request object.
+    static json::Value
+    accountsProposedRequest(std::vector const& accts)
+    {
+        json::Value jv{json::ValueType::Object};
+        jv[jss::accounts_proposed] = json::ValueType::Array;
+        for (auto const& a : accts)
+            jv[jss::accounts_proposed].append(a);
+        return jv;
+    }
+
+    // A single, valid XRP/USD order book request, as one entry of a
+    // jss::books array.
+    static json::Value
+    oneBookRequest()
+    {
+        using namespace jtx;
+        json::Value jv{json::ValueType::Object};
+        jv[jss::books] = json::ValueType::Array;
+        json::Value& book = jv[jss::books][0u];
+        book[jss::taker_gets] = json::ValueType::Object;
+        book[jss::taker_gets][jss::currency] = "XRP";
+        book[jss::taker_pays] = json::ValueType::Object;
+        book[jss::taker_pays][jss::currency] = "USD";
+        book[jss::taker_pays][jss::issuer] = Account("alice").human();
+        return jv;
+    }
+
+    // A single account_history_tx_stream subscribe request for `acct`.
+    static json::Value
+    accountHistoryRequest(std::string const& acct)
+    {
+        json::Value jv{json::ValueType::Object};
+        jv[jss::account_history_tx_stream] = json::ValueType::Object;
+        jv[jss::account_history_tx_stream][jss::account] = acct;
+        return jv;
+    }
+
+    // An envconfig modifier that lowers the per-connection subscription cap to
+    // `cap`, so the cap logic in doSubscribe can be driven without subscribing
+    // the production default (100'000) entries. (Env is non-movable, so this
+    // returns the config modifier rather than a ready-made Env.)
+    static auto
+    cappedConfig(std::size_t cap)
+    {
+        return [cap](std::unique_ptr cfg) {
+            cfg->maxSubscriptionsPerConnection = cap;
+            return jtx::singleThreadIo(std::move(cfg));
+        };
+    }
+
+    void
+    testSubscriptionCapRejects()
+    {
+        // A request that alone exceeds the cap is rejected with the exact
+        // cap error, before any state is recorded. Baseline negative path.
+        testcase("subscription cap rejects an over-cap request");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(5))};
+        auto wsc = makeWSClient(env.app().config());
+
+        // Six accounts against a cap of five: rejected.
+        auto const jr =
+            wsc->invoke("subscribe", accountsRequest(makeAccountStrings(6)))[jss::result];
+        BEAST_EXPECT(jr[jss::error] == "invalidParams");
+        BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
+    }
+
+    void
+    testReSubscribeNotOvercounted()
+    {
+        // Re-subscribing accounts already held by this connection adds no new
+        // tracked state, so it must be admitted even at the cap. The cap check
+        // must count only NET-NEW accounts, not the raw request size.
+        testcase("re-subscribe at the cap is not over-counted");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(5))};
+        auto wsc = makeWSClient(env.app().config());
+
+        // Fill the cap exactly with five distinct accounts.
+        auto const five = makeAccountStrings(5);
+        {
+            auto const r = wsc->invoke("subscribe", accountsRequest(five));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // Re-subscribe the same five: net-new is zero, so it stays within the
+        // cap and must succeed. (Pre-fix this was wrongly rejected.)
+        {
+            auto const r = wsc->invoke("subscribe", accountsRequest(five));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+    }
+
+    void
+    testBooksCapIndependentOfAccounts()
+    {
+        // Book subscriptions are tracked separately (OrderBookDB) and are not
+        // part of totalSubscriptionCount(). An account set at the cap must not
+        // block an unrelated book subscription.
+        testcase("books cap is independent of account count");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(5))};
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        BEAST_EXPECT(env.syncClose());
+
+        auto wsc = makeWSClient(env.app().config());
+
+        // Fill the account cap exactly.
+        {
+            auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(5)));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // A single book subscription must still be admitted: it does not count
+        // against the account cap. (Pre-fix this was wrongly rejected.)
+        {
+            auto const r = wsc->invoke("subscribe", oneBookRequest());
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+    }
+
+    void
+    testMultiFieldNoPartialSubscribe()
+    {
+        // A single request mixing fields must be all-or-nothing: if a later
+        // field trips the cap, an earlier field must NOT have subscribed. The
+        // leak is detected through the cap arithmetic itself - a follow-up
+        // request succeeds only if no state leaked from the rejected one.
+        testcase("multi-field subscribe does not partially subscribe");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(5))};
+        auto wsc = makeWSClient(env.app().config());
+
+        // accounts_proposed (3, evaluated first, would subscribe) +
+        // accounts (3): combined 6 exceeds the cap of 5, so the request is
+        // rejected. The proposed branch must not have leaked its 3 entries.
+        json::Value req = accountsProposedRequest(makeAccountStrings(3, 1));
+        for (auto const& a : makeAccountStrings(3, 100))
+            req[jss::accounts].append(a);
+        {
+            auto const jr = wsc->invoke("subscribe", req)[jss::result];
+            BEAST_EXPECT(jr[jss::error] == "invalidParams");
+            BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
+        }
+
+        // If the rejected request leaked its 3 proposed subscriptions, the
+        // connection's count is already 3 and this 3-account request would be
+        // rejected (3 + 3 > 5). With no leak the count is 0 and it succeeds.
+        {
+            auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3, 200)));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+    }
+
+    void
+    testHistoryReSubscribeNotOvercounted()
+    {
+        // An account_history_tx_stream subscribe is charged against the cap only
+        // when it is net-new, matching the account branches. Re-subscribing an
+        // account-history already held on this connection adds no tracked entry,
+        // so it must NOT be rejected at the cap. The two rejection causes are
+        // told apart by their exact error_message: the cap check yields "Too
+        // many subscriptions for this connection."; a duplicate that gets past
+        // the cap and is rejected downstream by subAccountHistory yields the
+        // generic "Invalid parameters.".
+        testcase("account_history re-subscribe at the cap is not over-counted");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(1))};
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        BEAST_EXPECT(env.syncClose());
+
+        auto wsc = makeWSClient(env.app().config());
+
+        // First account-history subscribe is net-new: charge 1 fills the cap of
+        // 1 exactly, so it is admitted. Positive path.
+        {
+            auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // Re-subscribe the same account-history while sitting exactly at the
+        // cap. Net-new is zero, so the cap check must pass; the request is then
+        // rejected by subAccountHistory as a duplicate, NOT by the cap. Proven
+        // by the exact message: it is the duplicate error, not the cap error.
+        // (Pre-fix, the flat charge of 1 made the cap check reject this with the
+        // cap message instead.)
+        {
+            auto const jr =
+                wsc->invoke("subscribe", accountHistoryRequest(alice.human()))[jss::result];
+            BEAST_EXPECT(jr[jss::error] == "invalidParams");
+            BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters.");
+            BEAST_EXPECT(jr[jss::error_message] != "Too many subscriptions for this connection.");
+        }
+    }
+
+    void
+    testHistoryCapRejectsNetNew()
+    {
+        // A genuinely net-new account-history subscribe on a connection already
+        // at the cap IS rejected, with the cap error. Negative path, and the
+        // counterpart to testHistoryReSubscribeNotOvercounted: it confirms the
+        // net-new charge still rejects when the entry really is new.
+        testcase("account_history net-new subscribe is rejected at the cap");
+
+        using namespace jtx;
+        Env env{*this, envconfig(cappedConfig(1))};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        env.fund(XRP(10000), alice, bob);
+        BEAST_EXPECT(env.syncClose());
+
+        auto wsc = makeWSClient(env.app().config());
+
+        // Fill the cap of 1 with alice's account-history.
+        {
+            auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human()));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // A different account-history (bob) is net-new: charge 1 over a cap of 1
+        // already full, so it is rejected with the cap error.
+        {
+            auto const jr =
+                wsc->invoke("subscribe", accountHistoryRequest(bob.human()))[jss::result];
+            BEAST_EXPECT(jr[jss::error] == "invalidParams");
+            BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection.");
+        }
+    }
+
+    void
+    testAsyncTeardownDoesNotStall()
+    {
+        // Test C (core regression): disconnecting a connection with many
+        // account subscriptions must NOT block subsequent operations or
+        // publishing. The teardown is now posted to a JobQueue job
+        // (scheduleAccountCleanup), so it runs off the disconnect thread.
+        testcase("async teardown does not stall publishing");
+
+        using namespace std::chrono_literals;
+        using namespace jtx;
+        Env env{*this, singleThreadIo(envconfig())};
+
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        BEAST_EXPECT(env.syncClose());
+
+        // A second, long-lived subscriber to alice that must keep receiving
+        // publishes after the first connection disconnects.
+        auto wscLive = makeWSClient(env.app().config());
+        {
+            json::Value jv{json::ValueType::Object};
+            jv[jss::accounts] = json::ValueType::Array;
+            jv[jss::accounts].append(alice.human());
+            auto const r = wscLive->invoke("subscribe", jv);
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // A connection that subscribes to many accounts, then disconnects. A
+        // few thousand entries is enough to be a real teardown while still
+        // running fast in CI.
+        constexpr std::size_t kBulk = 3000;
+        {
+            auto wscBulk = makeWSClient(env.app().config());
+            auto const r =
+                wscBulk->invoke("subscribe", accountsRequest(makeAccountStrings(kBulk, 10)));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+            // Destroying the client closes the WS connection, which destroys
+            // the server-side InfoSub and posts the chunked async cleanup job.
+            // WSClient exposes no explicit close(); resetting the owning
+            // unique_ptr is the disconnect path.
+            wscBulk.reset();
+        }
+
+        // Immediately after the disconnect, an unrelated operation completes
+        // promptly (it would block for seconds with inline teardown). This is a
+        // cheap liveness check; the publish assertion below is the real proof.
+        {
+            auto const info = env.app().getOPs().getServerInfo(false, true, false);
+            BEAST_EXPECT(info.isMember(jss::server_state));
+        }
+
+        // The live subscriber still receives a published transaction for alice
+        // within a short timeout, proving account-publishing was not stalled by
+        // the concurrent teardown.
+        {
+            env(pay(env.master, alice, XRP(100)));
+            BEAST_EXPECT(env.syncClose());
+            BEAST_EXPECT(wscLive->findMsg(5s, [&](auto const& jv) {
+                return jv.isMember(jss::transaction) &&
+                    jv[jss::transaction][jss::TransactionType] == jss::Payment &&
+                    jv[jss::transaction][jss::Destination] == alice.human();
+            }));
+        }
+
+        wscLive->invoke("unsubscribe", accountsRequest({alice.human()}));
+    }
+
+    void
+    testResubscribeAfterDisconnect()
+    {
+        // Test D (Phase 3 correctness): connection A subscribes to account X
+        // and disconnects (async cleanup pending, keyed on A's seq). A new
+        // connection B subscribes to X and MUST still receive publishes for X -
+        // A's deferred, seq-keyed cleanup must not remove B's subscription.
+        testcase("re-subscribe after disconnect still delivers");
+
+        using namespace std::chrono_literals;
+        using namespace jtx;
+        Env env{*this, singleThreadIo(envconfig())};
+
+        Account const alice{"alice"};
+        env.fund(XRP(10000), alice);
+        BEAST_EXPECT(env.syncClose());
+
+        // Connection A subscribes to alice, then disconnects. A also subscribes
+        // to a bulk set so its deferred cleanup is non-trivial and races with B.
+        {
+            auto wscA = makeWSClient(env.app().config());
+            auto bulk = makeAccountStrings(2000, 10);
+            bulk.push_back(alice.human());
+            auto const r = wscA->invoke("subscribe", accountsRequest(bulk));
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+            // Disconnect A by destroying its client (no explicit close()).
+            wscA.reset();
+        }
+
+        // Connection B (a new InfoSub with a distinct seq) subscribes to alice.
+        auto wscB = makeWSClient(env.app().config());
+        {
+            json::Value jv{json::ValueType::Object};
+            jv[jss::accounts] = json::ValueType::Array;
+            jv[jss::accounts].append(alice.human());
+            auto const r = wscB->invoke("subscribe", jv);
+            BEAST_EXPECTS(r[jss::status] == "success", to_string(r));
+        }
+
+        // A publish for alice must reach B. If A's seq-keyed cleanup had wrongly
+        // removed the shared alice entry, B would receive nothing.
+        {
+            env(pay(env.master, alice, XRP(100)));
+            BEAST_EXPECT(env.syncClose());
+            BEAST_EXPECT(wscB->findMsg(5s, [&](auto const& jv) {
+                return jv.isMember(jss::transaction) &&
+                    jv[jss::transaction][jss::TransactionType] == jss::Payment &&
+                    jv[jss::transaction][jss::Destination] == alice.human();
+            }));
+        }
+
+        wscB->invoke("unsubscribe", accountsRequest({alice.human()}));
+    }
+
     void
     run() override
     {
@@ -1569,6 +1985,14 @@ public:
         testSubBookChanges();
         testNFToken(all);
         testNFToken(all - featureNFTokenMintOffer);
+        testAsyncTeardownDoesNotStall();
+        testResubscribeAfterDisconnect();
+        testSubscriptionCapRejects();
+        testReSubscribeNotOvercounted();
+        testBooksCapIndependentOfAccounts();
+        testMultiFieldNoPartialSubscribe();
+        testHistoryReSubscribeNotOvercounted();
+        testHistoryCapRejectsNetNew();
     }
 };
 
diff --git a/src/test/rpc/TransactionEntry_test.cpp b/src/test/rpc/TransactionEntry_test.cpp
index 38a95e84f8..b57c615b71 100644
--- a/src/test/rpc/TransactionEntry_test.cpp
+++ b/src/test/rpc/TransactionEntry_test.cpp
@@ -183,7 +183,7 @@ class TransactionEntry_test : public beast::unit_test::Suite
             {
                 json::Value expected;
                 json::Reader().parse(expectedJson, expected);
-                if (RPC::containsError(expected))
+                if (rpc::containsError(expected))
                     Throw("Internal JSONRPC_test error.  Bad test JSON.");
 
                 for (auto memberIt = expected.begin(); memberIt != expected.end(); memberIt++)
diff --git a/src/test/rpc/Transaction_test.cpp b/src/test/rpc/Transaction_test.cpp
index 4dae475b63..a65dba1d9c 100644
--- a/src/test/rpc/Transaction_test.cpp
+++ b/src/test/rpc/Transaction_test.cpp
@@ -62,9 +62,9 @@ class Transaction_test : public beast::unit_test::Suite
 
         char const* command = jss::tx.cStr();
         char const* binary = jss::binary.cStr();
-        char const* notFound = RPC::getErrorInfo(RpcTxnNotFound).token;
-        char const* invalid = RPC::getErrorInfo(RpcInvalidLgrRange).token;
-        char const* excessive = RPC::getErrorInfo(RpcExcessiveLgrRange).token;
+        char const* notFound = rpc::getErrorInfo(RpcTxnNotFound).token;
+        char const* invalid = rpc::getErrorInfo(RpcInvalidLgrRange).token;
+        char const* excessive = rpc::getErrorInfo(RpcExcessiveLgrRange).token;
 
         Env env{*this, features};
         auto const alice = Account("alice");
@@ -301,9 +301,9 @@ class Transaction_test : public beast::unit_test::Suite
 
         char const* command = jss::tx.cStr();
         char const* binary = jss::binary.cStr();
-        char const* notFound = RPC::getErrorInfo(RpcTxnNotFound).token;
-        char const* invalid = RPC::getErrorInfo(RpcInvalidLgrRange).token;
-        char const* excessive = RPC::getErrorInfo(RpcExcessiveLgrRange).token;
+        char const* notFound = rpc::getErrorInfo(RpcTxnNotFound).token;
+        char const* invalid = rpc::getErrorInfo(RpcInvalidLgrRange).token;
+        char const* excessive = rpc::getErrorInfo(RpcExcessiveLgrRange).token;
 
         Env env{*this, makeNetworkConfig(11111)};
         uint32_t const netID = env.app().getNetworkIDService().getNetworkID();
@@ -333,7 +333,7 @@ class Transaction_test : public beast::unit_test::Suite
             auto const result = env.rpc(
                 command,
                 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                *RPC::encodeCTID(startLegSeq + i, txnIdx, netID),
+                *rpc::encodeCTID(startLegSeq + i, txnIdx, netID),
                 binary,
                 to_string(startLegSeq),
                 to_string(endLegSeq));
@@ -345,7 +345,7 @@ class Transaction_test : public beast::unit_test::Suite
 
         auto const tx = env.jt(noop(alice), Seq(env.seq(alice))).stx;
         // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-        auto const ctid = *RPC::encodeCTID(endLegSeq, tx->getSeqValue(), netID);
+        auto const ctid = *rpc::encodeCTID(endLegSeq, tx->getSeqProxy().value(), netID);
         for (int deltaEndSeq = 0; deltaEndSeq < 2; ++deltaEndSeq)
         {
             auto const result = env.rpc(
@@ -374,7 +374,7 @@ class Transaction_test : public beast::unit_test::Suite
             auto const result = env.rpc(
                 command,
                 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                *RPC::encodeCTID(startLegSeq + i, txnIdx, netID),
+                *rpc::encodeCTID(startLegSeq + i, txnIdx, netID),
                 binary,
                 to_string(endLegSeq + 1),
                 to_string(endLegSeq + 100));
@@ -434,7 +434,7 @@ class Transaction_test : public beast::unit_test::Suite
             auto const result = env.rpc(
                 command,
                 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-                *RPC::encodeCTID(endLegSeq, txnIdx, netID),
+                *rpc::encodeCTID(endLegSeq, txnIdx, netID),
                 to_string(startLegSeq),
                 to_string(deletedLedger - 1));
 
@@ -527,75 +527,75 @@ class Transaction_test : public beast::unit_test::Suite
 
         // Test case 1: Valid input values
         auto const expected11 = std::optional("CFFFFFFFFFFFFFFF");
-        BEAST_EXPECT(RPC::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, 0xFFFFU) == expected11);
+        BEAST_EXPECT(rpc::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, 0xFFFFU) == expected11);
         auto const expected12 = std::optional("C000000000000000");
-        BEAST_EXPECT(RPC::encodeCTID(0, 0, 0) == expected12);
+        BEAST_EXPECT(rpc::encodeCTID(0, 0, 0) == expected12);
         auto const expected13 = std::optional("C000000100020003");
-        BEAST_EXPECT(RPC::encodeCTID(1U, 2U, 3U) == expected13);
+        BEAST_EXPECT(rpc::encodeCTID(1U, 2U, 3U) == expected13);
         auto const expected14 = std::optional("C0CA2AA7326FFFFF");
-        BEAST_EXPECT(RPC::encodeCTID(13249191UL, 12911U, 65535U) == expected14);
+        BEAST_EXPECT(rpc::encodeCTID(13249191UL, 12911U, 65535U) == expected14);
 
         // Test case 2: ledger_seq greater than 0xFFFFFFF
-        BEAST_EXPECT(!RPC::encodeCTID(0x1000'0000UL, 0xFFFFU, 0xFFFFU));
+        BEAST_EXPECT(!rpc::encodeCTID(0x1000'0000UL, 0xFFFFU, 0xFFFFU));
 
         // Test case 3: txn_index greater than 0xFFFF
-        BEAST_EXPECT(!RPC::encodeCTID(0x0FFF'FFFF, 0x1'0000, 0xFFFF));
+        BEAST_EXPECT(!rpc::encodeCTID(0x0FFF'FFFF, 0x1'0000, 0xFFFF));
 
         // Test case 4: network_id greater than 0xFFFF
-        BEAST_EXPECT(!RPC::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, 0x1'0000U));
+        BEAST_EXPECT(!rpc::encodeCTID(0x0FFF'FFFFUL, 0xFFFFU, 0x1'0000U));
 
         // Test case 5: Valid input values
         auto const expected51 =
             std::optional>(std::make_tuple(0, 0, 0));
-        BEAST_EXPECT(RPC::decodeCTID("C000000000000000") == expected51);
+        BEAST_EXPECT(rpc::decodeCTID("C000000000000000") == expected51);
         auto const expected52 =
             std::optional>(std::make_tuple(1U, 2U, 3U));
-        BEAST_EXPECT(RPC::decodeCTID("C000000100020003") == expected52);
+        BEAST_EXPECT(rpc::decodeCTID("C000000100020003") == expected52);
         auto const expected53 = std::optional>(
             std::make_tuple(13249191UL, 12911U, 49221U));
-        BEAST_EXPECT(RPC::decodeCTID("C0CA2AA7326FC045") == expected53);
+        BEAST_EXPECT(rpc::decodeCTID("C0CA2AA7326FC045") == expected53);
 
         // Test case 6: ctid not a string or big int
-        BEAST_EXPECT(!RPC::decodeCTID(0xCFF));
+        BEAST_EXPECT(!rpc::decodeCTID(0xCFF));
 
         // Test case 7: ctid not a hexadecimal string
-        BEAST_EXPECT(!RPC::decodeCTID("C003FFFFFFFFFFFG"));
+        BEAST_EXPECT(!rpc::decodeCTID("C003FFFFFFFFFFFG"));
 
         // Test case 8: ctid not exactly 16 nibbles
-        BEAST_EXPECT(!RPC::decodeCTID("C003FFFFFFFFFFF"));
+        BEAST_EXPECT(!rpc::decodeCTID("C003FFFFFFFFFFF"));
 
         // Test case 9: ctid too large to be a valid CTID value
-        BEAST_EXPECT(!RPC::decodeCTID("CFFFFFFFFFFFFFFFF"));
+        BEAST_EXPECT(!rpc::decodeCTID("CFFFFFFFFFFFFFFFF"));
 
         // Test case 10: ctid doesn't start with a C nibble
-        BEAST_EXPECT(!RPC::decodeCTID("FFFFFFFFFFFFFFFF"));
+        BEAST_EXPECT(!rpc::decodeCTID("FFFFFFFFFFFFFFFF"));
 
         // Test case 11: Valid input values
         BEAST_EXPECT(
-            (RPC::decodeCTID(0xCFFF'FFFF'FFFF'FFFFULL) ==
+            (rpc::decodeCTID(0xCFFF'FFFF'FFFF'FFFFULL) ==
              std::optional>(
                  std::make_tuple(0x0FFF'FFFFUL, 0xFFFFU, 0xFFFFU))));
         BEAST_EXPECT(
-            (RPC::decodeCTID(0xC000'0000'0000'0000ULL) ==
+            (rpc::decodeCTID(0xC000'0000'0000'0000ULL) ==
              std::optional>(std::make_tuple(0, 0, 0))));
         BEAST_EXPECT(
-            (RPC::decodeCTID(0xC000'0001'0002'0003ULL) ==
+            (rpc::decodeCTID(0xC000'0001'0002'0003ULL) ==
              std::optional>(std::make_tuple(1U, 2U, 3U))));
         BEAST_EXPECT(
-            (RPC::decodeCTID(0xC0CA'2AA7'326F'C045ULL) ==
+            (rpc::decodeCTID(0xC0CA'2AA7'326F'C045ULL) ==
              std::optional>(
                  std::make_tuple(1324'9191UL, 12911U, 49221U))));
 
         // Test case 12: ctid not exactly 16 nibbles
-        BEAST_EXPECT(!RPC::decodeCTID(0xC003'FFFF'FFFF'FFF));
+        BEAST_EXPECT(!rpc::decodeCTID(0xC003'FFFF'FFFF'FFF));
 
         // Test case 13: ctid too large to be a valid CTID value
         // this test case is not possible in c++ because it would overflow the
         // type, left in for completeness
-        // BEAST_EXPECT(!RPC::decodeCTID(0xCFFFFFFFFFFFFFFFFULL));
+        // BEAST_EXPECT(!rpc::decodeCTID(0xCFFFFFFFFFFFFFFFFULL));
 
         // Test case 14: ctid doesn't start with a C nibble
-        BEAST_EXPECT(!RPC::decodeCTID(0xFFFF'FFFF'FFFF'FFFFULL));
+        BEAST_EXPECT(!rpc::decodeCTID(0xFFFF'FFFF'FFFF'FFFFULL));
     }
 
     void
@@ -619,7 +619,7 @@ class Transaction_test : public beast::unit_test::Suite
             env(pay(alice, bob, XRP(10)));
             env.close();
 
-            auto const ctid = RPC::encodeCTID(startLegSeq, 0, netID);
+            auto const ctid = rpc::encodeCTID(startLegSeq, 0, netID);
             if (netID > 0xFFFF)
             {
                 // Concise transaction IDs do not support a network ID > 0xFFFF.
@@ -650,7 +650,7 @@ class Transaction_test : public beast::unit_test::Suite
             env.close();
 
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            std::string const ctid = *RPC::encodeCTID(startLegSeq, 0, netID);
+            std::string const ctid = *rpc::encodeCTID(startLegSeq, 0, netID);
             auto isUpper = [](char c) { return std::isupper(c) != 0; };
 
             // Verify that there are at least two upper case letters in ctid and
@@ -705,7 +705,7 @@ class Transaction_test : public beast::unit_test::Suite
             BEAST_EXPECT(jrr.isMember(jss::ctid) == (netID <= 0xFFFF));
             if (jrr.isMember(jss::ctid))
             {
-                auto const ctid = RPC::encodeCTID(ledgerSeq, 0, netID);
+                auto const ctid = rpc::encodeCTID(ledgerSeq, 0, netID);
                 BEAST_EXPECT(
                     jrr[jss::ctid] == *ctid);  // NOLINT(bugprone-unchecked-optional-access)
             }
@@ -725,7 +725,7 @@ class Transaction_test : public beast::unit_test::Suite
             env.close();
 
             // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            auto const ctid = *RPC::encodeCTID(startLegSeq, 0, netID + 1);
+            auto const ctid = *rpc::encodeCTID(startLegSeq, 0, netID + 1);
             json::Value jsonTx;
             jsonTx[jss::binary] = false;
             jsonTx[jss::ctid] = ctid;
diff --git a/src/test/rpc/Version_test.cpp b/src/test/rpc/Version_test.cpp
index 71830c2219..b5c1abc160 100644
--- a/src/test/rpc/Version_test.cpp
+++ b/src/test/rpc/Version_test.cpp
@@ -32,7 +32,7 @@ class Version_test : public beast::unit_test::Suite
         auto jrr = env.rpc(
             "json",
             "version",
-            "{\"api_version\": " + std::to_string(RPC::kApiMaximumSupportedVersion) +
+            "{\"api_version\": " + std::to_string(rpc::kApiMaximumSupportedVersion) +
                 "}")[jss::result];
         BEAST_EXPECT(isCorrectReply(jrr));
 
@@ -62,7 +62,7 @@ class Version_test : public beast::unit_test::Suite
         auto re = env.rpc(
             "json",
             "version",
-            "{\"api_version\": " + std::to_string(RPC::kApiMinimumSupportedVersion - 1) + "}");
+            "{\"api_version\": " + std::to_string(rpc::kApiMinimumSupportedVersion - 1) + "}");
         BEAST_EXPECT(badVersion(re));
 
         BEAST_EXPECT(env.app().config().betaRpcApi);
@@ -71,7 +71,7 @@ class Version_test : public beast::unit_test::Suite
             "version",
             "{\"api_version\": " +
                 std::to_string(
-                    std::max(RPC::kApiMaximumSupportedVersion.value, RPC::kApiBetaVersion.value) +
+                    std::max(rpc::kApiMaximumSupportedVersion.value, rpc::kApiBetaVersion.value) +
                     1) +
                 "}");
         BEAST_EXPECT(badVersion(re));
@@ -86,38 +86,38 @@ class Version_test : public beast::unit_test::Suite
         testcase("test getAPIVersionNumber function");
 
         unsigned int const versionIfUnspecified =
-            RPC::kApiVersionIfUnspecified < RPC::kApiMinimumSupportedVersion
-            ? RPC::kApiInvalidVersion
-            : RPC::kApiVersionIfUnspecified;
+            rpc::kApiVersionIfUnspecified < rpc::kApiMinimumSupportedVersion
+            ? rpc::kApiInvalidVersion
+            : rpc::kApiVersionIfUnspecified;
 
         json::Value const jArray = json::Value(json::ValueType::Array);
         json::Value const jNull = json::Value(json::ValueType::Null);
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jArray, false) == versionIfUnspecified);
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jNull, false) == versionIfUnspecified);
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jArray, false) == versionIfUnspecified);
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jNull, false) == versionIfUnspecified);
 
         json::Value jObject = json::Value(json::ValueType::Object);
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == versionIfUnspecified);
-        jObject[jss::api_version] = RPC::kApiVersionIfUnspecified.value;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == versionIfUnspecified);
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == versionIfUnspecified);
+        jObject[jss::api_version] = rpc::kApiVersionIfUnspecified.value;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == versionIfUnspecified);
 
-        jObject[jss::api_version] = RPC::kApiMinimumSupportedVersion.value;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiMinimumSupportedVersion);
-        jObject[jss::api_version] = RPC::kApiMaximumSupportedVersion.value;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiMaximumSupportedVersion);
+        jObject[jss::api_version] = rpc::kApiMinimumSupportedVersion.value;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiMinimumSupportedVersion);
+        jObject[jss::api_version] = rpc::kApiMaximumSupportedVersion.value;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiMaximumSupportedVersion);
 
-        jObject[jss::api_version] = RPC::kApiMinimumSupportedVersion - 1;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiInvalidVersion);
-        jObject[jss::api_version] = RPC::kApiMaximumSupportedVersion + 1;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiInvalidVersion);
-        jObject[jss::api_version] = RPC::kApiBetaVersion.value;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, true) == RPC::kApiBetaVersion);
-        jObject[jss::api_version] = RPC::kApiBetaVersion + 1;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, true) == RPC::kApiInvalidVersion);
+        jObject[jss::api_version] = rpc::kApiMinimumSupportedVersion - 1;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiInvalidVersion);
+        jObject[jss::api_version] = rpc::kApiMaximumSupportedVersion + 1;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiInvalidVersion);
+        jObject[jss::api_version] = rpc::kApiBetaVersion.value;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, true) == rpc::kApiBetaVersion);
+        jObject[jss::api_version] = rpc::kApiBetaVersion + 1;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, true) == rpc::kApiInvalidVersion);
 
-        jObject[jss::api_version] = RPC::kApiInvalidVersion.value;
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiInvalidVersion);
+        jObject[jss::api_version] = rpc::kApiInvalidVersion.value;
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiInvalidVersion);
         jObject[jss::api_version] = "a";
-        BEAST_EXPECT(RPC::getAPIVersionNumber(jObject, false) == RPC::kApiInvalidVersion);
+        BEAST_EXPECT(rpc::getAPIVersionNumber(jObject, false) == rpc::kApiInvalidVersion);
     }
 
     void
@@ -141,7 +141,7 @@ class Version_test : public beast::unit_test::Suite
             "\"method\": \"version\", "
             "\"params\": { "
             "\"api_version\": " +
-            std::to_string(RPC::kApiMaximumSupportedVersion) + "}}";
+            std::to_string(rpc::kApiMaximumSupportedVersion) + "}}";
         auto re = env.rpc("json2", '[' + withoutApiVerion + ", " + withApiVerion + ']');
 
         if (!BEAST_EXPECT(re.isArray()))
@@ -176,7 +176,7 @@ class Version_test : public beast::unit_test::Suite
             "\"params\": { "
             "\"api_version\": " +
             std::to_string(
-                std::max(RPC::kApiMaximumSupportedVersion.value, RPC::kApiBetaVersion.value) + 1) +
+                std::max(rpc::kApiMaximumSupportedVersion.value, rpc::kApiBetaVersion.value) + 1) +
             "}}";
         auto re = env.rpc("json2", '[' + withoutApiVerion + ", " + withWrongApiVerion + ']');
 
@@ -226,15 +226,15 @@ class Version_test : public beast::unit_test::Suite
         auto jrr = env.rpc(
             "json",
             "version",
-            "{\"api_version\": " + std::to_string(RPC::kApiBetaVersion) + "}")[jss::result];
+            "{\"api_version\": " + std::to_string(rpc::kApiBetaVersion) + "}")[jss::result];
 
         if (!BEAST_EXPECT(jrr.isMember(jss::version)))
             return;
         if (!BEAST_EXPECT(jrr[jss::version].isMember(jss::first)) &&
             jrr[jss::version].isMember(jss::last))
             return;
-        BEAST_EXPECT(jrr[jss::version][jss::first] == RPC::kApiMinimumSupportedVersion.value);
-        BEAST_EXPECT(jrr[jss::version][jss::last] == RPC::kApiBetaVersion.value);
+        BEAST_EXPECT(jrr[jss::version][jss::first] == rpc::kApiMinimumSupportedVersion.value);
+        BEAST_EXPECT(jrr[jss::version][jss::last] == rpc::kApiBetaVersion.value);
     }
 
 public:
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 8e4ece1234..81f4abc126 100644
--- a/src/tests/libxrpl/CMakeLists.txt
+++ b/src/tests/libxrpl/CMakeLists.txt
@@ -27,19 +27,26 @@ target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl)
 # supported on Windows.
 set(test_modules
     basics
+    beast
     consensus
     crypto
     json
+    ledger
+    nodestore
     peerfinder
+    protocol
     resource
     shamap
     tx
     protocol_autogen
-    nodestore
+    server
 )
 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).
@@ -49,6 +56,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 e798cd1ccc..c6c9fcfef0 100644
--- a/src/tests/libxrpl/basics/IntrusiveShared.cpp
+++ b/src/tests/libxrpl/basics/IntrusiveShared.cpp
@@ -50,11 +50,11 @@ struct Barrier
 {
     std::mutex mtx;
     std::condition_variable cv;
-    int count;
-    int const initial;
+    std::size_t count;
+    std::size_t const initial;
     std::size_t generation{0};
 
-    explicit Barrier(int n) : count(n), initial(n)
+    explicit Barrier(std::size_t n) : count(n), initial(n)
     {
     }
 
@@ -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;
@@ -217,10 +219,8 @@ TEST(IntrusiveSharedTest, basics)
         auto id = b->id;
         EXPECT_EQ(TIBase::getState(id), Alive);
         EXPECT_EQ(b->useCount(), 1);
-        for (int i = 0; i < 10; ++i)
-        {
+        for (auto i = 0uz; i < 10; ++i)
             strong.push_back(b);
-        }
         b.reset();
         EXPECT_EQ(TIBase::getState(id), Alive);
         strong.resize(strong.size() - 1);
@@ -232,7 +232,7 @@ TEST(IntrusiveSharedTest, basics)
         id = b->id;
         EXPECT_EQ(TIBase::getState(id), Alive);
         EXPECT_EQ(b->useCount(), 1);
-        for (int i = 0; i < 10; ++i)
+        for (auto i = 0uz; i < 10; ++i)
         {
             weak.emplace_back(b);
             EXPECT_EQ(b->useCount(), 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);
             }
@@ -280,17 +279,17 @@ TEST(IntrusiveSharedTest, basics)
         TIBase::ResetStatesGuard const rsg{true};
 
         using enum TrackedState;
-        using swu = SharedWeakUnion;
-        swu b = makeSharedIntrusive();
+        using SharedWeak = SharedWeakUnion;
+        SharedWeak b = makeSharedIntrusive();
         EXPECT_TRUE(b.isStrong() && b.useCount() == 1);
         auto id = b.get()->id;
         EXPECT_EQ(TIBase::getState(id), Alive);
-        swu w = b;
+        SharedWeak w = b;
         EXPECT_TRUE(TIBase::getState(id) == Alive);
         EXPECT_TRUE(w.isStrong() && b.useCount() == 2);
         w.convertToWeak();
         EXPECT_TRUE(w.isWeak() && b.useCount() == 1);
-        swu s = w;
+        SharedWeak s = w;
         EXPECT_TRUE(s.isWeak() && b.useCount() == 1);
         s.convertToStrong();
         EXPECT_TRUE(s.isStrong() && b.useCount() == 2);
@@ -380,43 +379,57 @@ TEST(IntrusiveSharedTest, partial_delete)
     std::atomic destructorRan{false};
     std::atomic partialDeleteRan{false};
     std::latch partialDeleteStartedSyncPoint{2};
+
     strong->tracingCallback = [&](TrackedState cur, std::optional next) {
         using enum TrackedState;
-        if (next == DeletedStarted)
+        if (!next)
+            return;
+
+        switch (*next)
         {
-            // strong goes out of scope while weak is still in scope
-            // This checks that partialDelete has run to completion
-            // before the destructor is called. A sleep is inserted
-            // inside the partial delete to make sure the destructor is
-            // given an opportunity to run during partial delete.
-            EXPECT_EQ(cur, PartiallyDeleted);
-        }
-        if (next == PartiallyDeletedStarted)
-        {
-            partialDeleteStartedSyncPoint.arrive_and_wait();
-            using namespace std::chrono_literals;
-            // Sleep and let the weak pointer go out of scope,
-            // potentially triggering a destructor while partial delete
-            // is running. The test is to make sure that doesn't happen.
-            std::this_thread::sleep_for(800ms);
-        }
-        if (next == PartiallyDeleted)
-        {
-            EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load());
-        }
-        if (next == Deleted)
-        {
-            EXPECT_FALSE(destructorRan.exchange(true));
+            case DeletedStarted:
+                // strong goes out of scope while weak is still in scope
+                // This checks that partialDelete has run to completion
+                // before the destructor is called. A sleep is inserted
+                // inside the partial delete to make sure the destructor is
+                // given an opportunity to run during partial delete.
+                EXPECT_EQ(cur, PartiallyDeleted);
+                break;
+
+            case PartiallyDeletedStarted: {
+                partialDeleteStartedSyncPoint.arrive_and_wait();
+                using namespace std::chrono_literals;
+                // Sleep and let the weak pointer go out of scope,
+                // potentially triggering a destructor while partial delete
+                // is running. The test is to make sure that doesn't happen.
+                std::this_thread::sleep_for(800ms);
+                break;
+            }
+
+            case PartiallyDeleted:
+                EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load());
+                break;
+
+            case Deleted:
+                EXPECT_FALSE(destructorRan.exchange(true));
+                break;
+
+            case Uninitialized:
+            case Alive:
+                break;
         }
     };
+
     std::thread t1{[&] {
         partialDeleteStartedSyncPoint.arrive_and_wait();
         weak.reset();  // Trigger a full delete as soon as the partial
                        // delete starts
     }};
+
     std::thread t2{[&] {
         strong.reset();  // Trigger a partial delete
     }};
+
     t1.join();
     t2.join();
 
@@ -444,13 +457,24 @@ TEST(IntrusiveSharedTest, destructor)
     std::latch weakResetSyncPoint{2};
     strong->tracingCallback = [&](TrackedState cur, std::optional next) {
         using enum TrackedState;
-        if (next == PartiallyDeleted)
+        if (!next)
+            return;
+
+        switch (*next)
         {
-            EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load());
-        }
-        if (next == Deleted)
-        {
-            EXPECT_FALSE(destructorRan.exchange(true));
+            case PartiallyDeleted:
+                EXPECT_FALSE(partialDeleteRan.exchange(true) || destructorRan.load());
+                break;
+
+            case Deleted:
+                EXPECT_FALSE(destructorRan.exchange(true));
+                break;
+
+            case Uninitialized:
+            case Alive:
+            case PartiallyDeletedStarted:
+            case DeletedStarted:
+                break;
         }
     };
     std::thread t1{[&] {
@@ -492,25 +516,36 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant)
     auto tracingCallback = [&](TrackedState cur, std::optional next) {
         using enum TrackedState;
         auto [destructorRan, partialDeleteRan] = getDestructorState();
-        if (next == PartiallyDeleted)
+        if (!next)
+            return;
+
+        switch (*next)
         {
-            EXPECT_FALSE(partialDeleteRan || destructorRan);
-            setPartialDeleteRan();
-        }
-        if (next == Deleted)
-        {
-            EXPECT_FALSE(destructorRan);
-            setDestructorRan();
+            case PartiallyDeleted:
+                EXPECT_FALSE(partialDeleteRan || destructorRan);
+                setPartialDeleteRan();
+                break;
+
+            case Deleted:
+                EXPECT_FALSE(destructorRan);
+                setDestructorRan();
+                break;
+
+            case Uninitialized:
+            case Alive:
+            case PartiallyDeletedStarted:
+            case DeletedStarted:
+                break;
         }
     };
     auto createVecOfPointers = [&](auto const& toClone, std::default_random_engine& eng)
         -> std::vector, WeakIntrusive>> {
         std::vector, WeakIntrusive>> result;
-        std::uniform_int_distribution<> toCreateDist(4, 64);
+        std::uniform_int_distribution toCreateDist(4, 64);
         std::uniform_int_distribution<> isStrongDist(0, 1);
         auto numToCreate = toCreateDist(eng);
         result.reserve(numToCreate);
-        for (int i = 0; i < numToCreate; ++i)
+        for (auto i = 0uz; i < numToCreate; ++i)
         {
             if (isStrongDist(eng))
             {
@@ -523,8 +558,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant)
         }
         return result;
     };
-    constexpr int kLoopIters = 2 * 1024;
-    constexpr int kNumThreads = 16;
+    constexpr auto kLoopIters = 2uz * 1024;
+    constexpr auto kNumThreads = 16uz;
     std::vector> toClone;
     Barrier loopStartSyncPoint{kNumThreads};
     Barrier postCreateToCloneSyncPoint{kNumThreads};
@@ -533,7 +568,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant)
         std::random_device rd;
         std::vector result;
         result.reserve(kNumThreads);
-        for (int i = 0; i < kNumThreads; ++i)
+        for (auto i = 0uz; i < kNumThreads; ++i)
             result.emplace_back(rd());
         return result;
     }();
@@ -541,8 +576,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant)
     // cloneAndDestroy clones the strong pointer into a vector of mixed
     // strong and weak pointers and destroys them all at once.
     // threadId==0 is special.
-    auto cloneAndDestroy = [&](int threadId) {
-        for (int i = 0; i < kLoopIters; ++i)
+    auto cloneAndDestroy = [&](std::size_t threadId) {
+        for (auto i = 0uz; i < kLoopIters; ++i)
         {
             // ------ Sync Point ------
             loopStartSyncPoint.arriveAndWait();
@@ -582,11 +617,11 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_variant)
     };
     std::vector threads;
     threads.reserve(kNumThreads);
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads.emplace_back(cloneAndDestroy, i);
     }
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads[i].join();
     }
@@ -623,31 +658,42 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union)
     auto tracingCallback = [&](TrackedState cur, std::optional next) {
         using enum TrackedState;
         auto [destructorRan, partialDeleteRan] = getDestructorState();
-        if (next == PartiallyDeleted)
+        if (!next)
+            return;
+
+        switch (*next)
         {
-            EXPECT_FALSE(partialDeleteRan || destructorRan);
-            setPartialDeleteRan();
-        }
-        if (next == Deleted)
-        {
-            EXPECT_FALSE(destructorRan);
-            setDestructorRan();
+            case PartiallyDeleted:
+                EXPECT_FALSE(partialDeleteRan || destructorRan);
+                setPartialDeleteRan();
+                break;
+
+            case Deleted:
+                EXPECT_FALSE(destructorRan);
+                setDestructorRan();
+                break;
+
+            case Uninitialized:
+            case Alive:
+            case PartiallyDeletedStarted:
+            case DeletedStarted:
+                break;
         }
     };
     auto createVecOfPointers =
         [&](auto const& toClone,
             std::default_random_engine& eng) -> std::vector> {
         std::vector> result;
-        std::uniform_int_distribution<> toCreateDist(4, 64);
+        std::uniform_int_distribution toCreateDist(4, 64);
         auto numToCreate = toCreateDist(eng);
         result.reserve(numToCreate);
-        for (int i = 0; i < numToCreate; ++i)
+        for (auto i = 0uz; i < numToCreate; ++i)
             result.emplace_back(SharedIntrusive(toClone));
         return result;
     };
-    constexpr int kLoopIters = 2 * 1024;
-    constexpr int kFlipPointersLoopIters = 256;
-    constexpr int kNumThreads = 16;
+    constexpr auto kLoopIters = 2uz * 1024;
+    constexpr auto kFlipPointersLoopIters = 256uz;
+    constexpr auto kNumThreads = 16uz;
     std::vector> toClone;
     Barrier loopStartSyncPoint{kNumThreads};
     Barrier postCreateToCloneSyncPoint{kNumThreads};
@@ -657,7 +703,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union)
         std::random_device rd;
         std::vector result;
         result.reserve(kNumThreads);
-        for (int i = 0; i < kNumThreads; ++i)
+        for (auto i = 0uz; i < kNumThreads; ++i)
             result.emplace_back(rd());
         return result;
     }();
@@ -666,8 +712,8 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union)
     // mixed strong and weak pointers, runs a loop that randomly
     // changes strong pointers to weak pointers,  and destroys them
     // all at once.
-    auto cloneAndDestroy = [&](int threadId) {
-        for (int i = 0; i < kLoopIters; ++i)
+    auto cloneAndDestroy = [&](std::size_t threadId) {
+        for (auto i = 0uz; i < kLoopIters; ++i)
         {
             // ------ Sync Point ------
             loopStartSyncPoint.arriveAndWait();
@@ -702,7 +748,7 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union)
             postCreateVecOfPointersSyncPoint.arriveAndWait();
 
             std::uniform_int_distribution<> isStrongDist(0, 1);
-            for (int f = 0; f < kFlipPointersLoopIters; ++f)
+            for (auto f = 0uz; f < kFlipPointersLoopIters; ++f)
             {
                 for (auto& p : v)
                 {
@@ -725,11 +771,11 @@ TEST(IntrusiveSharedTest, multithreaded_clear_mixed_union)
     };
     std::vector threads;
     threads.reserve(kNumThreads);
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads.emplace_back(cloneAndDestroy, i);
     }
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads[i].join();
     }
@@ -761,21 +807,32 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak)
     auto tracingCallback = [&](TrackedState cur, std::optional next) {
         using enum TrackedState;
         auto [destructorRan, partialDeleteRan] = getDestructorState();
-        if (next == PartiallyDeleted)
+        if (!next)
+            return;
+
+        switch (*next)
         {
-            EXPECT_FALSE(partialDeleteRan || destructorRan);
-            setPartialDeleteRan();
-        }
-        if (next == Deleted)
-        {
-            EXPECT_FALSE(destructorRan);
-            setDestructorRan();
+            case PartiallyDeleted:
+                EXPECT_FALSE(partialDeleteRan || destructorRan);
+                setPartialDeleteRan();
+                break;
+
+            case Deleted:
+                EXPECT_FALSE(destructorRan);
+                setDestructorRan();
+                break;
+
+            case Uninitialized:
+            case Alive:
+            case PartiallyDeletedStarted:
+            case DeletedStarted:
+                break;
         }
     };
 
-    constexpr int kLoopIters = 2 * 1024;
-    constexpr int kLockWeakLoopIters = 256;
-    constexpr int kNumThreads = 16;
+    constexpr auto kLoopIters = 2uz * 1024;
+    constexpr auto kLockWeakLoopIters = 256uz;
+    constexpr auto kNumThreads = 16uz;
     std::vector> toLock;
     Barrier loopStartSyncPoint{kNumThreads};
     Barrier postCreateToLockSyncPoint{kNumThreads};
@@ -784,8 +841,8 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak)
     // lockAndDestroy creates weak pointers from the strong pointer
     // and runs a loop that locks the weak pointer. At the end of the loop
     // all the pointers are destroyed all at once.
-    auto lockAndDestroy = [&](int threadId) {
-        for (int i = 0; i < kLoopIters; ++i)
+    auto lockAndDestroy = [&](std::size_t threadId) {
+        for (auto i = 0uz; i < kLoopIters; ++i)
         {
             // ------ Sync Point ------
             loopStartSyncPoint.arriveAndWait();
@@ -816,7 +873,7 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak)
             // Multiple threads all create a weak pointer from the same
             // strong pointer
             WeakIntrusive const weak{toLock[threadId]};
-            for (int wi = 0; wi < kLockWeakLoopIters; ++wi)
+            for (auto wi = 0uz; wi < kLockWeakLoopIters; ++wi)
             {
                 EXPECT_FALSE(weak.expired());
                 auto strong = weak.lock();
@@ -831,11 +888,11 @@ TEST(IntrusiveSharedTest, multithreaded_locking_weak)
     };
     std::vector threads;
     threads.reserve(kNumThreads);
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads.emplace_back(lockAndDestroy, i);
     }
-    for (int i = 0; i < kNumThreads; ++i)
+    for (auto i = 0uz; i < kNumThreads; ++i)
     {
         threads[i].join();
     }
diff --git a/src/tests/libxrpl/basics/MallocTrim.cpp b/src/tests/libxrpl/basics/MallocTrim.cpp
index 6ac8957f0e..0f98a156a5 100644
--- a/src/tests/libxrpl/basics/MallocTrim.cpp
+++ b/src/tests/libxrpl/basics/MallocTrim.cpp
@@ -49,7 +49,7 @@ TEST(MallocTrimReport, structure)
 }
 
 #if defined(__GLIBC__) && BOOST_OS_LINUX
-TEST(parseStatmRSSkB, standard_format)
+TEST(ParseStatmRSSkB, standard_format)
 {
     using xrpl::detail::parseStatmRSSkB;
 
@@ -121,7 +121,7 @@ TEST(parseStatmRSSkB, standard_format)
 }
 #endif
 
-TEST(mallocTrim, without_debug_logging)
+TEST(MallocTrim, without_debug_logging)
 {
     beast::Journal const journal{beast::Journal::getNullSink()};
 
@@ -144,7 +144,7 @@ TEST(mallocTrim, without_debug_logging)
 #endif
 }
 
-TEST(mallocTrim, empty_tag)
+TEST(MallocTrim, empty_tag)
 {
     beast::Journal const journal{beast::Journal::getNullSink()};
     MallocTrimReport const report = mallocTrim("", journal);
@@ -157,7 +157,7 @@ TEST(mallocTrim, empty_tag)
 #endif
 }
 
-TEST(mallocTrim, with_debug_logging)
+TEST(MallocTrim, with_debug_logging)
 {
     struct DebugSink : public beast::Journal::Sink
     {
@@ -194,12 +194,12 @@ TEST(mallocTrim, with_debug_logging)
 #endif
 }
 
-TEST(mallocTrim, repeated_calls)
+TEST(MallocTrim, repeated_calls)
 {
     beast::Journal const journal{beast::Journal::getNullSink()};
 
     // Call malloc_trim multiple times to ensure it's safe
-    for (int i = 0; i < 5; ++i)
+    for (auto i = 0uz; i < 5; ++i)
     {
         MallocTrimReport const report = mallocTrim("iteration_" + std::to_string(i), journal);
 
diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp
index e806e7c051..a81e90527e 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
@@ -323,11 +336,11 @@ TEST(NumberTest, add)
                     __LINE__,
                 },
                 {
-                    // Does not round. Mantissas are going to be > maxRep, so if
+                    // Does not round. Mantissas are going to be > kMaxRep, so if
                     // added together as uint64_t's, the result will overflow.
                     // With addition using uint128_t, there's no problem. After
                     // normalizing, the resulting mantissa ends up less than
-                    // maxRep.
+                    // kMaxRep.
                     Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}},
                     Number{false, 9'999'999'999'999'999'990ULL, 0, Number::Normalized{}},
                     Number{false, 1'999'999'999'999'999'998ULL, 1, Number::Normalized{}},
@@ -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())
@@ -1078,14 +1243,6 @@ TEST(NumberTest, root)
                 EXPECT_EQ(result, z) << ss.str();
             }
         };
-        /*
-        auto tests = [&](auto const& cSmall, auto const& cLarge) {
-            test(cSmall);
-            if (scale != MantissaRange::mantissa_scale::small)
-                test(cLarge);
-        };
-        */
-
         auto const cSmall = std::to_array(
             {{Number{2}, 2, Number{1414213562373095049, -18}},
              {Number{2'000'000}, 2, Number{1414213562373095049, -15}},
@@ -1511,7 +1668,7 @@ TEST(NumberTest, to_string)
                     NumberRoundModeGuard const mg(Number::RoundingMode::TowardsZero);
 
                     auto const maxMantissa = Number::maxMantissa();
-                    EXPECT_EQ(maxMantissa, (9'999'999'999'999'999));
+                    EXPECT_EQ(maxMantissa, 9'999'999'999'999'999);
                     test(
                         Number{false, (maxMantissa * 1000) + 999, -3, Number::Normalized()},
                         "9999999999999999",
@@ -1550,7 +1707,7 @@ TEST(NumberTest, to_string)
                     NumberRoundModeGuard const mg(Number::RoundingMode::TowardsZero);
 
                     auto const maxMantissa = Number::maxMantissa();
-                    EXPECT_EQ((maxMantissa), (9'999'999'999'999'999'999ULL));
+                    EXPECT_EQ(maxMantissa, 9'999'999'999'999'999'999ULL);
                     test(
                         Number{false, maxMantissa, 0, Number::Normalized{}},
                         "9999999999999999990",
diff --git a/src/tests/libxrpl/basics/RangeSet.cpp b/src/tests/libxrpl/basics/RangeSet.cpp
index 44b13ad581..2e224c79f7 100644
--- a/src/tests/libxrpl/basics/RangeSet.cpp
+++ b/src/tests/libxrpl/basics/RangeSet.cpp
@@ -10,7 +10,7 @@
 
 using namespace xrpl;
 
-TEST(RangeSet, prevMissing)
+TEST(RangeSet, prev_missing)
 {
     // Set will include:
     // [ 0, 5]
@@ -36,7 +36,7 @@ TEST(RangeSet, prevMissing)
     }
 }
 
-TEST(RangeSet, toString)
+TEST(RangeSet, to_string)
 {
     RangeSet set;
     EXPECT_EQ(to_string(set), "empty");
@@ -54,7 +54,7 @@ TEST(RangeSet, toString)
     EXPECT_EQ(to_string(set), "1-2,6");
 }
 
-TEST(RangeSet, fromString)
+TEST(RangeSet, from_string)
 {
     RangeSet set;
 
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..1859de2cc1 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, trim_whitespace)
+{
+    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, to_lower)
+{
+    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, trim_and_lower_ignore_locale)
+{
+    // 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/base58.cpp b/src/tests/libxrpl/basics/base58.cpp
index d452453f76..d6b1d2c3f9 100644
--- a/src/tests/libxrpl/basics/base58.cpp
+++ b/src/tests/libxrpl/basics/base58.cpp
@@ -151,7 +151,7 @@ randomBigInt(std::uint8_t minSize = 1, std::uint8_t maxSize = 5)
     auto const numCoeff = numCoeffDist(eng);
     std::vector coeffs;
     coeffs.reserve(numCoeff);
-    for (int i = 0; i < numCoeff; ++i)
+    for (auto i = 0uz; i < numCoeff; ++i)
     {
         coeffs.push_back(dist(eng));
     }
@@ -167,7 +167,7 @@ TEST(Base58Test, multiprecision)
     auto eng = randEngine();
     std::uniform_int_distribution dist;
     std::uniform_int_distribution dist1(1);
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::uint64_t const d = dist(eng);
         if (d == 0u)
@@ -185,7 +185,7 @@ TEST(Base58Test, multiprecision)
         EXPECT_EQ(refMod.convert_to(), mod);
         EXPECT_EQ(foundDiv, refDiv);
     }
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::uint64_t const d = dist(eng);
         auto bigInt = multiprecision_utils::randomBigInt(/*minSize*/ 2);
@@ -204,7 +204,7 @@ TEST(Base58Test, multiprecision)
         auto const foundAdd = multiprecision_utils::toBoostMP(bigInt);
         EXPECT_EQ(refAdd, foundAdd);
     }
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::uint64_t const d = dist1(eng);
         // Force overflow
@@ -221,7 +221,7 @@ TEST(Base58Test, multiprecision)
         auto const foundAdd = multiprecision_utils::toBoostMP(bigInt);
         EXPECT_NE(refAdd, foundAdd);
     }
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::uint64_t const d = dist(eng);
         auto bigInt = multiprecision_utils::randomBigInt(/* minSize */ 2);
@@ -239,7 +239,7 @@ TEST(Base58Test, multiprecision)
         auto const foundMul = multiprecision_utils::toBoostMP(bigInt);
         EXPECT_EQ(refMul, foundMul);
     }
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::uint64_t const d = dist1(eng);
         // Force overflow
@@ -265,7 +265,7 @@ TEST(Base58Test, fast_matches_ref)
 
         std::array b256ResultBuf[2];
         std::array, 2> b256Result;
-        for (int i = 0; i < 2; ++i)
+        for (auto i = 0uz; i < 2; ++i)
         {
             std::span const outBuf{b58ResultBuf[i]};
             if (i == 0)
@@ -297,7 +297,7 @@ TEST(Base58Test, fast_matches_ref)
             }
         }
 
-        for (int i = 0; i < 2; ++i)
+        for (auto i = 0uz; i < 2; ++i)
         {
             std::span const outBuf{b256ResultBuf[i].data(), b256ResultBuf[i].size()};
             if (i == 0)
@@ -339,7 +339,7 @@ TEST(Base58Test, fast_matches_ref)
 
         std::array b256ResultBuf[2];
         std::array, 2> b256Result;
-        for (int i = 0; i < 2; ++i)
+        for (auto i = 0uz; i < 2; ++i)
         {
             std::span const outBuf{b58ResultBuf[i].data(), b58ResultBuf[i].size()};
             if (i == 0)
@@ -370,7 +370,7 @@ TEST(Base58Test, fast_matches_ref)
             }
         }
 
-        for (int i = 0; i < 2; ++i)
+        for (auto i = 0uz; i < 2; ++i)
         {
             std::span const outBuf{b256ResultBuf[i].data(), b256ResultBuf[i].size()};
             if (i == 0)
@@ -425,7 +425,7 @@ TEST(Base58Test, fast_matches_ref)
 
     // test with random data
     constexpr std::size_t kIters = 100000;
-    for (int i = 0; i < kIters; ++i)
+    for (auto i = 0uz; i < kIters; ++i)
     {
         std::array b256DataBuf{};
         auto const [tokType, b256Data] = randomB256TestData(b256DataBuf);
diff --git a/src/tests/libxrpl/basics/base64.cpp b/src/tests/libxrpl/basics/base64.cpp
index d26d23700a..c9f8331c98 100644
--- a/src/tests/libxrpl/basics/base64.cpp
+++ b/src/tests/libxrpl/basics/base64.cpp
@@ -14,7 +14,7 @@ check(std::string const& in, std::string const& out)
     EXPECT_EQ(base64Decode(encoded), in);
 }
 
-TEST(base64, base64)
+TEST(Base64, base64)
 {
     // cspell: disable
     check("", "");
diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp
index 174cc33aa0..4783820205 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 
@@ -59,65 +60,67 @@ struct BaseUintTest : public ::testing::Test
     static void
     testComparisons()
     {
-        {
-            static constexpr std::array, 6> kTestArgs{
-                {{"0000000000000000", "0000000000000001"},
-                 {"0000000000000000", "ffffffffffffffff"},
-                 {"1234567812345678", "2345678923456789"},
-                 {"8000000000000000", "8000000000000001"},
-                 {"aaaaaaaaaaaaaaa9", "aaaaaaaaaaaaaaaa"},
-                 {"fffffffffffffffe", "ffffffffffffffff"}}};
+        using HexPair = std::pair;
 
-            for (auto const& arg : kTestArgs)
+        {
+            static constexpr auto kTestArgs = std::to_array({
+                {"0000000000000000", "0000000000000001"},
+                {"0000000000000000", "ffffffffffffffff"},
+                {"1234567812345678", "2345678923456789"},
+                {"8000000000000000", "8000000000000001"},
+                {"aaaaaaaaaaaaaaa9", "aaaaaaaaaaaaaaaa"},
+                {"fffffffffffffffe", "ffffffffffffffff"},
+            });
+
+            for (auto const& [smallerText, largerText] : kTestArgs)
             {
-                xrpl::BaseUInt<64> const u{arg.first}, v{arg.second};
+                xrpl::BaseUInt<64> const smaller{smallerText}, larger{largerText};
                 // For code readability, we want to use general boolean
                 // expectations instead of specific EXPECT_LT etc.
-                EXPECT_TRUE(u < v);
-                EXPECT_TRUE(u <= v);
-                EXPECT_TRUE(u != v);
-                EXPECT_FALSE(u == v);
-                EXPECT_FALSE(u > v);
-                EXPECT_FALSE(u >= v);
-                EXPECT_FALSE(v < u);
-                EXPECT_FALSE(v <= u);
-                EXPECT_TRUE(v != u);
-                EXPECT_FALSE(v == u);
-                EXPECT_TRUE(v > u);
-                EXPECT_TRUE(v >= u);
-                EXPECT_TRUE(u == u);
-                EXPECT_TRUE(v == v);
+                EXPECT_TRUE(smaller < larger);
+                EXPECT_TRUE(smaller <= larger);
+                EXPECT_TRUE(smaller != larger);
+                EXPECT_FALSE(smaller == larger);
+                EXPECT_FALSE(smaller > larger);
+                EXPECT_FALSE(smaller >= larger);
+                EXPECT_FALSE(larger < smaller);
+                EXPECT_FALSE(larger <= smaller);
+                EXPECT_TRUE(larger != smaller);
+                EXPECT_FALSE(larger == smaller);
+                EXPECT_TRUE(larger > smaller);
+                EXPECT_TRUE(larger >= smaller);
+                EXPECT_TRUE(smaller == smaller);
+                EXPECT_TRUE(larger == larger);
             }
         }
 
         {
-            static constexpr std::array, 6> kTestArgs{
-                {
-                    {"000000000000000000000000", "000000000000000000000001"},
-                    {"000000000000000000000000", "ffffffffffffffffffffffff"},
-                    {"0123456789ab0123456789ab", "123456789abc123456789abc"},
-                    {"555555555555555555555555", "55555555555a555555555555"},
-                    {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"},
-                    {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"},
-                }};
+            static constexpr auto kTestArgs = std::to_array({
+                {"000000000000000000000000", "000000000000000000000001"},
+                {"000000000000000000000000", "ffffffffffffffffffffffff"},
+                {"0123456789ab0123456789ab", "123456789abc123456789abc"},
+                {"555555555555555555555555", "55555555555a555555555555"},
+                {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"},
+                {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"},
+            });
 
-            for (auto const& arg : kTestArgs)
+            for (auto const& [smallerText, largerText] : kTestArgs)
             {
-                xrpl::BaseUInt<96> const u{arg.first}, v{arg.second};
-                EXPECT_TRUE(u < v);
-                EXPECT_TRUE(u <= v);
-                EXPECT_TRUE(u != v);
-                EXPECT_FALSE(u == v);
-                EXPECT_FALSE(u > v);
-                EXPECT_FALSE(u >= v);
-                EXPECT_FALSE(v < u);
-                EXPECT_FALSE(v <= u);
-                EXPECT_TRUE(v != u);
-                EXPECT_FALSE(v == u);
-                EXPECT_TRUE(v > u);
-                EXPECT_TRUE(v >= u);
-                EXPECT_TRUE(u == u);
-                EXPECT_TRUE(v == v);
+                xrpl::BaseUInt<96> const smaller{smallerText}, larger{largerText};
+                EXPECT_TRUE(smaller < larger);
+                EXPECT_TRUE(smaller <= larger);
+                EXPECT_TRUE(smaller != larger);
+                EXPECT_FALSE(smaller == larger);
+                EXPECT_FALSE(smaller > larger);
+                EXPECT_FALSE(smaller >= larger);
+                EXPECT_FALSE(larger < smaller);
+                EXPECT_FALSE(larger <= smaller);
+                EXPECT_TRUE(larger != smaller);
+                EXPECT_FALSE(larger == smaller);
+                EXPECT_TRUE(larger > smaller);
+                EXPECT_TRUE(larger >= smaller);
+                EXPECT_TRUE(smaller == smaller);
+                EXPECT_TRUE(larger == larger);
             }
         }
     }
@@ -125,7 +128,7 @@ struct BaseUintTest : public ::testing::Test
 
 using BaseUintDeathTest = BaseUintTest;
 
-TEST_F(BaseUintDeathTest, fromRaw_size_mismatch)
+TEST_F(BaseUintDeathTest, from_raw_size_mismatch)
 {
     // ENABLE_VOIDSTAR is a debug build, but does not crash on failed asserts. Rather than twist
     // these tests into knots to make them work, just skip them.
@@ -203,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:
@@ -330,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:
@@ -340,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
@@ -355,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
@@ -401,20 +386,20 @@ TEST_F(BaseUintTest, base_uint)
             {
             }
         };
-        constexpr StrBaseUInt kTestCases[] = {
+        constexpr auto kTestCases = std::to_array({
             "000000000000000000000000",
             "000000000000000000000001",
             "fedcba9876543210ABCDEF91",
             "19FEDCBA0123456789abcdef",
             "800000000000000000000000",
             "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/basics/contract.cpp b/src/tests/libxrpl/basics/contract.cpp
index 0c6b32a7ad..e9404da78b 100644
--- a/src/tests/libxrpl/basics/contract.cpp
+++ b/src/tests/libxrpl/basics/contract.cpp
@@ -6,7 +6,7 @@
 
 using namespace xrpl;
 
-TEST(contract, contract)
+TEST(Contract, contract)
 {
     try
     {
diff --git a/src/tests/libxrpl/basics/join.cpp b/src/tests/libxrpl/basics/join.cpp
index 66c832678b..427f0b42bc 100644
--- a/src/tests/libxrpl/basics/join.cpp
+++ b/src/tests/libxrpl/basics/join.cpp
@@ -19,11 +19,11 @@ struct JoinTest : public ::testing::Test
 
 TEST_F(JoinTest, join)
 {
-    auto test = [](auto collectionanddelimiter, std::string expected) {
+    auto test = [](auto collectionAndDelimiter, std::string expected) {
         std::stringstream ss;
         // Put something else in the buffer before and after to ensure that
         // the << operator returns the stream correctly.
-        ss << "(" << collectionanddelimiter << ")";
+        ss << "(" << collectionAndDelimiter << ")";
         auto const str = ss.str();
         EXPECT_EQ(str.substr(1, str.length() - 2), expected);
         EXPECT_EQ(str.front(), '(');
diff --git a/src/tests/libxrpl/basics/mulDiv.cpp b/src/tests/libxrpl/basics/mulDiv.cpp
index 725bef399e..cdc672a444 100644
--- a/src/tests/libxrpl/basics/mulDiv.cpp
+++ b/src/tests/libxrpl/basics/mulDiv.cpp
@@ -7,7 +7,7 @@
 
 using namespace xrpl;
 
-TEST(mulDiv, mulDiv)
+TEST(MulDiv, mul_div)
 {
     auto const max = std::numeric_limits::max();
     std::uint64_t const max32 = std::numeric_limits::max();
diff --git a/src/tests/libxrpl/basics/scope.cpp b/src/tests/libxrpl/basics/scope.cpp
index dc5623e967..71186d3d90 100644
--- a/src/tests/libxrpl/basics/scope.cpp
+++ b/src/tests/libxrpl/basics/scope.cpp
@@ -6,7 +6,7 @@
 
 using namespace xrpl;
 
-TEST(scope, ScopeExit)
+TEST(Scope, scope_exit)
 {
     // ScopeExit always executes the functor on destruction,
     // unless release() is called
@@ -56,7 +56,7 @@ TEST(scope, ScopeExit)
     EXPECT_EQ(i, 5);
 }
 
-TEST(scope, ScopeFail)
+TEST(Scope, scope_fail)
 {
     // ScopeFail executes the functor on destruction only
     // if an exception is unwinding, unless release() is called
@@ -106,7 +106,7 @@ TEST(scope, ScopeFail)
     EXPECT_EQ(i, 5);
 }
 
-TEST(scope, ScopeSuccess)
+TEST(Scope, scope_success)
 {
     // ScopeSuccess executes the functor on destruction only
     // if an exception is not unwinding, unless release() is called
diff --git a/src/tests/libxrpl/basics/tagged_integer.cpp b/src/tests/libxrpl/basics/tagged_integer.cpp
index 7382527d4d..dc553d4c0f 100644
--- a/src/tests/libxrpl/basics/tagged_integer.cpp
+++ b/src/tests/libxrpl/basics/tagged_integer.cpp
@@ -105,7 +105,7 @@ static_assert(
 
 using TagInt = TaggedInteger;
 
-TEST(tagged_integer, comparison_operators)
+TEST(TaggedInteger, comparison_operators)
 {
     TagInt const zero(0);
     TagInt const one(1);
@@ -131,7 +131,7 @@ TEST(tagged_integer, comparison_operators)
     EXPECT_FALSE(one <= zero);
 }
 
-TEST(tagged_integer, increment_decrement_operators)
+TEST(TaggedInteger, increment_decrement_operators)
 {
     TagInt const zero(0);
     TagInt const one(1);
@@ -146,7 +146,7 @@ TEST(tagged_integer, increment_decrement_operators)
     EXPECT_EQ(a, zero);
 }
 
-TEST(tagged_integer, arithmetic_operators)
+TEST(TaggedInteger, arithmetic_operators)
 {
     TagInt const a{-2};
     EXPECT_EQ(+a, TagInt{-2});
@@ -166,7 +166,7 @@ TEST(tagged_integer, arithmetic_operators)
     EXPECT_EQ((TagInt{16} >> TagInt{2}), TagInt{4});
 }
 
-TEST(tagged_integer, assignment_operators)
+TEST(TaggedInteger, assignment_operators)
 {
     TagInt a{-2};
     TagInt b{0};
diff --git a/src/tests/libxrpl/beast/LexicalCast.cpp b/src/tests/libxrpl/beast/LexicalCast.cpp
new file mode 100644
index 0000000000..d18af4e1cd
--- /dev/null
+++ b/src/tests/libxrpl/beast/LexicalCast.cpp
@@ -0,0 +1,339 @@
+#include 
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace beast {
+namespace {
+
+template 
+[[nodiscard]] constexpr bool
+parses(std::string_view text)
+{
+    T out{};
+    return lexicalCastChecked(out, text);
+}
+
+template 
+[[nodiscard]] constexpr T
+parsed(std::string_view text)
+{
+    T out{};
+    return lexicalCastChecked(out, text) ? out : T{};
+}
+
+template 
+constexpr T kMax = std::numeric_limits::max();
+
+template 
+constexpr T kMin = std::numeric_limits::min();
+
+template 
+constexpr T kUnderMax = kMax - 1;
+
+template 
+constexpr T kOverMin = kMin + 1;
+
+// Comfortably inside the range, not boundary values.
+constexpr auto kNearMax32 = kMax - 5;
+constexpr auto kNearMin32 = kMin + 4;
+constexpr auto kUnderInt64Max = uint64_t{kMax} - 1;
+constexpr auto kInRangeInt16 = int16_t{-5711};
+
+// No wider integer type can hold these, so ToString cannot produce them.
+constexpr auto kAboveUint64Max = "18446744073709551616";
+constexpr auto kBelowInt64Min = "-9223372036854775809";
+
+// Out of range for every integer type we test.
+constexpr auto kTwentyNines = "99999999999999999999";
+constexpr auto kNegativeTwentyNines = "-99999999999999999999";
+
+// Arbitrary values chosen to sit well outside a type's range, not just over it.
+constexpr auto kAboveUint16Max = "75821";
+constexpr auto kBelowInt16Min = "-75821";
+constexpr auto kAboveInt32Max = "5294967295";
+constexpr auto kAboveInt16Max = "66666";
+
+constexpr auto kPositiveInt32 = int32_t{42};
+constexpr auto kNegativeInt32 = int32_t{-42};
+
+constexpr auto kPositiveInt32Text = "+42";
+constexpr auto kNegativeInt32Text = "-42";
+
+constexpr auto kNegativeOne = "-1";
+constexpr auto kNegativeZero = "-0";
+constexpr auto kBareZero = "0";
+constexpr auto kPositiveZero = "+0";
+
+// Full-width digits one and zero, not ASCII ones.
+constexpr std::string_view kFullWidthDigits = "\xef\xbc\x91\xef\xbc\x90";
+
+// The decimal text of a value, usable in a constant expression.
+template 
+struct ToString
+{
+    std::array buffer{};
+    std::size_t length{};
+
+    constexpr explicit ToString(T value)
+    {
+        auto const result = std::to_chars(buffer.data(), buffer.data() + buffer.size(), value);
+        length = static_cast(result.ptr - buffer.data());
+    }
+
+    constexpr
+    operator std::string_view() const
+    {
+        return {buffer.data(), length};
+    }
+};
+
+template 
+constexpr auto kMaxText = ToString{kMax};
+
+template 
+constexpr auto kUnderMaxText = ToString{kUnderMax};
+
+template 
+constexpr auto kOverMaxText = ToString{Wider{kMax} + 1};
+
+template 
+constexpr auto kMinText = ToString{kMin};
+
+template 
+constexpr auto kOverMinText = ToString{kOverMin};
+
+template 
+constexpr auto kUnderMinText = ToString{Wider{kMin} - 1};
+
+constexpr auto kOverUint32MaxText = ToString{uint64_t{kMax} + 5};
+constexpr auto kNegatedOverUint32MaxText = ToString{-(int64_t{kMax} + 5)};
+
+// lexicalCastThrow deduces its input type, so the text has to be an explicit
+// string_view rather than a ToString.
+template 
+[[nodiscard]] constexpr T
+castThrow(Value value)
+{
+    return lexicalCastThrow(std::string_view{ToString{value}});
+}
+
+template 
+[[nodiscard]] bool
+roundTrips(std::string_view text)
+{
+    T out{};
+    return lexicalCastChecked(out, text) && std::to_string(out) == text;
+}
+
+template 
+void
+expectRoundTrip(T value)
+{
+    SCOPED_TRACE(::testing::Message() << "value: " << value);
+
+    auto const text = lexicalCast(value);
+    EXPECT_EQ(text, std::to_string(value));
+
+    auto decoded = static_cast(~value);  // ensure decoded != value
+    EXPECT_TRUE(lexicalCastChecked(decoded, text));
+    EXPECT_EQ(decoded, value);
+}
+
+}  // namespace
+
+// int/unsigned/short/unsigned short are covered by the list below — they are
+// these exact types everywhere we build.
+static_assert(std::is_same_v);
+static_assert(std::is_same_v);
+static_assert(std::is_same_v);
+static_assert(std::is_same_v);
+
+using IntegerTypes = ::testing::Types<  //
+    int16_t,
+    uint16_t,
+    int32_t,
+    uint32_t,
+    int64_t,
+    uint64_t>;
+
+struct IntegerTypeNames
+{
+    template 
+    static std::string
+    // NOLINTNEXTLINE(readability-identifier-naming) - required by gtest
+    GetName(int)
+    {
+        return (std::is_signed_v ? "int" : "uint") + std::to_string(sizeof(T) * 8) + "_t";
+    }
+};
+
+template 
+class LexicalCastIntegers : public ::testing::Test
+{
+};
+
+TYPED_TEST_SUITE(LexicalCastIntegers, IntegerTypes, IntegerTypeNames);
+
+TYPED_TEST(LexicalCastIntegers, round_trips_random_values)
+{
+    static constexpr auto kSampleCount = 1000uz;
+
+    xor_shift_engine r{50};  // seeded per test so a failure reproduces on its own
+
+    for (auto i = 0uz; i < kSampleCount; ++i)
+        expectRoundTrip(static_cast(r()));
+}
+
+TYPED_TEST(LexicalCastIntegers, round_trips_numeric_limits)
+{
+    expectRoundTrip(std::numeric_limits::min());
+    expectRoundTrip(std::numeric_limits::max());
+}
+
+TEST(LexicalCast, round_trips_every_int16_value)
+{
+    for (int32_t i = kMin; i <= kMax; ++i)
+    {
+        auto const value = static_cast(i);
+
+        // ASSERT, or a broken cast reports all 65536 iterations.
+        auto const text = lexicalCast(value);
+        ASSERT_EQ(text, std::to_string(value));
+        ASSERT_EQ(lexicalCast(text), value);
+    }
+}
+
+TEST(LexicalCast, rejects_overflow)
+{
+    static_assert(not parses(kOverUint32MaxText));
+    static_assert(not parses(kTwentyNines));
+    static_assert(not parses(kAboveUint16Max));
+}
+
+TEST(LexicalCast, rejects_underflow)
+{
+    static_assert(not parses(kNegativeOne));
+    static_assert(not parses(kNegatedOverUint32MaxText));
+    static_assert(not parses(kNegativeTwentyNines));
+    static_assert(not parses(kBelowInt16Min));
+}
+
+TEST(LexicalCast, accepts_up_to_the_maximum)
+{
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kOverMaxText));
+
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kOverMaxText));
+
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kOverMaxText));
+
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kOverMaxText));
+
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kOverMaxText));
+
+    static_assert(parsed(kUnderMaxText) == kUnderMax);
+    static_assert(parsed(kMaxText) == kMax);
+    static_assert(not parses(kAboveUint64Max));
+}
+
+TEST(LexicalCast, accepts_down_to_the_minimum)
+{
+    static_assert(parsed(kOverMinText) == kOverMin);
+    static_assert(parsed(kMinText) == kMin);
+    static_assert(not parses(kUnderMinText));
+
+    static_assert(parsed(kOverMinText) == kOverMin);
+    static_assert(parsed(kMinText) == kMin);
+    static_assert(not parses(kUnderMinText));
+
+    static_assert(parsed(kOverMinText) == kOverMin);
+    static_assert(parsed(kMinText) == kMin);
+    static_assert(not parses(kBelowInt64Min));
+}
+
+TEST(LexicalCast, limits_round_trip_through_to_string)
+{
+    EXPECT_TRUE(roundTrips(kMaxText));
+    EXPECT_TRUE(roundTrips(kMaxText));
+    EXPECT_TRUE(roundTrips(kMinText));
+    EXPECT_TRUE(roundTrips(kMaxText));
+    EXPECT_TRUE(roundTrips(kMinText));
+    EXPECT_TRUE(roundTrips(kMaxText));
+    EXPECT_TRUE(roundTrips(kMinText));
+}
+
+TEST(LexicalCast, accepts_signed_zero_in_every_form)
+{
+    static_assert(parsed(kNegativeZero) == 0);
+    static_assert(parsed(kBareZero) == 0);
+    static_assert(parsed(kPositiveZero) == 0);
+}
+
+TEST(LexicalCast, rejects_negative_zero_when_unsigned)
+{
+    static_assert(not parses(kNegativeZero));
+    static_assert(parsed(kBareZero) == 0);
+    static_assert(parsed(kPositiveZero) == 0);
+}
+
+TEST(LexicalCast, accepts_char_pointer_and_std_string_input)
+{
+    int32_t fromLiteral = 0;
+    EXPECT_TRUE(lexicalCastChecked(fromLiteral, kPositiveInt32Text));
+    EXPECT_EQ(fromLiteral, kPositiveInt32);
+
+    int32_t fromString = 0;
+    EXPECT_TRUE(lexicalCastChecked(fromString, std::string{kNegativeInt32Text}));
+    EXPECT_EQ(fromString, kNegativeInt32);
+}
+
+TEST(LexicalCast, throwing_cast_returns_in_range_values)
+{
+    static_assert(castThrow(kUnderInt64Max) == kUnderInt64Max);
+    static_assert(castThrow(kNearMax32) == kNearMax32);
+    static_assert(castThrow(kNearMin32) == kNearMin32);
+    static_assert(castThrow(kInRangeInt16) == kInRangeInt16);
+}
+
+TEST(LexicalCast, throwing_cast_throws_on_out_of_range)
+{
+    EXPECT_THROW(lexicalCastThrow(kTwentyNines), BadLexicalCast);
+
+    // kNearMax32 with digits appended, so each is further past uint32_t's range.
+    for (auto const scale : {10, 100, 1000})
+    {
+        auto const tooBig = ToString{uint64_t{kNearMax32} * scale};
+        EXPECT_THROW(lexicalCastThrow(std::string_view{tooBig}), BadLexicalCast);
+    }
+
+    EXPECT_THROW(lexicalCastThrow(kAboveInt32Max), BadLexicalCast);
+    EXPECT_THROW(lexicalCastThrow(kAboveInt16Max), BadLexicalCast);
+}
+
+// Full-width digits, not ASCII ones.
+TEST(LexicalCast, throwing_cast_throws_on_utf8_digits)
+{
+    EXPECT_THROW(lexicalCastThrow(kFullWidthDigits), BadLexicalCast);
+}
+
+}  // namespace beast
diff --git a/src/tests/libxrpl/beast/SemanticVersion.cpp b/src/tests/libxrpl/beast/SemanticVersion.cpp
new file mode 100644
index 0000000000..21b33c9476
--- /dev/null
+++ b/src/tests/libxrpl/beast/SemanticVersion.cpp
@@ -0,0 +1,333 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace beast {
+namespace {
+
+using IdentifierList = SemanticVersion::IdentifierList;
+
+// Version strings are not valid C++ identifiers, so squash their punctuation to
+// turn one into a gtest parameter name.
+std::string
+identifierFor(std::string_view version)
+{
+    std::string name{version};
+    std::ranges::replace_if(
+        name, [](char c) { return !std::isalnum(c, std::locale::classic()); }, '_');
+    if (!name.empty() && std::isdigit(name.front(), std::locale::classic()))
+        name.insert(0, "v_");
+    return name;
+}
+
+// Pre-release and metadata suffixes, each applied to a "major.minor.patch" base.
+// The valid ones leave a well-formed base well-formed; the invalid ones make any
+// base malformed.
+constexpr auto kValidPreRelease =
+    std::to_array({"", "-1", "-a", "-a1", "-a1.b1", "-ab.cd", "--"});
+constexpr auto kInvalidPreRelease =
+    std::to_array({"+", "!", "-", "-!", "-.", "-a.!", "-0.a"});
+constexpr auto kValidMetaData = std::to_array({"", "+a", "+1", "+a.b", "+ab.cd"});
+constexpr auto kInvalidMetaData =
+    std::to_array({"!", "+", "++", "+!", "+.", "+a.!"});
+
+// Assembles base + preRelease + metaData and checks whether it parses. A version
+// we accept must also round-trip through print().
+void
+expectParse(
+    std::string_view base,
+    std::string_view preRelease,
+    std::string_view metaData,
+    bool shouldPass)
+{
+    auto const input = std::string{base}.append(preRelease).append(metaData);
+    SCOPED_TRACE(::testing::Message() << '"' << input << '"');
+
+    SemanticVersion v;
+
+    if (shouldPass)
+    {
+        EXPECT_TRUE(v.parse(input));
+        EXPECT_EQ(v.print(), input);
+    }
+    else
+    {
+        EXPECT_FALSE(v.parse(input));
+    }
+}
+
+struct ParseCase
+{
+    std::string_view testName;
+    std::string_view base;
+    bool shouldPass;
+};
+
+std::string
+parseCaseName(::testing::TestParamInfo const& info)
+{
+    return std::string{info.param.testName};
+}
+
+constexpr auto kParseCases = std::to_array({
+    {.testName = "zeroes", .base = "0.0.0", .shouldPass = true},
+    {.testName = "simple", .base = "1.2.3", .shouldPass = true},
+    {.testName = "max_int", .base = "2147483647.2147483647.2147483647", .shouldPass = true},
+
+    // negative values
+    {.testName = "negative_major", .base = "-1.2.3", .shouldPass = false},
+    {.testName = "negative_minor", .base = "1.-2.3", .shouldPass = false},
+    {.testName = "negative_patch", .base = "1.2.-3", .shouldPass = false},
+
+    // missing parts
+    {.testName = "empty", .base = "", .shouldPass = false},
+    {.testName = "major_only", .base = "1", .shouldPass = false},
+    {.testName = "major_then_dot", .base = "1.", .shouldPass = false},
+    {.testName = "major_and_minor", .base = "1.2", .shouldPass = false},
+    {.testName = "major_minor_then_dot", .base = "1.2.", .shouldPass = false},
+    {.testName = "missing_major", .base = ".2.3", .shouldPass = false},
+
+    // whitespace
+    {.testName = "leading_space", .base = " 1.2.3", .shouldPass = false},
+    {.testName = "space_after_major", .base = "1 .2.3", .shouldPass = false},
+    {.testName = "space_after_minor", .base = "1.2 .3", .shouldPass = false},
+    {.testName = "trailing_space", .base = "1.2.3 ", .shouldPass = false},
+
+    // leading zeroes
+    {.testName = "leading_zero_in_major", .base = "01.2.3", .shouldPass = false},
+    {.testName = "leading_zero_in_minor", .base = "1.02.3", .shouldPass = false},
+    {.testName = "leading_zero_in_patch", .base = "1.2.03", .shouldPass = false},
+});
+
+struct ValuesCase
+{
+    std::string_view testName;
+    std::string_view input;
+    int majorVersion;
+    int minorVersion;
+    int patchVersion;
+    IdentifierList preReleaseIdentifiers{};  // NOLINT(readability-redundant-member-init)
+    IdentifierList metaData{};               // NOLINT(readability-redundant-member-init)
+};
+
+std::string
+valuesCaseName(::testing::TestParamInfo const& info)
+{
+    return std::string{info.param.testName};
+}
+
+std::vector const kValuesCases{
+    {
+        .testName = "zero_major",
+        .input = "0.1.2",
+        .majorVersion = 0,
+        .minorVersion = 1,
+        .patchVersion = 2,
+    },
+    {
+        .testName = "simple",
+        .input = "1.2.3",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+    },
+    {
+        .testName = "one_pre_release_identifier",
+        .input = "1.2.3-rc1",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .preReleaseIdentifiers = {"rc1"},
+    },
+    {
+        .testName = "two_pre_release_identifiers",
+        .input = "1.2.3-rc1.debug",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .preReleaseIdentifiers = {"rc1", "debug"},
+    },
+    {
+        .testName = "three_pre_release_identifiers",
+        .input = "1.2.3-rc1.debug.asm",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .preReleaseIdentifiers = {"rc1", "debug", "asm"},
+    },
+    {
+        .testName = "one_metadata_identifier",
+        .input = "1.2.3+full",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .metaData = {"full"},
+    },
+    {
+        .testName = "two_metadata_identifiers",
+        .input = "1.2.3+full.prod",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .metaData = {"full", "prod"},
+    },
+    {
+        .testName = "three_metadata_identifiers",
+        .input = "1.2.3+full.prod.x86",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .metaData = {"full", "prod", "x86"},
+    },
+    {
+        .testName = "pre_release_and_metadata",
+        .input = "1.2.3-rc1.debug.asm+full.prod.x86",
+        .majorVersion = 1,
+        .minorVersion = 2,
+        .patchVersion = 3,
+        .preReleaseIdentifiers = {"rc1", "debug", "asm"},
+        .metaData = {"full", "prod", "x86"},
+    },
+};
+
+struct OrderCase
+{
+    std::string_view lesser;
+    std::string_view greater;
+};
+
+std::string
+orderCaseName(::testing::TestParamInfo const& info)
+{
+    return identifierFor(info.param.lesser) + "_below_" + identifierFor(info.param.greater);
+}
+
+constexpr auto kOrderCases = std::to_array({
+    {.lesser = "1.0.0-alpha", .greater = "1.0.0-alpha.1"},
+    {.lesser = "1.0.0-alpha.1", .greater = "1.0.0-alpha.beta"},
+    {.lesser = "1.0.0-alpha.beta", .greater = "1.0.0-beta"},
+    {.lesser = "1.0.0-beta", .greater = "1.0.0-beta.2"},
+    {.lesser = "1.0.0-beta.2", .greater = "1.0.0-beta.11"},
+    {.lesser = "1.0.0-beta.11", .greater = "1.0.0-rc.1"},
+    {.lesser = "1.0.0-rc.1", .greater = "1.0.0"},
+    {.lesser = "0.9.9", .greater = "1.0.0"},
+});
+
+}  // namespace
+
+class SemanticVersionParse : public ::testing::TestWithParam
+{
+};
+
+// Exercises the base string on its own and with every combination of appended
+// pre-release identifiers and metadata.
+TEST_P(SemanticVersionParse, pre_release_and_metadata_combinations)
+{
+    auto const& [testName, base, shouldPass] = GetParam();
+
+    for (auto const preRelease : kValidPreRelease)
+    {
+        for (auto const metaData : kValidMetaData)
+            expectParse(base, preRelease, metaData, shouldPass);
+
+        for (auto const metaData : kInvalidMetaData)
+            expectParse(base, preRelease, metaData, false);
+    }
+
+    // A malformed pre-release section poisons the whole string, whatever
+    // metadata follows it.
+    for (auto const preRelease : kInvalidPreRelease)
+    {
+        for (auto const metaData : kValidMetaData)
+            expectParse(base, preRelease, metaData, false);
+
+        for (auto const metaData : kInvalidMetaData)
+            expectParse(base, preRelease, metaData, false);
+    }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    Inputs,
+    SemanticVersionParse,
+    ::testing::ValuesIn(kParseCases),
+    parseCaseName);
+
+class SemanticVersionValues : public ::testing::TestWithParam
+{
+};
+
+TEST_P(SemanticVersionValues, decomposes_into_components)
+{
+    auto const& expected = GetParam();
+
+    SemanticVersion v;
+    EXPECT_TRUE(v.parse(expected.input));
+
+    EXPECT_EQ(v.majorVersion, expected.majorVersion);
+    EXPECT_EQ(v.minorVersion, expected.minorVersion);
+    EXPECT_EQ(v.patchVersion, expected.patchVersion);
+
+    EXPECT_EQ(v.preReleaseIdentifiers, expected.preReleaseIdentifiers);
+    EXPECT_EQ(v.metaData, expected.metaData);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    Inputs,
+    SemanticVersionValues,
+    ::testing::ValuesIn(kValuesCases),
+    valuesCaseName);
+
+class SemanticVersionOrder : public ::testing::TestWithParam
+{
+};
+
+TEST_P(SemanticVersionOrder, lesser_precedes_greater)
+{
+    auto const& [lesser, greater] = GetParam();
+
+    // Metadata takes no part in precedence, so attaching it to either side must
+    // leave the ordering untouched.
+    static constexpr auto kMetaData = std::to_array({"", "+meta"});
+
+    for (auto const lesserMetaData : kMetaData)
+    {
+        for (auto const greaterMetaData : kMetaData)
+        {
+            auto const lesserInput = std::string{lesser}.append(lesserMetaData);
+            auto const greaterInput = std::string{greater}.append(greaterMetaData);
+            SCOPED_TRACE(
+                ::testing::Message() << '"' << lesserInput << "\" < \"" << greaterInput << '"');
+
+            SemanticVersion lesserVersion;
+            SemanticVersion greaterVersion;
+            EXPECT_TRUE(lesserVersion.parse(lesserInput));
+            EXPECT_TRUE(greaterVersion.parse(greaterInput));
+
+            EXPECT_EQ(compare(lesserVersion, lesserVersion), 0);
+            EXPECT_EQ(compare(greaterVersion, greaterVersion), 0);
+            EXPECT_LT(compare(lesserVersion, greaterVersion), 0);
+            EXPECT_GT(compare(greaterVersion, lesserVersion), 0);
+
+            EXPECT_LT(lesserVersion, greaterVersion);
+            EXPECT_GT(greaterVersion, lesserVersion);
+            EXPECT_EQ(lesserVersion, lesserVersion);
+            EXPECT_EQ(greaterVersion, greaterVersion);
+        }
+    }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    Pairs,
+    SemanticVersionOrder,
+    ::testing::ValuesIn(kOrderCases),
+    orderCaseName);
+
+}  // namespace beast
diff --git a/src/tests/libxrpl/beast/Zero.cpp b/src/tests/libxrpl/beast/Zero.cpp
new file mode 100644
index 0000000000..2ac725509a
--- /dev/null
+++ b/src/tests/libxrpl/beast/Zero.cpp
@@ -0,0 +1,92 @@
+#include 
+
+#include 
+
+namespace beast {
+
+struct AdlTester
+{
+};
+
+int
+signum(AdlTester)
+{
+    return 0;
+}
+
+namespace inner_adl_test {
+
+struct AdlTester2
+{
+};
+
+int
+signum(AdlTester2)
+{
+    return 0;
+}
+
+}  // namespace inner_adl_test
+
+namespace {
+
+struct IntegerWrapper
+{
+    int value;
+
+    IntegerWrapper(int v) : value(v)
+    {
+    }
+
+    [[nodiscard]] int
+    signum() const
+    {
+        return value;
+    }
+};
+
+void
+testLhsZero(IntegerWrapper x)
+{
+    EXPECT_EQ(x >= kZero, x.signum() >= 0);
+    EXPECT_EQ(x > kZero, x.signum() > 0);
+    EXPECT_EQ(x == kZero, x.signum() == 0);
+    EXPECT_EQ(x != kZero, x.signum() != 0);
+    EXPECT_EQ(x < kZero, x.signum() < 0);
+    EXPECT_EQ(x <= kZero, x.signum() <= 0);
+}
+
+void
+testRhsZero(IntegerWrapper x)
+{
+    EXPECT_EQ(kZero >= x, 0 >= x.signum());
+    EXPECT_EQ(kZero > x, 0 > x.signum());
+    EXPECT_EQ(kZero == x, 0 == x.signum());
+    EXPECT_EQ(kZero != x, 0 != x.signum());
+    EXPECT_EQ(kZero < x, 0 < x.signum());
+    EXPECT_EQ(kZero <= x, 0 <= x.signum());
+}
+
+}  // namespace
+
+TEST(Zero, lhs)
+{
+    testLhsZero(-7);
+    testLhsZero(0);
+    testLhsZero(32);
+}
+
+TEST(Zero, rhs)
+{
+    testRhsZero(-4);
+    testRhsZero(0);
+    testRhsZero(64);
+}
+
+TEST(Zero, adl)
+{
+    EXPECT_TRUE(AdlTester{} == kZero);
+    EXPECT_TRUE(inner_adl_test::AdlTester2{} == kZero);
+}
+
+}  // namespace beast
diff --git a/src/tests/libxrpl/consensus/CensorshipDetector.cpp b/src/tests/libxrpl/consensus/CensorshipDetector.cpp
index aa6b2d086b..2c6b6ec731 100644
--- a/src/tests/libxrpl/consensus/CensorshipDetector.cpp
+++ b/src/tests/libxrpl/consensus/CensorshipDetector.cpp
@@ -69,7 +69,7 @@ TEST(CensorshipDetectorTest, censorship_detector)
     runRound(cdet, ++round, {23, 24, 25, 26}, {25, 27}, {23, 26}, {24});
     runRound(cdet, ++round, {23, 26, 28}, {26, 28}, {23}, {});
 
-    for (int i = 0; i != 10; ++i)
+    for (auto i = 0uz; i != 10; ++i)
         runRound(cdet, ++round, {23}, {}, {23}, {});
 
     runRound(cdet, ++round, {23, 29}, {29}, {23}, {});
diff --git a/src/tests/libxrpl/crypto/csprng.cpp b/src/tests/libxrpl/crypto/csprng.cpp
index 957f7f5c56..71e20f2ddc 100644
--- a/src/tests/libxrpl/crypto/csprng.cpp
+++ b/src/tests/libxrpl/crypto/csprng.cpp
@@ -6,7 +6,7 @@
 
 using namespace xrpl;
 
-TEST(csprng, get_values)
+TEST(Csprng, get_values)
 {
     auto& engine = cryptoPrng();
     auto randVal = engine();
diff --git a/src/tests/libxrpl/csf/TrustGraph.h b/src/tests/libxrpl/csf/TrustGraph.h
index d010b954e0..8a804fcd5b 100644
--- a/src/tests/libxrpl/csf/TrustGraph.h
+++ b/src/tests/libxrpl/csf/TrustGraph.h
@@ -118,9 +118,9 @@ public:
         std::vector res;
 
         // Loop over all pairs of uniqueUNLs
-        for (int i = 0; i < uniqueUNLs.size(); ++i)
+        for (auto i = 0uz; i < uniqueUNLs.size(); ++i)
         {
-            for (int j = (i + 1); j < uniqueUNLs.size(); ++j)
+            for (auto j = i + 1; j < uniqueUNLs.size(); ++j)
             {
                 auto const& unlA = uniqueUNLs[i];
                 auto const& unlB = uniqueUNLs[j];
diff --git a/src/tests/libxrpl/csf/random.h b/src/tests/libxrpl/csf/random.h
index 007bdecb1b..56838bb280 100644
--- a/src/tests/libxrpl/csf/random.h
+++ b/src/tests/libxrpl/csf/random.h
@@ -24,11 +24,12 @@ randomWeightedShuffle(std::vector v, std::vector w, G& g)
 {
     using std::swap;
 
-    for (int i = 0; i < v.size() - 1; ++i)
+    for (auto i = 0uz; i + 1 < v.size(); ++i)
     {
-        // pick a random item weighted by w
-        std::discrete_distribution<> dd(w.begin() + i, w.end());  // NOLINT(misc-const-correctness)
-        auto idx = dd(g);
+        // Pick a random item from the unplaced tail, weighted by w.
+        // NOLINTNEXTLINE(misc-const-correctness)
+        std::discrete_distribution dd(w.begin() + i, w.end());
+        auto const idx = i + dd(g);
         std::swap(v[i], v[idx]);
         std::swap(w[i], w[idx]);
     }
diff --git a/src/tests/libxrpl/helpers/TestServiceRegistry.h b/src/tests/libxrpl/helpers/TestServiceRegistry.h
index f7b09bccd1..e763c8bde4 100644
--- a/src/tests/libxrpl/helpers/TestServiceRegistry.h
+++ b/src/tests/libxrpl/helpers/TestServiceRegistry.h
@@ -213,7 +213,7 @@ public:
         throw std::logic_error("TestServiceRegistry::peerReservations() not implemented");
     }
 
-    Resource::Manager&
+    resource::Manager&
     getResourceManager() override
     {
         throw std::logic_error("TestServiceRegistry::getResourceManager() not implemented");
diff --git a/src/tests/libxrpl/json/Value.cpp b/src/tests/libxrpl/json/Value.cpp
index a58a5df9fd..1aa0756419 100644
--- a/src/tests/libxrpl/json/Value.cpp
+++ b/src/tests/libxrpl/json/Value.cpp
@@ -21,7 +21,7 @@
 
 namespace xrpl {
 
-TEST(json_value, limits)
+TEST(JsonValue, limits)
 {
     using namespace json;
     static_assert(Value::kMinInt == Int(~(UInt(-1) / 2)));
@@ -29,7 +29,7 @@ TEST(json_value, limits)
     static_assert(Value::kMaxUInt == UInt(-1));
 }
 
-TEST(json_value, construct_and_compare_Json_StaticString)
+TEST(JsonValue, construct_and_compare_json_static_string)
 {
     static constexpr char kSample[]{"Contents of a json::StaticString"};
 
@@ -52,7 +52,7 @@ TEST(json_value, construct_and_compare_Json_StaticString)
     EXPECT_NE(kTest3, str);
 }
 
-TEST(json_value, different_types)
+TEST(JsonValue, different_types)
 {
     // Exercise ValueType constructor
     static constexpr json::StaticString kStaticStr{"staticStr"};
@@ -206,7 +206,7 @@ TEST(json_value, different_types)
     }
 }
 
-TEST(json_value, compare_strings)
+TEST(JsonValue, compare_strings)
 {
     auto doCompare = [&](json::Value const& lhs,
                          json::Value const& rhs,
@@ -560,7 +560,7 @@ TEST(json_value, compare_strings)
 #pragma pop_macro("DO_COMPARE")
 }
 
-TEST(json_value, bool)
+TEST(JsonValue, bool)
 {
     EXPECT_FALSE(json::Value());
 
@@ -583,7 +583,7 @@ TEST(json_value, bool)
     EXPECT_TRUE(bool(object));
 }
 
-TEST(json_value, bad_json)
+TEST(JsonValue, bad_json)
 {
     char const* s(R"({"method":"ledger","params":[{"ledger_index":1e300}]})");
 
@@ -607,7 +607,7 @@ parseValue(std::string const& doc)
 
 }  // namespace
 
-TEST(json_value, parse_double_valid)
+TEST(JsonValue, parse_double_valid)
 {
     // 1e300 is large but still representable, so it parses (unlike the out-of-range cases below).
     for (auto const& [text, expected] :
@@ -627,14 +627,14 @@ TEST(json_value, parse_double_valid)
     }
 }
 
-TEST(json_value, parse_double_out_of_range)
+TEST(JsonValue, parse_double_out_of_range)
 {
     // Magnitudes with no finite double representation are rejected.
     for (char const* oor : {"1e400", "-1e400", "0.001e500", "1e-400", "-1e-400", "123e-500"})
         EXPECT_FALSE(parseValue(oor).has_value()) << oor;
 }
 
-TEST(json_value, parse_double_malformed)
+TEST(JsonValue, parse_double_malformed)
 {
     // readNumber() collects any run of digits and '.eE+-' into a single Double
     // token, so these malformed tokens reach decodeDouble. Each has a valid
@@ -644,7 +644,7 @@ TEST(json_value, parse_double_malformed)
         EXPECT_FALSE(parseValue(bad).has_value()) << bad;
 }
 
-TEST(json_value, edge_cases)
+TEST(JsonValue, edge_cases)
 {
     std::uint32_t const maxUInt = std::numeric_limits::max();
     std::int32_t const maxInt = std::numeric_limits::max();
@@ -791,7 +791,7 @@ TEST(json_value, edge_cases)
     }
 }
 
-TEST(json_value, copy)
+TEST(JsonValue, copy)
 {
     json::Value v1{2.5};
     EXPECT_TRUE(v1.isDouble());
@@ -812,7 +812,7 @@ TEST(json_value, copy)
     EXPECT_EQ(v1, v2);
 }
 
-TEST(json_value, move)
+TEST(JsonValue, move)
 {
     json::Value v1{2.5};
     EXPECT_TRUE(v1.isDouble());
@@ -831,7 +831,7 @@ TEST(json_value, move)
     EXPECT_NE(v1, v2);  // NOLINT(bugprone-use-after-move)
 }
 
-TEST(json_value, comparisons)
+TEST(JsonValue, comparisons)
 {
     json::Value a, b;
     auto testEquals = [&](std::string const& name) {
@@ -886,7 +886,7 @@ TEST(json_value, comparisons)
     testGreaterThan("big");
 }
 
-TEST(json_value, compact)
+TEST(JsonValue, compact)
 {
     json::Value j;
     json::Reader r;
@@ -909,7 +909,7 @@ TEST(json_value, compact)
     }
 }
 
-TEST(json_value, conversions)
+TEST(JsonValue, conversions)
 {
     // We have json::ValueType::Real but json::Value::asDouble.
     // TODO: What's the thinking here?
@@ -1125,7 +1125,7 @@ TEST(json_value, conversions)
     }
 }
 
-TEST(json_value, access_members)
+TEST(JsonValue, access_members)
 {
     json::Value val;
     EXPECT_EQ(val.type(), json::ValueType::Null);
@@ -1218,7 +1218,7 @@ TEST(json_value, access_members)
     }
 }
 
-TEST(json_value, remove_members)
+TEST(JsonValue, remove_members)
 {
     json::Value val;
     EXPECT_EQ(val.removeMember(std::string("member")).type(), json::ValueType::Null);
@@ -1245,7 +1245,7 @@ TEST(json_value, remove_members)
     EXPECT_EQ(val.size(), 0);
 }
 
-TEST(json_value, iterator)
+TEST(JsonValue, iterator)
 {
     {
         // Iterating an array.
@@ -1331,7 +1331,7 @@ TEST(json_value, iterator)
     }
 }
 
-TEST(json_value, nest_limits)
+TEST(JsonValue, nest_limits)
 {
     json::Reader r;
     {
@@ -1377,7 +1377,7 @@ TEST(json_value, nest_limits)
     }
 }
 
-TEST(json_value, memory_leak)
+TEST(JsonValue, memory_leak)
 {
     // When run with the address sanitizer, this test confirms there is no
     // memory leak with the scenarios below.
diff --git a/src/tests/libxrpl/ledger/AMMEntry.cpp b/src/tests/libxrpl/ledger/AMMEntry.cpp
new file mode 100644
index 0000000000..6013d0f38f
--- /dev/null
+++ b/src/tests/libxrpl/ledger/AMMEntry.cpp
@@ -0,0 +1,25 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(AMMEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    Asset const xrp{xrpIssue()};
+    Asset const usd{IOU("USD", e.alice).issue()};
+
+    expectKeylet(e, keylet::amm(xrp, usd), "amm(asset, asset)", xrp, usd);
+
+    expectKeylet(e, keylet::amm(e.someID()), "amm(uint256)", e.someID());
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/AccountRootEntry.cpp b/src/tests/libxrpl/ledger/AccountRootEntry.cpp
new file mode 100644
index 0000000000..4c8f18337e
--- /dev/null
+++ b/src/tests/libxrpl/ledger/AccountRootEntry.cpp
@@ -0,0 +1,21 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(AccountRootEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    expectKeylet(e, keylet::account(e.alice.id()), "account(id)", e.alice.id());
+
+    expectKeylet(
+        e, keylet::account(Account("nobody").id()), "account(id) absent", Account("nobody").id());
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp
new file mode 100644
index 0000000000..3f296759f3
--- /dev/null
+++ b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp
@@ -0,0 +1,17 @@
+#include 
+
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(AmendmentsEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    expectKeylet(e, keylet::amendments(), "amendments()");
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/BridgeEntry.cpp b/src/tests/libxrpl/ledger/BridgeEntry.cpp
new file mode 100644
index 0000000000..6e6a4394f8
--- /dev/null
+++ b/src/tests/libxrpl/ledger/BridgeEntry.cpp
@@ -0,0 +1,41 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(BridgeEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    STXChainBridge const bridge{e.alice.id(), xrpIssue(), e.bob.id(), IOU("USD", e.bob).issue()};
+
+    expectKeylet(
+        e,
+        keylet::bridge(bridge, STXChainBridge::ChainType::Locking),
+        "bridge(bridge, Locking)",
+        bridge,
+        STXChainBridge::ChainType::Locking);
+
+    expectKeylet(
+        e,
+        keylet::bridge(bridge, STXChainBridge::ChainType::Issuing),
+        "bridge(bridge, Issuing)",
+        bridge,
+        STXChainBridge::ChainType::Issuing);
+
+    // The two chain types must not collide, or the assertions above would
+    // pass with chainType ignored entirely.
+    EXPECT_NE(
+        keylet::bridge(bridge, STXChainBridge::ChainType::Locking).key,
+        keylet::bridge(bridge, STXChainBridge::ChainType::Issuing).key);
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/CheckEntry.cpp b/src/tests/libxrpl/ledger/CheckEntry.cpp
new file mode 100644
index 0000000000..58f6250b20
--- /dev/null
+++ b/src/tests/libxrpl/ledger/CheckEntry.cpp
@@ -0,0 +1,23 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(CheckEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    SeqProxy const seq = SeqProxy::rawSequence(7);
+
+    expectKeylet(
+        e, keylet::check(e.alice.id(), seq), "check(id, seq)", e.alice.id(), seq);
+
+    expectKeylet(e, keylet::check(e.someID()), "check(uint256)", e.someID());
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/CredentialEntry.cpp b/src/tests/libxrpl/ledger/CredentialEntry.cpp
new file mode 100644
index 0000000000..16b877858b
--- /dev/null
+++ b/src/tests/libxrpl/ledger/CredentialEntry.cpp
@@ -0,0 +1,39 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+TEST(CredentialEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    std::string const credTypeStr = "termsandconditions";
+    Slice const credType = makeSlice(credTypeStr);
+
+    expectKeylet(
+        e,
+        keylet::credential(e.alice.id(), e.bob.id(), credType),
+        "credential(subject, issuer, credType)",
+        e.alice.id(),
+        e.bob.id(),
+        credType);
+
+    expectKeylet(
+        e, keylet::credential(e.someID()), "credential(uint256)", e.someID());
+
+    // Subject and issuer are both AccountIDs, so the assertion above only
+    // has teeth if their order matters.
+    EXPECT_NE(
+        keylet::credential(e.alice.id(), e.bob.id(), credType).key,
+        keylet::credential(e.bob.id(), e.alice.id(), credType).key);
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/DIDEntry.cpp b/src/tests/libxrpl/ledger/DIDEntry.cpp
new file mode 100644
index 0000000000..ff0017caad
--- /dev/null
+++ b/src/tests/libxrpl/ledger/DIDEntry.cpp
@@ -0,0 +1,17 @@
+#include 
+
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(DIDEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    expectKeylet(e, keylet::did(e.alice.id()), "did(account)", e.alice.id());
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/DelegateEntry.cpp b/src/tests/libxrpl/ledger/DelegateEntry.cpp
new file mode 100644
index 0000000000..3ffc3c73ed
--- /dev/null
+++ b/src/tests/libxrpl/ledger/DelegateEntry.cpp
@@ -0,0 +1,29 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(DelegateEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    expectKeylet(
+        e,
+        keylet::delegate(e.alice.id(), e.bob.id()),
+        "delegate(account, authorizedAccount)",
+        e.alice.id(),
+        e.bob.id());
+
+    // Both arguments are AccountIDs, so the assertion above only has teeth
+    // if their order matters.
+    EXPECT_NE(
+        keylet::delegate(e.alice.id(), e.bob.id()).key,
+        keylet::delegate(e.bob.id(), e.alice.id()).key);
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp
new file mode 100644
index 0000000000..115baa741d
--- /dev/null
+++ b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp
@@ -0,0 +1,54 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(DepositPreauthEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    std::string const credTypeStr = "termsandconditions";
+    std::set> const authCreds{{e.bob.id(), makeSlice(credTypeStr)}};
+
+    expectKeylet(
+        e,
+        keylet::depositPreauth(e.alice.id(), e.bob.id()),
+        "depositPreauth(owner, preauthorized)",
+        e.alice.id(),
+        e.bob.id());
+
+    expectKeylet(
+        e,
+        keylet::depositPreauth(e.alice.id(), authCreds),
+        "depositPreauth(owner, authCreds)",
+        e.alice.id(),
+        authCreds);
+
+    expectKeylet(
+        e, keylet::depositPreauth(e.someID()), "depositPreauth(uint256)", e.someID());
+
+    // Owner and preauthorized are both AccountIDs, so the assertion above
+    // only has teeth if their order matters.
+    EXPECT_NE(
+        keylet::depositPreauth(e.alice.id(), e.bob.id()).key,
+        keylet::depositPreauth(e.bob.id(), e.alice.id()).key);
+
+    // The credential-set overload must not collide with the single-account
+    // one.
+    EXPECT_NE(
+        keylet::depositPreauth(e.alice.id(), authCreds).key,
+        keylet::depositPreauth(e.alice.id(), e.bob.id()).key);
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp
new file mode 100644
index 0000000000..41349ea427
--- /dev/null
+++ b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp
@@ -0,0 +1,28 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+TEST(DirectoryNodeEntryTests, constructors)
+{
+    EntryTestEnv e;
+
+    expectKeylet(
+        e, keylet::ownerDir(e.alice.id()), "ownerDir(id)", e.alice.id());
+
+    expectKeylet(
+        e, keylet::page(e.someID(), 3u), "page(root, index)", e.someID(), std::uint64_t{3});
+
+    // The two overloads reach different keylet:: functions; a copy-paste
+    // slip between them would be invisible otherwise.
+    EXPECT_NE(keylet::ownerDir(e.alice.id()).key, keylet::page(e.someID(), 3u).key);
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/ledger/EntryTestHelpers.h b/src/tests/libxrpl/ledger/EntryTestHelpers.h
new file mode 100644
index 0000000000..d98b40795e
--- /dev/null
+++ b/src/tests/libxrpl/ledger/EntryTestHelpers.h
@@ -0,0 +1,123 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+/**
+ * Scaffolding shared by the per-entry-type suites.
+ *
+ * Each of those suites needs the same three things: a ledger with a few funded
+ * accounts, a throwaway ApplyView that is never applied, and some arbitrary
+ * uint256 to stand in for an object ID. Build one of these per test case --
+ * TxTest construction dominates the runtime of these tests by a wide margin,
+ * and none of the assertions mutate the ledger.
+ */
+class EntryTestEnv
+{
+public:
+    TxTest env;
+    Account const alice{"alice"};
+    Account const bob{"bob"};
+    Account const carol{"carol"};
+
+    EntryTestEnv() : av_(&fundAndClose(), TapNone)
+    {
+    }
+
+    /**
+     * The closed ledger apply() was built over. Nothing here closes another
+     * ledger or submits a transaction afterward, so this and apply() never
+     * diverge.
+     */
+    [[nodiscard]] ReadView const&
+    read() const
+    {
+        return env.getClosedLedger();
+    }
+
+    [[nodiscard]] ApplyView&
+    apply()
+    {
+        return av_;
+    }
+
+    /**
+     * An arbitrary but stable uint256, for the entry constructors that take
+     * an object ID directly. Nothing in the ledger has this key, which is the
+     * point: those overloads should resolve to a non-existent entry.
+     */
+    [[nodiscard]] uint256
+    someID() const
+    {
+        return read().header().parentHash;
+    }
+
+private:
+    // Runs from the av_ member initializer, so it may only touch env and the
+    // accounts -- everything declared above av_.
+    ReadView const&
+    fundAndClose()
+    {
+        env.createAccount(alice, XRP(10'000));
+        env.createAccount(bob, XRP(10'000));
+        env.createAccount(carol, XRP(10'000));
+        env.close();
+        return env.getClosedLedger();
+    }
+
+    ApplyViewImpl av_;
+};
+
+/**
+ * Assert that both flavors of @p Entry built from @p args resolve the ledger
+ * object that @p expected names.
+ *
+ * The entry classes are near identical, so the defect they invite is a
+ * copy-paste one: a constructor that reaches the wrong keylet:: function, or
+ * that transposes two same-typed arguments. Comparing against an independently
+ * spelled-out keylet at the call site catches exactly that.
+ *
+ * @p what names the overload under test, so a failure says which one broke.
+ */
+template