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 e220cd0249..e8c5f3c30f 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,6 +7,7 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true @@ -30,7 +31,9 @@ ignoreRegExpList: - ABCDEFGHIJKLMNOPQRSTUVWXYZ - ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz overrides: - - filename: "**/*_test.cpp" # all test files + - filename: + - "**/*_test.cpp" # legacy boost.test files + - "src/tests/**/*.cpp" # gtest test files ignoreRegExpList: - /"[^"]*"/g # double-quoted strings - /'[^']*'/g # single-quoted strings @@ -66,6 +69,7 @@ words: - Buildx - canonicality - canonicalised + - cctools - changespq - checkme - choco @@ -107,6 +111,7 @@ words: - disablerepo - distro - doxyfile + - dsymutil - dxrpl - elgamal - enabled @@ -130,6 +135,7 @@ words: - godexsoft - gpgcheck - gpgkey + - Hinnant - hotwallet - hwaddress - hwrap @@ -163,6 +169,8 @@ words: - llection - LOCALGOOD - logwstream + - Lombrozo + - lresolv - lseq - lsmf - ltype @@ -172,7 +180,6 @@ words: - MPTAMM - MPTDEX - Merkle - - Metafuncton - misprediction - missingok - mptbalance @@ -200,6 +207,7 @@ words: - nftokens - nftpage - nikb + - Nikolaos - nixfmt - nixos - nixpkgs @@ -216,6 +224,8 @@ words: - Nyffenegger - onlatest - ostr + - otool + - oxalica - pargs - partitioner - paychan @@ -244,11 +254,15 @@ words: - Raphson - rcflags - replayer + - repodata + - repomd - rerandomize - rerandomization - rerandomized - rerandomizes - rerere + - retargeted + - retargets - retriable - RIPD - ripdtop @@ -284,6 +298,7 @@ words: - sles - soci - socidb + - Sonatype - sponsee - sponsees - SRPMS @@ -303,6 +318,7 @@ words: - summands - superpeer - superpeers + - Swatinem - takergets - takerpays - ters @@ -340,6 +356,7 @@ words: - unsquelch - unsquelched - unsquelching + - unsuffixed - unvalidated - unveto - unvetoed @@ -357,12 +374,15 @@ words: - wthread - xbridge - xchain + - xcrun - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld - xrplf - xxhash - xxhasher + - zstdio - CGNAT diff --git a/.envrc b/.envrc index 3550a30f2d..ec38b75f5c 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,7 @@ +watch_file nix/*.nix + +# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any +# change in there has to invalidate direnv's cached environment. +watch_dir conan + use flake diff --git a/.github/actions/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..7f1061df93 --- /dev/null +++ b/.github/actions/release-info/action.yml @@ -0,0 +1,90 @@ +name: Release info +description: "Derive the version, release channel and package release number for this build." + +outputs: + version: + description: "The build version number." + value: ${{ steps.version.outputs.version }} + channel: + description: "The release channel this build belongs to." + value: ${{ steps.channel.outputs.channel }} + pkg_release: + description: "The package release number: 1 for a tag, the run number otherwise." + value: ${{ steps.pkg_release.outputs.pkg_release }} + +runs: + using: composite + steps: + # A tag names its own version. Anything else takes it from BuildInfo.cpp and + # appends the commit hash as build metadata, joined with a plus sign because a + # Conan version cannot contain two hyphens. + - name: Determine version + id: version + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + if [[ "${IS_TAG}" == "true" ]]; then + version="${REF_NAME}" + else + version="$(awk -F'"' '/versionString =/ { print $2 }' src/libxrpl/protocol/BuildInfo.cpp)" + if [[ -z "${version}" ]]; then + echo "Unable to read versionString from BuildInfo.cpp." >&2 + exit 1 + fi + version="${version}+${SHA:0:7}" + fi + + echo "version=${version}" | tee -a "${GITHUB_OUTPUT}" + + # Only a tag says how mature a build is: a push is a develop build whatever + # its version, and a non-public codebase keeps its packages to itself. + - name: Determine release channel + id: channel + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + REF_NAME: ${{ github.ref_name }} + VISIBILITY: ${{ github.event.repository.visibility }} + run: | + pre_release="" + if [[ "${REF_NAME}" == *-* ]]; then + pre_release="${REF_NAME#*-}" + fi + + if [[ "${VISIBILITY}" != "public" ]]; then + channel=private + elif [[ "${IS_TAG}" != "true" ]]; then + channel=develop + elif [[ -z "${pre_release}" ]]; then + channel=stable + elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then + channel=unstable + elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then + channel=experimental + else + echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2 + exit 1 + fi + + echo "channel=${channel}" | tee -a "${GITHUB_OUTPUT}" + + # A tag is packaged once, so its release number is fixed at 1. Develop builds + # repeat the same version, so the run number is what makes each push an + # upgrade rather than a reinstall. + - name: Determine package release + id: pkg_release + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + RUN_NUMBER: ${{ github.run_number }} + run: | + if [[ "${IS_TAG}" == "true" ]]; then + pkg_release=1 + else + pkg_release="${RUN_NUMBER}" + fi + + echo "pkg_release=${pkg_release}" | tee -a "${GITHUB_OUTPUT}" diff --git a/.github/actions/setup-nix-env/action.yml b/.github/actions/setup-nix-env/action.yml new file mode 100644 index 0000000000..a95053e536 --- /dev/null +++ b/.github/actions/setup-nix-env/action.yml @@ -0,0 +1,69 @@ +name: Setup Nix environment +description: "Build the flake's CI environment and put its tools on PATH." + +# The environment from nix/ci-env.nix, the same one the Linux CI images bake in +# (see nix/docker). Exported onto PATH rather than entered with `nix develop`: +# the composite actions below run plain `bash` and would escape a dev shell. + +runs: + using: composite + + steps: + - name: Build the CI environment + id: build + shell: bash + env: + # --out-link doubles as a GC root for the length of the job. + OUT_LINK: ${{ runner.temp }}/xrpld-ci-env + run: | + # --extra-experimental-features: flakes may not be on in the runner's nix.conf. + nix --extra-experimental-features "nix-command flakes" \ + build .#default --out-link "${OUT_LINK}" --print-build-logs + echo "path=$(readlink -f "${OUT_LINK}")" >>"${GITHUB_OUTPUT}" + + - name: Export the environment + shell: bash + env: + ENV_PATH: ${{ steps.build.outputs.path }} + run: | + echo "${ENV_PATH}/bin" >>"${GITHUB_PATH}" + + # Already KEY=VALUE per line. See `darwinEnv` in nix/ci-env.nix. + ENV_FILE="${ENV_PATH}/share/xrpld-ci-env/env" + if [ -f "${ENV_FILE}" ]; then + cat "${ENV_FILE}" >>"${GITHUB_ENV}" + fi + + # XrplSanity.cmake otherwise rejects a Nix compiler as one that leaked. + echo "XRPL_DEVSHELL=ci-env" >>"${GITHUB_ENV}" + + # Unlike the Linux nix images, macOS needs no SSL_CERT_FILE: it has its + # own trust store, and pinning would break TLS to hosts relying on it. + + # Workspace-local, so `cleanup-workspace` clears it, but not the + # `.conan2` prepare-runner hands the system toolchain: that Conan is a + # different version, and the two would migrate each other's cache. + echo "CONAN_HOME=${{ github.workspace }}/.conan2-nix" >>"${GITHUB_ENV}" + + # Config, profiles and remote, exactly as the dev shell sets them up on + # entry; the `setup-conan` action is skipped for this toolchain. + - name: Setup Conan + shell: bash + run: ./conan/init.sh + + # `Check tools` runs later but swallows failures; a bad export would just + # build with the system toolchain. + - name: Verify the toolchain resolves into the Nix store + shell: bash + run: | + for tool in clang clang++ cmake ninja conan; do + path="$(command -v "${tool}" || true)" + echo "${tool} -> ${path:-}" + case "${path}" in + /nix/store/*) ;; + *) + echo "::error::${tool} does not resolve into the Nix store" + exit 1 + ;; + esac + done diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 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 f1f7aa18f7..95d75c04b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,10 @@ @@ -15,7 +15,7 @@ https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your- Please include a summary of the changes. This may be a direct input to the release notes. If too broad, please consider splitting into multiple PRs. -If a relevant task or issue, please link it here. +If there is a relevant task or issue, please link it here. --> ### Context of Change @@ -65,5 +65,5 @@ This section may not be needed if your change includes thoroughly commented unit diff --git a/.github/scripts/levelization/README.md b/.github/scripts/levelization/README.md index f657344827..93748c43e1 100644 --- a/.github/scripts/levelization/README.md +++ b/.github/scripts/levelization/README.md @@ -40,18 +40,18 @@ listed later. | 04 | xrpl/protocol | | 05 | xrpl/core xrpl/resource xrpl/server | | 06 | xrpl/ledger xrpl/nodestore xrpl/net | -| 07 | xrpl/shamap | +| 07 | xrpl/shamap xrpl/consensus | ## xrpld Modules (Application Implementation) -| Level / Tier | Module(s) | -| ------------ | -------------------------------- | -| 05 | xrpld/conditions xrpld/consensus | -| 06 | xrpld/core xrpld/peerfinder | -| 07 | xrpld/shamap xrpld/overlay | -| 08 | xrpld/app | -| 09 | xrpld/rpc | -| 10 | xrpld/perflog | +| Level / Tier | Module(s) | +| ------------ | --------------------------- | +| 05 | xrpld/conditions | +| 06 | xrpld/core xrpld/peerfinder | +| 07 | xrpld/shamap xrpld/overlay | +| 08 | xrpld/app | +| 09 | xrpld/rpc | +| 10 | xrpld/perflog | ## Test Modules diff --git a/.github/scripts/levelization/results/loops.txt b/.github/scripts/levelization/results/loops.txt index cf70468e32..ea7b8a372a 100644 --- a/.github/scripts/levelization/results/loops.txt +++ b/.github/scripts/levelization/results/loops.txt @@ -1,9 +1,6 @@ Loop: xrpld.app xrpld.overlay xrpld.app > xrpld.overlay -Loop: xrpld.app xrpld.peerfinder - xrpld.peerfinder ~= xrpld.app - Loop: xrpld.app xrpld.rpc xrpld.rpc > xrpld.app diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index 3c9c514516..5577c363fd 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -6,6 +6,8 @@ libxrpl.conditions > xrpl.basics libxrpl.conditions > xrpl.conditions libxrpl.config > xrpl.basics libxrpl.config > xrpl.config +libxrpl.consensus > xrpl.basics +libxrpl.consensus > xrpl.consensus libxrpl.core > xrpl.basics libxrpl.core > xrpl.core libxrpl.core > xrpl.json @@ -25,6 +27,9 @@ libxrpl.nodestore > xrpl.config libxrpl.nodestore > xrpl.json libxrpl.nodestore > xrpl.nodestore libxrpl.nodestore > xrpl.protocol +libxrpl.peerfinder > xrpl.basics +libxrpl.peerfinder > xrpl.peerfinder +libxrpl.peerfinder > xrpl.protocol libxrpl.protocol > xrpl.basics libxrpl.protocol > xrpl.json libxrpl.protocol > xrpl.protocol @@ -60,9 +65,9 @@ test.app > test.jtx test.app > test.unit_test test.app > xrpl.basics test.app > xrpl.config +test.app > xrpl.consensus test.app > xrpl.core test.app > xrpld.app -test.app > xrpld.consensus test.app > xrpld.core test.app > xrpld.overlay test.app > xrpld.rpc @@ -83,12 +88,9 @@ test.basics > xrpl.protocol test.beast > xrpl.basics test.conditions > xrpl.basics test.conditions > xrpl.conditions -test.consensus > test.csf test.consensus > test.jtx -test.consensus > test.unit_test test.consensus > xrpl.basics test.consensus > xrpld.app -test.consensus > xrpld.consensus test.consensus > xrpl.ledger test.consensus > xrpl.protocol test.consensus > xrpl.shamap @@ -103,10 +105,6 @@ test.core > xrpl.json test.core > xrpl.protocol test.core > xrpl.rdb test.core > xrpl.server -test.csf > xrpl.basics -test.csf > xrpld.consensus -test.csf > xrpl.json -test.csf > xrpl.ledger test.json > test.jtx test.json > xrpl.json test.jtx > test.unit_test @@ -132,12 +130,9 @@ test.ledger > xrpl.json test.ledger > xrpl.ledger test.ledger > xrpl.protocol test.nodestore > test.jtx -test.nodestore > test.unit_test test.nodestore > xrpl.basics -test.nodestore > xrpl.config test.nodestore > xrpld.core test.nodestore > xrpl.nodestore -test.nodestore > xrpl.protocol test.nodestore > xrpl.rdb test.overlay > test.jtx test.overlay > test.unit_test @@ -146,19 +141,13 @@ test.overlay > xrpl.config test.overlay > xrpld.app test.overlay > xrpld.core test.overlay > xrpld.overlay -test.overlay > xrpld.peerfinder test.overlay > xrpl.json test.overlay > xrpl.nodestore +test.overlay > xrpl.peerfinder test.overlay > xrpl.protocol test.overlay > xrpl.resource test.overlay > xrpl.server test.overlay > xrpl.shamap -test.peerfinder > test.beast -test.peerfinder > test.unit_test -test.peerfinder > xrpl.basics -test.peerfinder > xrpld.core -test.peerfinder > xrpld.peerfinder -test.peerfinder > xrpl.protocol test.protocol > test.jtx test.protocol > test.unit_test test.protocol > xrpl.basics @@ -192,11 +181,13 @@ test.unit_test > xrpl.basics test.unit_test > xrpl.protocol tests.libxrpl > xrpl.basics tests.libxrpl > xrpl.config +tests.libxrpl > xrpl.consensus tests.libxrpl > xrpl.core tests.libxrpl > xrpl.json tests.libxrpl > xrpl.ledger tests.libxrpl > xrpl.net tests.libxrpl > xrpl.nodestore +tests.libxrpl > xrpl.peerfinder tests.libxrpl > xrpl.protocol tests.libxrpl > xrpl.protocol_autogen tests.libxrpl > xrpl.resource @@ -206,6 +197,10 @@ tests.libxrpl > xrpl.tx xrpl.conditions > xrpl.basics xrpl.conditions > xrpl.protocol xrpl.config > xrpl.basics +xrpl.consensus > xrpl.basics +xrpl.consensus > xrpl.json +xrpl.consensus > xrpl.ledger +xrpl.consensus > xrpl.protocol xrpl.core > xrpl.basics xrpl.core > xrpl.json xrpl.core > xrpl.protocol @@ -220,6 +215,8 @@ xrpl.nodestore > xrpl.basics xrpl.nodestore > xrpl.config xrpl.nodestore > xrpl.json xrpl.nodestore > xrpl.protocol +xrpl.peerfinder > xrpl.basics +xrpl.peerfinder > xrpl.protocol xrpl.protocol > xrpl.basics xrpl.protocol > xrpl.json xrpl.protocol_autogen > xrpl.json @@ -246,23 +243,20 @@ xrpl.tx > xrpl.protocol xrpld.app > test.unit_test xrpld.app > xrpl.basics xrpld.app > xrpl.config +xrpld.app > xrpl.consensus xrpld.app > xrpl.core -xrpld.app > xrpld.consensus xrpld.app > xrpld.core xrpld.app > xrpl.json xrpld.app > xrpl.ledger xrpld.app > xrpl.net xrpld.app > xrpl.nodestore +xrpld.app > xrpl.peerfinder xrpld.app > xrpl.protocol xrpld.app > xrpl.rdb xrpld.app > xrpl.resource xrpld.app > xrpl.server xrpld.app > xrpl.shamap xrpld.app > xrpl.tx -xrpld.consensus > xrpl.basics -xrpld.consensus > xrpl.json -xrpld.consensus > xrpl.ledger -xrpld.consensus > xrpl.protocol xrpld.core > xrpl.basics xrpld.core > xrpl.config xrpld.core > xrpl.core @@ -271,21 +265,22 @@ xrpld.core > xrpl.protocol xrpld.core > xrpl.rdb xrpld.overlay > xrpl.basics xrpld.overlay > xrpl.config +xrpld.overlay > xrpl.consensus xrpld.overlay > xrpl.core -xrpld.overlay > xrpld.consensus xrpld.overlay > xrpld.core xrpld.overlay > xrpld.peerfinder xrpld.overlay > xrpl.json xrpld.overlay > xrpl.ledger +xrpld.overlay > xrpl.peerfinder xrpld.overlay > xrpl.protocol xrpld.overlay > xrpl.resource xrpld.overlay > xrpl.server xrpld.overlay > xrpl.shamap xrpld.overlay > xrpl.tx xrpld.peerfinder > xrpl.basics -xrpld.peerfinder > xrpl.config +xrpld.peerfinder > xrpld.app xrpld.peerfinder > xrpld.core -xrpld.peerfinder > xrpl.protocol +xrpld.peerfinder > xrpl.peerfinder xrpld.peerfinder > xrpl.rdb xrpld.perflog > xrpl.basics xrpld.perflog > xrpl.config diff --git a/.github/scripts/rename/binary.sh b/.github/scripts/rename/binary.sh index 89d884538c..4a3e86675a 100755 --- a/.github/scripts/rename/binary.sh +++ b/.github/scripts/rename/binary.sh @@ -49,7 +49,7 @@ ${SED_COMMAND} -i -E 's@ripple/xrpld@XRPLF/rippled@g' BUILD.md ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' BUILD.md ${SED_COMMAND} -i -E 's@xrpld \(`xrpld`\)@xrpld@g' BUILD.md ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' CONTRIBUTING.md -${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/build/install.md +${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/install.md popd echo "Processing complete." diff --git a/.github/scripts/rename/docs.sh b/.github/scripts/rename/docs.sh index 9f080b06e5..9d7be209a3 100755 --- a/.github/scripts/rename/docs.sh +++ b/.github/scripts/rename/docs.sh @@ -77,8 +77,8 @@ ${SED_COMMAND} -i 's/Ripple integrators/XRPL developers/' README.md ${SED_COMMAND} -i 's/sanitizer-configuration-for-rippled/sanitizer-configuration-for-xrpld/' docs/build/sanitizers.md ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/levelization/README.md ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/strategy-matrix/generate.py -${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/build/install.md -${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/build/install.md +${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/install.md +${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/install.md ${SED_COMMAND} -i 's/rippled/xrpld/g' docs/Doxyfile ${SED_COMMAND} -i 's/ripple_basics/basics/' include/xrpl/basics/CountedObject.h ${SED_COMMAND} -i 's/ str: # Every config must declare 'minimal'. Minimal configs form the reduced matrix # built for pull requests by default; the full matrix adds the rest. Packaging # configs declare it too, but packaging is gated in the workflow, not by it. +# +# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that +# the flag applies to every entry a config expands into, so only set it on +# configs that expand to a single combination. @dataclasses.dataclass @@ -43,6 +53,7 @@ class LinuxConfig: build_type: list[str] arch: list[str] minimal: bool + benchmark: bool = False # if true, smoke-run the benchmarks after testing sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" @@ -81,7 +92,11 @@ class PlatformConfig: build_type: list[str] minimal: bool build_only: bool = False # if true, skip tests (e.g. macos/Windows Debug) + benchmark: bool = False # if true, smoke-run the benchmarks after testing extra_cmake_args: str = "" + # "" is the runner's system compiler, "nix" the flake's CI environment. + # macOS only: Linux always builds in a Nix image, Windows has no Nix. + toolchain: str = "" def __post_init__(self) -> None: if isinstance(self.build_type, str): @@ -125,18 +140,21 @@ class MatrixEntry: cmake_args: str cmake_target: str build_only: bool + benchmark: bool build_type: str architecture: Architecture sanitizers: str image: str = "" # container image; empty for macOS/Windows (runs natively) compiler: str = "" # compiler name ("gcc" or "clang"); empty for macOS/Windows + toolchain: str = "" # "nix" for the flake's CI environment; see PlatformConfig @dataclasses.dataclass class PackagingEntry: """One entry in the generated packaging strategy matrix.""" - artifact_name: str + xrpld_artifact_name: str + validator_keys_artifact_name: str image: str distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps @@ -193,6 +211,7 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), cmake_target="all", build_only=False, + benchmark=cfg.benchmark, build_type=build_type, architecture=arch_info, sanitizers=sanitizer, @@ -206,18 +225,23 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: """Generate the packaging matrix from a LinuxFile's package_configs section. - Packaging uses vanilla distro images (debian:bookworm, ubi9, …) instead of + Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of the nix-based build images, because deb/rpm tooling (debhelper, rpm-build) is taken from the distro's archive rather than from nixpkgs. Each config entry carries its own 'image'. + + The artifact names must match what the build job uploads: one artifact per + binary, each named after the build config. """ entries = [] for distro, configs in linux.package_configs.items(): for cfg in configs: for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): + config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64" entries.append( PackagingEntry( - artifact_name=f"xrpld-{distro}-{compiler}-{build_type.lower()}-amd64", + xrpld_artifact_name=f"xrpld-{config_name}", + validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, distro=distro, ) @@ -239,15 +263,20 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry] if minimal and not cfg.minimal: continue for build_type in cfg.build_type: + name = f"{platform_name}-{arch}-{build_type.lower()}" + if cfg.toolchain: + name += f"-{cfg.toolchain}" entries.append( MatrixEntry( - config_name=f"{platform_name}-{arch}-{build_type.lower()}", + config_name=name, cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), cmake_target="install" if is_windows else "all", build_only=cfg.build_only, + benchmark=cfg.benchmark, build_type=build_type, architecture=Architecture(platform=pf.platform, runner=pf.runner), sanitizers="", + toolchain=cfg.toolchain, ) ) return entries diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 7edbf96ef6..e739a42d5a 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,13 +1,23 @@ { - "image_tag": "sha-2e25435", + "image_tag": "sha-a0074f8", "configs": { "ubuntu": [ + { + "compiler": ["gcc"], + "build_type": ["Debug"], + "arch": ["amd64"], + "minimal": true, + "suffix": "coverage", + "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" + }, { "compiler": ["clang"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": true + "minimal": true, + "benchmark": true }, + { "compiler": ["gcc"], "build_type": ["Release"], @@ -29,14 +39,6 @@ "sanitizers": ["address", "undefinedbehavior"] }, - { - "compiler": ["gcc"], - "build_type": ["Debug"], - "arch": ["amd64"], - "minimal": true, - "suffix": "coverage", - "extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0" - }, { "compiler": ["clang"], "build_type": ["Debug"], @@ -68,7 +70,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ], @@ -77,7 +80,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ] }, @@ -88,7 +92,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" } ], @@ -98,7 +102,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" } ] } diff --git a/.github/scripts/strategy-matrix/macos.json b/.github/scripts/strategy-matrix/macos.json index 98e0f13141..554031009c 100644 --- a/.github/scripts/strategy-matrix/macos.json +++ b/.github/scripts/strategy-matrix/macos.json @@ -12,6 +12,19 @@ "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", "build_only": true, "minimal": false + }, + { + "build_type": "Release", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "toolchain": "nix", + "minimal": false + }, + { + "build_type": "Debug", + "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "toolchain": "nix", + "build_only": true, + "minimal": false } ] } diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index da28b8db49..813edd8aff 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -8,20 +8,24 @@ on: - ".github/workflows/build-nix-images.yml" - "flake.nix" - "flake.lock" + - "rust-toolchain.toml" - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" pull_request: paths: - ".github/workflows/build-nix-images.yml" - "flake.nix" - "flake.lock" + - "rust-toolchain.toml" - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" workflow_dispatch: @@ -54,7 +58,7 @@ jobs: base_image: debian:bookworm - name: rhel base_image: registry.access.redhat.com/ubi9/ubi:latest - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/nix-${{ matrix.distro.name }} dockerfile: nix/docker/Dockerfile diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index e72ea876a7..c927942fca 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -36,9 +36,10 @@ jobs: distro: - name: debian base_image: debian:bookworm + # AlmaLinux rather than UBI9, which does not ship rpm-sign. - name: rhel - base_image: registry.access.redhat.com/ubi9/ubi:latest - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c + base_image: almalinux:9 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} dockerfile: package/Dockerfile diff --git a/.github/workflows/build-pre-commit-image.yml b/.github/workflows/build-pre-commit-image.yml index 9b20dc7951..71f083b686 100644 --- a/.github/workflows/build-pre-commit-image.yml +++ b/.github/workflows/build-pre-commit-image.yml @@ -30,7 +30,7 @@ jobs: permissions: contents: read packages: write - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/pre-commit dockerfile: bin/pre-commit/Dockerfile diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml new file mode 100644 index 0000000000..d167e52e61 --- /dev/null +++ b/.github/workflows/cargo-audit.yml @@ -0,0 +1,80 @@ +name: Cargo audit + +on: + schedule: + # 06:32 UTC every Monday. + - cron: "32 6 * * 1" + push: + branches: + - "develop" + - "release/*" + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + pull_request: + paths: + - "crates/**/Cargo.toml" + - "crates/Cargo.lock" + - ".github/workflows/cargo-audit.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + permissions: + contents: read + # Needed to open an issue on scheduled failures. + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run cargo audit + id: audit + continue-on-error: true + run: | + set -o pipefail + cargo audit | tee /tmp/cargo-audit.txt + + - name: Prepare issue body + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + run: | + { + echo "## \`cargo audit\` found advisories" + echo + echo '```' + cat /tmp/cargo-audit.txt + echo '```' + echo + echo "---" + echo "*This issue was automatically created by the cargo-audit workflow.*" + } >/tmp/cargo-audit-issue.md + + - name: Create issue + if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }} + uses: XRPLF/actions/create-issue@2b8bc36af85b88bca0dd7bfac2e2dc05f94ad712 + with: + title: "cargo audit found vulnerabilities" + body_file: /tmp/cargo-audit-issue.md + labels: "Bug,Security" + + - name: Fail if advisories were found + if: ${{ steps.audit.outcome != 'success' }} + run: | + echo "cargo audit found advisories!" + cat /tmp/cargo-audit.txt + exit 1 diff --git a/.github/workflows/check-pr-description.yml b/.github/workflows/check-pr-description.yml index 744449f216..f8e7b6cdc4 100644 --- a/.github/workflows/check-pr-description.yml +++ b/.github/workflows/check-pr-description.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Write PR body to file env: diff --git a/.github/workflows/check-pr-title.yml b/.github/workflows/check-pr-title.yml index 4b5f679df1..cc80982440 100644 --- a/.github/workflows/check-pr-title.yml +++ b/.github/workflows/check-pr-title.yml @@ -20,4 +20,4 @@ on: jobs: check_title: if: ${{ github.event.pull_request.draft != true }} - uses: XRPLF/actions/.github/workflows/check-pr-title.yml@cba1f0891650baf1a9c88624dc2d72573be2eb81 + uses: XRPLF/actions/.github/workflows/check-pr-title.yml@d7c65e49225a38f6d8010eacf017bb5a98d7476c diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml new file mode 100644 index 0000000000..1169140481 --- /dev/null +++ b/.github/workflows/check-tools.yml @@ -0,0 +1,114 @@ +# Verifies the committed snapshots of `bin/check-tools.sh` output for each Nix +# environment (see nix/check-tools/). If the environment changes — a new image +# tag, an updated flake.lock, a different tool list — without the matching +# snapshot being regenerated and committed, this workflow fails so the drift is +# caught in review. +# +# To regenerate the snapshots, see nix/check-tools/README.md. +name: Check tools + +on: + pull_request: + paths: + - ".github/workflows/check-tools.yml" + - ".github/scripts/strategy-matrix/linux.json" + - "bin/check-tools.sh" + - "nix/**" + - "flake.nix" + - "flake.lock" + - "rust-toolchain.toml" + push: + branches: + - "develop" + paths: + - ".github/workflows/check-tools.yml" + - ".github/scripts/strategy-matrix/linux.json" + - "bin/check-tools.sh" + - "nix/**" + - "flake.nix" + - "flake.lock" + - "rust-toolchain.toml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + # The nix-nixos image tag is pinned alongside the build matrix in linux.json, + # so snapshots are checked against the exact image CI builds against. + linux-image-tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read nix image tag + id: tag + run: echo "tag=$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" >>"${GITHUB_OUTPUT}" + + # One job for all environments; they differ only in whether the tools come + # from the nix-nixos container (Linux) or `nix develop` (macOS). + check-tools: + needs: linux-image-tag + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + snapshot: nix/check-tools/nix-ubuntu-amd64.txt + nix_develop: false + - runner: ubuntu-24.04-arm + snapshot: nix/check-tools/nix-ubuntu-arm64.txt + nix_develop: false + - runner: macos-26-apple-clang-21 + snapshot: nix/check-tools/macos.txt + nix_develop: true + runs-on: ${{ matrix.runner }} + # Linux runs inside the pinned nix-nixos image; macOS runs natively and uses + # the flake's dev shell instead (see the run step below). + container: ${{ !matrix.nix_develop && format('ghcr.io/xrplf/xrpld/nix-ubuntu:{0}', needs.linux-image-tag.outputs.tag) || null }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + + - name: Regenerate snapshot + env: + CHECK_TOOLS_SKIP_CLONE: "1" + # check-tools.sh skips some macOS tools when CI is set; the snapshots + # capture the full `nix develop` environment, so unset it here. + CI: "" + run: | + if [ "${{ matrix.nix_develop }}" = "true" ]; then + # `nix develop` prints the dev-shell greeting first; keep only the + # check-tools.sh output (from the "Detected OS:" line onward). + nix --extra-experimental-features "nix-command flakes" develop \ + -c bash bin/check-tools.sh | sed -n '/^Detected OS:/,$p' >"${{ matrix.snapshot }}" + else + bash bin/check-tools.sh >"${{ matrix.snapshot }}" + fi + + - name: Verify snapshot is up to date + run: | + if ! git diff --exit-code -- "${{ matrix.snapshot }}"; then + echo "::error::${{ matrix.snapshot }} is out of date. Regenerate it (see nix/check-tools/README.md) and commit the result." + exit 1 + fi + + - name: Upload regenerated snapshot + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: check-tools-${{ runner.os }}-${{ runner.arch }} + path: ${{ matrix.snapshot }} diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 442a202a44..933c7b8a54 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Determine changed files # This step checks whether any files have changed that should # cause the next jobs to run. We do it this way rather than @@ -77,22 +77,28 @@ jobs: # Keep the paths below in sync with those in `on-trigger.yml`. .github/actions/build-deps/** - .github/actions/generate-version/** + .github/actions/release-info/** .github/actions/setup-conan/** + .github/actions/setup-nix-env/** .github/scripts/strategy-matrix/** .github/workflows/reusable-build-test-config.yml .github/workflows/reusable-build-test.yml + .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml .github/workflows/reusable-package.yml + .github/workflows/reusable-rust.yml .github/workflows/reusable-strategy-matrix.yml .github/workflows/reusable-test.yml .github/workflows/reusable-upload-recipe.yml .clang-tidy .codecov.yml + bin/check-nix-store-refs.sh bin/check-tools.sh + bin/default-loader-path.sh cfg/** cmake/** conan/** + crates/** external/** include/** src/** @@ -100,6 +106,9 @@ jobs: CMakeLists.txt conanfile.py conan.lock + flake.lock + flake.nix + nix/** LICENSE.md package/** README.md @@ -125,6 +134,11 @@ jobs: outputs: go: ${{ steps.go.outputs.go == 'true' }} + check-autogen: + needs: should-run + if: ${{ needs.should-run.outputs.go == 'true' }} + uses: ./.github/workflows/reusable-check-autogen.yml + check-levelization: needs: should-run if: ${{ needs.should-run.outputs.go == 'true' }} @@ -161,6 +175,13 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + needs: should-run + if: ${{ needs.should-run.outputs.go == 'true' }} + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + package: needs: [should-run, build-test] # Packaging consumes the debian/rhel release binaries, which are only built @@ -199,10 +220,12 @@ jobs: passed: if: failure() || cancelled() needs: + - check-autogen - check-levelization - check-rename - clang-tidy - build-test + - rust - package - upload-recipe - notify-clio diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index abedc13d69..d8a9a5113e 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -1,5 +1,9 @@ -# This workflow uploads the libxrpl recipe to the Conan remote and builds -# release packages when a versioned tag is pushed. +# When a versioned tag is pushed, this workflow: +# +# - uploads the libxrpl recipe to the Conan remote +# - builds and tests the release binaries +# - builds the DEB and RPM packages +# - publishes those packages to the XRPLF package repositories name: Tag on: @@ -24,7 +28,7 @@ jobs: remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} build-test: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} uses: ./.github/workflows/reusable-build-test.yml strategy: fail-fast: true @@ -37,6 +41,12 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} package: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + publish: true + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 49a93d2746..2099f5f739 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -15,22 +15,28 @@ on: # Keep the paths below in sync with those in `on-pr.yml`. - ".github/actions/build-deps/**" - - ".github/actions/generate-version/**" + - ".github/actions/release-info/**" - ".github/actions/setup-conan/**" + - ".github/actions/setup-nix-env/**" - ".github/scripts/strategy-matrix/**" - ".github/workflows/reusable-build-test-config.yml" - ".github/workflows/reusable-build-test.yml" + - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" - ".github/workflows/reusable-package.yml" + - ".github/workflows/reusable-rust.yml" - ".github/workflows/reusable-strategy-matrix.yml" - ".github/workflows/reusable-test.yml" - ".github/workflows/reusable-upload-recipe.yml" - ".clang-tidy" - ".codecov.yml" + - "bin/check-nix-store-refs.sh" - "bin/check-tools.sh" + - "bin/default-loader-path.sh" - "cfg/**" - "cmake/**" - "conan/**" + - "crates/**" - "external/**" - "include/**" - "src/**" @@ -38,6 +44,9 @@ on: - "CMakeLists.txt" - "conanfile.py" - "conan.lock" + - "flake.lock" + - "flake.nix" + - "nix/**" - "LICENSE.md" - "package/**" - "README.md" @@ -66,6 +75,9 @@ defaults: shell: bash jobs: + check-autogen: + uses: ./.github/workflows/reusable-check-autogen.yml + clang-tidy: uses: ./.github/workflows/reusable-clang-tidy.yml permissions: @@ -91,6 +103,11 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + rust: + uses: ./.github/workflows/reusable-rust.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + upload-recipe: needs: build-test # Only run when pushing to the develop branch. @@ -103,3 +120,11 @@ jobs: package: needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + # Packages are built on every trigger; only develop pushes in XRPLF/rippled + # publish them, matching upload-recipe above. + publish: ${{ github.repository == 'XRPLF/rippled' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9970e9a07d..905e910591 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -14,7 +14,7 @@ on: jobs: # Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks. run-hooks: - uses: XRPLF/actions/.github/workflows/pre-commit.yml@1bde119a1ab71305ba5d3716e7a82cea1c7bdede + uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131 with: runs_on: ubuntu-latest container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 90182e7cbb..b8ca7751ab 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,13 +41,13 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-2e25435 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2 + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 69fa7a7bef..2846c3fb85 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -3,6 +3,12 @@ name: Build and test configuration on: workflow_call: inputs: + benchmark: + description: "Whether to smoke-run the benchmarks after testing." + required: false + type: boolean + default: false + build_only: description: 'Whether to only build or to build and test the code ("true", "false").' required: true @@ -63,6 +69,12 @@ on: type: string default: "" + toolchain: + description: 'Where the toolchain comes from ("nix" to build the flake CI environment on the runner, empty for the system one). macOS only: Linux always builds in a Nix image, and Nix has no Windows support.' + required: false + type: string + default: "" + secrets: CODECOV_TOKEN: description: "The Codecov token to use for uploading coverage reports." @@ -100,23 +112,32 @@ jobs: # header files are copied into separate directories by CMake, which will # otherwise result in cache misses. CCACHE_SLOPPINESS: include_file_ctime,include_file_mtime - # Determine if coverage and voidstar should be enabled. + # Determine if coverage, voidstar and validator-keys should be enabled. COVERAGE_ENABLED: ${{ contains(inputs.cmake_args, '-Dcoverage=ON') }} VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} + VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }} SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }} + # The binaries reusable-package.yml consumes. A private repository skips + # them except on a tag push, which is what produces its release packages. + PACKAGING_ARTIFACTS_ENABLED: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} steps: - name: Cleanup workspace (macOS and Windows) if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2 + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: ${{ inputs.ccache_enabled }} + # Before any step that uses a build tool, composite actions included. + - name: Setup Nix environment + if: ${{ inputs.toolchain == 'nix' }} + uses: ./.github/actions/setup-nix-env + - name: Set ccache log file if: ${{ inputs.ccache_enabled && runner.debug == '1' }} run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >>"${GITHUB_ENV}" @@ -141,7 +162,21 @@ jobs: with: compiler: ${{ inputs.compiler }} + - name: Use cargo artifacts cache + uses: ./.github/actions/cargo-cache + with: + cache-directories: ${{ env.BUILD_DIR }}/corrosion + key: ${{ inputs.config_name }} + # two workspaces here because build artifacts are located in 2 places: + # - crates/target when cargo is called directly + # - build/cargo when cargo is called by cmake + workspaces: | + crates + crates -> ${{ runner.os == 'Windows' && format('../{0}/x64/{1}/cargo', env.BUILD_DIR, inputs.build_type) || format('../{0}/cargo', env.BUILD_DIR) }} + + # `setup-nix-env` already did this for the Nix toolchain. - name: Setup Conan + if: ${{ inputs.toolchain != 'nix' }} env: SANITIZERS: ${{ inputs.sanitizers }} uses: ./.github/actions/setup-conan @@ -170,9 +205,9 @@ jobs: .. # Export the sanitizer options before any instrumented binary runs. The - # protocol code-gen and build steps below invoke instrumented dependency - # tools (protoc, grpc), so setting UBSAN_OPTIONS here lets the UBSan - # suppression list silence their diagnostics too, not just at test time. + # build step below invokes instrumented dependency tools (protoc, grpc), + # so setting UBSAN_OPTIONS here lets the UBSan suppression list silence + # their diagnostics too, not just at test time. # GITHUB_WORKSPACE (not the github.workspace context) is used so the path # resolves correctly inside the container job. - name: Set sanitizer options @@ -190,32 +225,6 @@ jobs: echo "UBSAN_OPTIONS=include=${SUPP}/runtime-ubsan-options.txt:suppressions=${SUPP}/ubsan.supp" >>${GITHUB_ENV} echo "LSAN_OPTIONS=include=${SUPP}/runtime-lsan-options.txt:suppressions=${SUPP}/lsan.supp" >>${GITHUB_ENV} - - name: Check protocol autogen files are up-to-date - working-directory: ${{ env.BUILD_DIR }} - env: - MESSAGE: | - - The generated protocol wrapper classes are out of date. - - This typically happens when the macro files or generator scripts - have changed but the generated files were not regenerated. - - To fix this: - 1. Run: cmake --build . --target setup_code_gen - 2. Run: cmake --build . --target code_gen - 3. Commit and push the regenerated files - run: | - set -e - cmake --build . --target setup_code_gen - cmake --build . --target code_gen - DIFF=$(git -C .. status --porcelain -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen) - if [ -n "${DIFF}" ]; then - echo "::error::Generated protocol files are out of date" - git -C .. diff -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen - echo "${MESSAGE}" - exit 1 - fi - - name: Build the binary working-directory: ${{ env.BUILD_DIR }} env: @@ -231,6 +240,24 @@ jobs: --target "${CMAKE_TARGET}" \ 2>&1 | tee "${GITHUB_WORKSPACE}/build.log" + # Nothing may reference the store, so whole trees are checked - the Conan + # cache included, since what it holds is what gets uploaded and reused. + - name: Check the build output for Nix store references (Nix toolchain) + if: ${{ inputs.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${BUILD_DIR}" + + - name: Check the Conan cache for Nix store references (Nix toolchain) + if: ${{ inputs.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}" + + # Only what PatchNixBinary.cmake retargets: the toolchain in the Linux + # images always references the store. Same condition it uses. + - name: Check for Nix store references (Linux) + if: ${{ runner.os == 'Linux' && env.SANITIZERS_ENABLED == 'false' }} + run: | + ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpld" + ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpl_tests" + - name: Show ccache statistics if: ${{ inputs.ccache_enabled }} run: | @@ -241,7 +268,7 @@ jobs: fi - name: Upload the binary (Linux) - if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: xrpld-${{ inputs.config_name }} @@ -249,6 +276,22 @@ jobs: retention-days: 3 if-no-files-found: error + - name: Run the validator-keys tests + if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' }} + working-directory: ${{ env.BUILD_DIR }} + run: ./validator-keys --unittest + + - name: Upload the validator-keys binary + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && env.VALIDATOR_KEYS_ENABLED == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: validator-keys-${{ inputs.config_name }} + path: | + ${{ env.BUILD_DIR }}/validator-keys + ${{ env.BUILD_DIR }}/validator-keys-LICENSE + retention-days: 3 + if-no-files-found: error + - name: Upload the test binary (Linux) if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -266,7 +309,7 @@ jobs: ./xrpld --definitions | python3 -m json.tool >server_definitions.json - name: Upload server definitions - if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'debian-gcc-release-amd64' }} + if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'ubuntu-gcc-debug-amd64-coverage' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: server-definitions @@ -326,13 +369,21 @@ jobs: LD_PRELOAD="$PRELOAD" ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee "${GITHUB_WORKSPACE}/unittest.log" + - name: Run Rust tests + if: ${{ !inputs.build_only }} + working-directory: crates + run: cargo nextest run --workspace --all-features --locked --no-tests=warn + # Smoke-run every benchmark module with a single repetition to confirm the # benchmarks still build and execute. This is a correctness check, not a - # performance measurement, so it is skipped for instrumented builds - # (sanitizers/coverage/voidstar), where it would be slow and meaningless, - # and on Windows, where the `install` target does not build them. + # performance measurement, so there is nothing to gain from repeating it + # across configurations: it is opted into by a single config in the + # strategy matrix (see the 'benchmark' flag in the JSON files), which + # keeps it off instrumented builds (sanitizers/coverage/voidstar), where + # it would be slow and meaningless, off Debug builds, where it is much + # slower, and off Windows, where the `install` target does not build them. - name: Run the benchmarks - if: ${{ !inputs.build_only && runner.os != 'Windows' && env.SANITIZERS_ENABLED == 'false' && env.COVERAGE_ENABLED != 'true' && env.VOIDSTAR_ENABLED != 'true' }} + if: ${{ inputs.benchmark }} working-directory: ${{ env.BUILD_DIR }} run: | rc=0 @@ -387,13 +438,14 @@ jobs: --target coverage - name: Upload coverage report - if: ${{ github.repository == 'XRPLF/rippled' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} + if: ${{ github.repository_owner == 'XRPLF' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true disable_telem: true fail_ci_if_error: true files: ${{ env.BUILD_DIR }}/coverage.xml + flags: cpp plugins: noop token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml index 4b64c53521..7ea106f438 100644 --- a/.github/workflows/reusable-build-test.yml +++ b/.github/workflows/reusable-build-test.yml @@ -40,6 +40,7 @@ jobs: fail-fast: ${{ github.event_name == 'merge_group' }} matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} with: + benchmark: ${{ matrix.benchmark }} build_only: ${{ matrix.build_only }} build_type: ${{ matrix.build_type }} ccache_enabled: ${{ inputs.ccache_enabled }} @@ -50,5 +51,6 @@ jobs: config_name: ${{ matrix.config_name }} sanitizers: ${{ matrix.sanitizers }} compiler: ${{ matrix.compiler || '' }} + toolchain: ${{ matrix.toolchain || '' }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/reusable-check-autogen.yml b/.github/workflows/reusable-check-autogen.yml new file mode 100644 index 0000000000..bb77ea85a9 --- /dev/null +++ b/.github/workflows/reusable-check-autogen.yml @@ -0,0 +1,76 @@ +# This workflow checks that the generated protocol wrapper classes are +# up-to-date with the macro files and generator scripts they are produced from, +# see more info in include/xrpl/protocol_autogen/README.md. +name: Check autogen + +# This workflow can only be triggered by other workflows. +on: workflow_call + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-autogen + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + BUILD_DIR: build/codegen + +jobs: + autogen: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + # Code generation is pure Python, so the standalone project below offers + # the same targets as the main build without needing its dependencies or + # a compiler, which keeps this job down to a few seconds. + - name: Configure CMake + run: cmake -S cmake/codegen -B "${BUILD_DIR}" + + - name: Install code generation dependencies + run: cmake --build "${BUILD_DIR}" --target setup_code_gen + + - name: Generate code + run: cmake --build "${BUILD_DIR}" --target code_gen + + - name: Check for differences + env: + MESSAGE: | + + The generated protocol wrapper classes are out of date. + + This typically happens when the macro files or generator scripts + have changed but the generated files were not regenerated. + + Run the following from the repository root, then commit and push + the regenerated files. This needs neither the dependencies nor a + compiler. See include/xrpl/protocol_autogen/README.md for more info. + + cmake -S cmake/codegen -B build/codegen + cmake --build build/codegen --target setup_code_gen + cmake --build build/codegen --target code_gen + + In an already configured build directory, the 'setup_code_gen' and + 'code_gen' targets do the same thing. + run: | + # Record untracked files in the index without staging their contents, + # so that classes generated for a newly added transaction or ledger + # entry type show up in the diff below rather than silently as an + # empty one. + git add --intent-to-add . + DIFF=$(git status --porcelain) + if [ -n "${DIFF}" ]; then + # Print the differences to give the contributor a hint about what to + # expect when running code generation on their own machine. + git diff + echo "${MESSAGE}" + exit 1 + fi diff --git a/.github/workflows/reusable-check-levelization.yml b/.github/workflows/reusable-check-levelization.yml index 88c95ac3ba..7f547f2ab6 100644 --- a/.github/workflows/reusable-check-levelization.yml +++ b/.github/workflows/reusable-check-levelization.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check levelization run: python .github/scripts/levelization/generate.py - name: Check for differences diff --git a/.github/workflows/reusable-check-rename.yml b/.github/workflows/reusable-check-rename.yml index 9a91e98ee3..874c8adcde 100644 --- a/.github/workflows/reusable-check-rename.yml +++ b/.github/workflows/reusable-check-rename.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check definitions run: .github/scripts/rename/definitions.sh . - name: Check copyright notices diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 4a10e4b0f7..ac21c83ea0 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -27,23 +27,23 @@ jobs: determine-files: permissions: contents: read - uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@d041ac9f1fa9f07a4ba335eb4c1c82233fb3fef6 + uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@70145243b905dc3e040a61d39c00e178cfb96f71 run-clang-tidy: name: Run clang tidy needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-2e25435" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8" permissions: contents: read issues: write steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2 + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false @@ -59,6 +59,12 @@ jobs: with: compiler: ${{ env.COMPILER }} + - name: Use cargo artifacts cache + uses: ./.github/actions/cargo-cache + with: + cache-directories: ${{ env.BUILD_DIR }}/corrosion + workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo + - name: Setup Conan uses: ./.github/actions/setup-conan @@ -80,13 +86,13 @@ jobs: -Dwerr=ON \ -Dxrpld=ON \ -Dverify_headers=ON \ + -Drust=ON \ .. - # clang-tidy needs headers generated from proto files - - name: Build libxrpl.libpb + - name: Build clang-tidy prerequisites working-directory: ${{ env.BUILD_DIR }} run: | - ninja -j ${{ steps.nproc.outputs.nproc }} xrpl.libpb + ninja -j ${{ steps.nproc.outputs.nproc }} tidy_prerequisites - name: Run clang tidy id: run_clang_tidy @@ -95,7 +101,7 @@ jobs: TARGETS: ${{ needs.determine-files.outputs.need_full_run != 'true' && needs.determine-files.outputs.cpp_changed_files || 'include src tests' }} run: | set -o pipefail - run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}" + run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -format -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}" - name: Print filtered clang-tidy errors if: ${{ steps.run_clang_tidy.outcome != 'success' }} diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 55bc20dc5c..cfae706ee1 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,17 +1,37 @@ -# Build Linux packages (DEB and RPM) from pre-built binary artifacts. -# Discovers which configurations to package from linux.json (configs in -# "package_configs") and fans out one job per distro. Only linux/amd64 is -# supported; the runner is hardcoded in the job below. +# Build Linux packages from the pre-built xrpld and validator-keys artifacts: +# +# - one job per distro, taken from "package_configs" in linux.json +# - each job runs in that distro's container, which is what decides DEB or RPM +# - with 'publish: true' a job also uploads what it built +# (see package/publish_pkg.sh) +# +# Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package on: workflow_call: inputs: - pkg_release: - description: "Package release number. Increment when repackaging the same executable." + publish: + description: "Whether to publish the packages after building them." + required: false + type: boolean + default: false + nexus_url: + description: "The base URL of the Nexus instance hosting the deb and rpm repositories." required: false type: string - default: "1" + default: https://packages.xrplf.org + + secrets: + remote_username: + description: "The username of a Nexus account with write access to the repositories." + required: false + remote_password: + description: "The password or token for that Nexus account." + required: false + signing_key: + description: "Armoured PGP private key used to sign the RPMs. Required when publishing." + required: false defaults: run: @@ -27,7 +47,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -41,11 +61,11 @@ jobs: package: needs: [generate-matrix] - if: ${{ github.event.repository.visibility == 'public' }} + if: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: "${{ matrix.artifact_name }}" + name: "${{ matrix.xrpld_artifact_name }}" permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] @@ -54,28 +74,56 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Download pre-built binary + - name: Download pre-built xrpld binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ matrix.artifact_name }} + name: ${{ matrix.xrpld_artifact_name }} path: ${{ env.BUILD_DIR }} - - name: Make binary executable - run: chmod +x "${BUILD_DIR}/xrpld" + - name: Download pre-built validator-keys binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.validator_keys_artifact_name }} + path: ${{ env.BUILD_DIR }} + + - name: Make binaries executable + run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys" + + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info - name: Build package env: - PKG_RELEASE: ${{ inputs.pkg_release }} + PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} + PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} run: ./package/build_pkg.sh + # Before the upload, so the artifact and the published package are the + # same bytes. DEBs are not signed, so the key is never set on that job. + - name: Sign RPM + if: ${{ inputs.publish && matrix.distro == 'rhel' }} + env: + PKG_SIGNING_KEY: ${{ secrets.signing_key }} + run: ./package/sign_rpm.sh "${BUILD_DIR}" + - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ${{ matrix.artifact_name }}-pkg + name: ${{ matrix.xrpld_artifact_name }}-pkg path: | ${{ env.BUILD_DIR }}/debbuild/*.deb ${{ env.BUILD_DIR }}/debbuild/*.ddeb ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/*.rpm if-no-files-found: error + + - name: Publish package + if: ${{ inputs.publish }} + env: + CHANNEL: ${{ steps.release_info.outputs.channel }} + NEXUS_URL: ${{ inputs.nexus_url }} + NEXUS_USERNAME: ${{ secrets.remote_username }} + NEXUS_PASSWORD: ${{ secrets.remote_password }} + run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml new file mode 100644 index 0000000000..83301f97ad --- /dev/null +++ b/.github/workflows/reusable-rust.yml @@ -0,0 +1,80 @@ +# Clippy, coverage and documentation for the Rust crates in crates/. Each runs +# as an independent job on a GitHub-hosted runner, but inside the same container +# image used to build the crates in the C++/Corrosion path, so the toolchain +# (and therefore the lints, coverage instrumentation and the cargo cache) matches +# what production builds use. +# +# Rust unit tests are deliberately NOT run here. They run as part of the C++ +# build (reusable-build-test-config.yml), which already compiles the crates on a +# self-hosted runner, so there is no need to provision a toolchain again. +name: Rust + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + description: "The Codecov token to use for uploading coverage reports." + required: true + +defaults: + run: + shell: bash + working-directory: crates + +permissions: + contents: read + +jobs: + clippy: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: ./.github/actions/cargo-cache + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + coverage: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: ./.github/actions/cargo-cache + + - name: Generate coverage report + run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info + + - name: Upload coverage report + if: ${{ github.repository == 'XRPLF/rippled' }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + disable_telem: true + fail_ci_if_error: true + files: crates/lcov.info + flags: rust + plugins: noop + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + doc: + runs-on: ubuntu-latest + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Use cargo artifacts cache + uses: ./.github/actions/cargo-cache + + - name: Build documentation + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --workspace --no-deps --all-features --locked diff --git a/.github/workflows/reusable-strategy-matrix.yml b/.github/workflows/reusable-strategy-matrix.yml index de8d9cfc8e..12f11b0fbe 100644 --- a/.github/workflows/reusable-strategy-matrix.yml +++ b/.github/workflows/reusable-strategy-matrix.yml @@ -23,7 +23,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 07077163ed..680d95fb97 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,18 +40,18 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-2e25435 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Generate build version number - id: version - uses: ./.github/actions/generate-version + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info - name: Set up Conan uses: ./.github/actions/setup-conan @@ -64,8 +64,8 @@ jobs: - name: Upload Conan recipe (version) run: | - conan export . --version=${{ steps.version.outputs.version }} - conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }} + conan export . --version=${{ steps.release_info.outputs.version }} + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.release_info.outputs.version }} # When this workflow is triggered by a push event, it will always be when merging into the # 'develop' branch, see on-trigger.yml. @@ -92,4 +92,4 @@ jobs: conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release outputs: - ref: xrpl/${{ steps.version.outputs.version }} + ref: xrpl/${{ steps.release_info.outputs.version }} diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index abc0867b15..184f13cc5e 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -65,13 +65,18 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2 + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false + # Before any step that uses a build tool, composite actions included. + - name: Setup Nix environment + if: ${{ matrix.toolchain == 'nix' }} + uses: ./.github/actions/setup-nix-env + - name: Print build environment uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574 @@ -87,7 +92,9 @@ jobs: with: compiler: ${{ matrix.compiler }} + # `setup-nix-env` already did this for the Nix toolchain. - name: Setup Conan + if: ${{ matrix.toolchain != 'nix' }} env: SANITIZERS: ${{ matrix.sanitizers }} uses: ./.github/actions/setup-conan @@ -106,6 +113,10 @@ jobs: log_verbosity: ${{ runner.os == 'Windows' && 'quiet' || 'verbose' }} sanitizers: ${{ matrix.sanitizers }} + - name: Check the Conan cache for Nix store references (Nix toolchain) + if: ${{ matrix.toolchain == 'nix' }} + run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}" + - name: Log into Conan remote if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.NEXUS_REMOTE_USERNAME }}" --password "${{ secrets.NEXUS_REMOTE_PASSWORD }}" diff --git a/.gitignore b/.gitignore index 6bd34ece04..c5af8eb7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -81,8 +81,14 @@ DerivedData # Python __pycache__ +# Rust build artifacts. +target/ + # Direnv's directory /.direnv # clangd cache /.cache + +# Rust build directory +crates/target diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d339cb29ed..e5e69759fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,15 @@ repos: types_or: [c++, c, proto] exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/ + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --manifest-path crates/Cargo.toml --all + language: system + types: [rust] + pass_filenames: false # rustfmt formats the whole workspace + - repo: https://github.com/BlankSpruce/gersemi-pre-commit rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7 hooks: diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index a04f265328..d521f9c024 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -42,6 +42,7 @@ This section contains changes targeting a future version. ### Bugfixes +- `get_aggregate_price`: Duplicate entries in the `oracles` request array are now ignored. [#6586](https://github.com/XRPLF/rippled/pull/6586) - Peer Crawler: The `port` field in `overlay.active[]` now consistently returns an integer instead of a string for outbound peers. [#6318](https://github.com/XRPLF/rippled/pull/6318) - `ping`: The `ip` field is no longer returned as an empty string for proxied connections without a forwarded-for header. It is now omitted, consistent with the behavior for identified connections. [#6730](https://github.com/XRPLF/rippled/pull/6730) - gRPC `GetLedgerDiff`: Fixed error message that incorrectly said "base ledger not validated" when the desired ledger was not validated. [#6730](https://github.com/XRPLF/rippled/pull/6730) @@ -53,6 +54,11 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `vault_info`: Errors now identify what the request got wrong instead of reporting every failure as the unregistered token `malformedRequest`, and the `error`, `error_code` and `error_message` fields now agree with each other. An invalid `vault_id` or `seq` returns `invalidParams`, an invalid `owner` returns `actMalformed`, and a request that mixes `vault_id` with `owner`/`seq` or supplies neither returns `invalidParams` with a message naming the accepted combinations. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: A well-formed all-zero `vault_id` now returns `entryNotFound` instead of being rejected as malformed, and `entryNotFound` responses now include `error_code` and `error_message`. Clients that request `ripplerpc` 3.0 or above therefore receive HTTP 400 with that error rather than HTTP 200. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: `vault_id` and `owner` must now be strings, matching how `ledger_entry` reads the same fields. An object or an array in either field previously produced an internal error, and a number was silently converted to its decimal text; `vault_id` now returns `invalidParams` and `owner` returns `actMalformed`. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) +- `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) ## XRP Ledger server version 3.1.0 diff --git a/BUILD.md b/BUILD.md index a15c94edc9..e98d204d0b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -4,34 +4,14 @@ ## Minimum Requirements -See [System Requirements](https://xrpl.org/system-requirements.html). +For the hardware needed to run a node, see +[System Requirements](https://xrpl.org/system-requirements.html). -Building xrpld generally requires Git, Python, Conan, CMake, and a C++ -compiler. - -- [Python](https://www.python.org/downloads/) -- [Conan](https://conan.io/downloads.html) -- [CMake](https://cmake.org/download/) - -You can verify that the required tools are installed and runnable with: - -```bash -./bin/check-tools.sh -``` - -`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are: - -| Compiler | Version | -| ----------- | --------------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 21 | -| MSVC | 19.44[^windows] | +For the software needed to build xrpld, see the +[environment setup guide](./docs/build/environment.md). ## Operating Systems -Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms. - ### Linux The Ubuntu Linux distribution has received the highest level of quality @@ -42,12 +22,13 @@ Our Linux CI tooling is distro-independent and uses a Nix-based environment, so ### macOS Many `xrpld` engineers use macOS for development. +The minimum supported version is macOS 15 (Sequoia). +CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOYMENT_TARGET` to 15. ### Windows -Windows is used by some engineers for development only. - -[^windows]: Windows is not recommended for production use. +Windows is used by some engineers for development only, and is not recommended +for production use. ## Steps @@ -72,37 +53,25 @@ releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan -After you have a [C++ development environment](./docs/build/environment.md) ready with Git, Python, -Conan, CMake, and a C++ compiler, you may need to set up your Conan profile. - -These instructions assume a basic familiarity with Conan and CMake. If you are -unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official -[Getting Started][conan-getting-started] walkthrough. - -#### Profiles - -We recommend that you install our Conan profiles: +Once your [development environment](./docs/build/environment.md) is ready, set +Conan up for this repository: ```bash -conan config install conan/profiles/ -tf $(conan config home)/profiles/ +./conan/init.sh ``` -You can check your Conan profile by running: +That installs our [`global.conf`](./conan/global.conf), our Conan +[profiles](./conan/profiles), and the `xrplf` remote that hosts some of our +dependencies. It honours `CONAN_HOME` and never deletes an existing Conan home, +so it is safe to re-run — it only overwrites the files it manages. -```bash -conan profile show -``` +> [!TIP] +> In the [Nix development shell](./docs/build/nix.md#conan-configuration) this is +> already done for you: the script runs on entry. -If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan. -More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md). - -#### Add xrplf remote - -Run the following command to add the `xrplf` remote, which hosts some of our dependencies: - -```bash -conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ -``` +You can inspect the resulting profile with `conan profile show`. If it is not +suitable for your environment, create a custom profile and pass it to Conan — see +[Advanced Conan configuration](./docs/build/advanced_conan.md). ### Set Up Ccache @@ -245,7 +214,17 @@ cmake --build . --target setup_code_gen # create venv and install dependencies cmake --build . --target code_gen # regenerate code ``` -The regenerated files should be committed alongside your changes. +The same targets are also available as a standalone project, which does not +need the dependencies to be configured first: + +``` +cmake -S cmake/codegen -B build/codegen +cmake --build build/codegen --target setup_code_gen +cmake --build build/codegen --target code_gen +``` + +The regenerated files should be committed alongside your changes. CI verifies +that they are up-to-date. ## Coverage report @@ -257,10 +236,14 @@ which is only enabled when the `coverage` option is set, e.g. with Prerequisites for the coverage report: - [gcovr tool][gcovr] (can be installed e.g. with [pip][python-pip]) -- `gcov` for GCC (installed with the compiler by default) or -- `llvm-cov` for Clang (installed with the compiler by default) +- `gcov` for GCC or `llvm-cov` for Clang, usually installed with the compiler - `Debug` build type +> [!NOTE] +> Clang coverage is not available in the [Nix development shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell): +> its `clang` shells do not ship `llvm-cov`. Use a `gcc` shell instead (`.#gcc`, +> or `.#gcc-plain` on Linux), which provides a `gcov` matching its compiler. + A coverage report is created when the following steps are completed, in order: 1. `xrpld` binary built with instrumentation data, enabled by the `coverage` @@ -321,6 +304,7 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details. | ---------------- | ------------- | ----------------------------------------------------------------------------- | | `assert` | OFF | Force enabling assertions. | | `coverage` | OFF | Prepare the coverage report. | +| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. | | `tests` | OFF | Build tests. | | `unity` | OFF | Configure a unity build. | | `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. | @@ -333,6 +317,30 @@ memory) since they concatenate sources into fewer translation units. Non-unity builds may be faster for incremental builds, and can be helpful for detecting `#include` omissions. +### Rust crates + +The Rust crates in `crates/` are only part of the build when `rust` is ON. With +`-Drust=OFF` (the default) the `crates` directory is not added to the build, no +cxxbridge bindings are generated, and the C++ tests that exercise the Rust +interop are not compiled — so no Rust toolchain is needed. CI builds always pass +`-Drust=ON`. + +With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`, +`rustc`) matching the channel pinned in +[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and +generates the cxxbridge bindings. It is provided by the +[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without +any extra setup; otherwise install it as described in +[Rust](./docs/build/environment.md#rust). + +The crates also have their own Rust unit tests. Those are run with `cargo` and +need only the Rust toolchain, independently of CMake and of the `rust` option +(CI runs them with `cargo nextest`): + +```bash +cargo test --manifest-path crates/Cargo.toml --workspace +``` + ### Verifying headers The regular build only compiles `.cpp` files, so a header is only ever checked @@ -377,10 +385,14 @@ After any updates or changes to dependencies, you may need to do the following: 4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). +If you are using the Nix development shell, whether prebuilt Conan binaries apply +depends on your platform — see +[Prebuilt packages](./docs/build/nix.md#prebuilt-packages). + #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, -please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). +please [set Conan up](#set-up-conan) so the `xrplf` remote is configured, or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). ### `protobuf/port_def.inc` file not found @@ -400,7 +412,6 @@ For example, if you want to build Debug: 1. For conan install, pass `--settings build_type=Debug` 2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug` -[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 [conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html [unity-build]: https://en.wikipedia.org/wiki/Unity_build [gcovr]: https://gcovr.com/en/stable/getting-started.html diff --git a/CMakeLists.txt b/CMakeLists.txt index f2e8fb3ae5..a324cecedc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,23 @@ if(DEFINED CMAKE_MODULE_PATH) endif() list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +# Must be set before project() because project() consumes it when configuring the compiler and SDK. +# A user-provided -DCMAKE_OSX_DEPLOYMENT_TARGET still takes precedence. +# +# CMAKE_SYSTEM_NAME can't be used before project(), so CMAKE_HOST_SYSTEM_NAME is used instead. +# +# When CMAKE_OSX_DEPLOYMENT_TARGET is bumped to >=26.0, FastFloat dependency won't be needed anymore +if( + CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin" + AND NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET +) + set(CMAKE_OSX_DEPLOYMENT_TARGET + "15.0" + CACHE STRING + "Minimum macOS deployment version" + ) +endif() + project(xrpl) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD 23) @@ -87,6 +104,7 @@ include(deps/Boost) add_subdirectory(external/antithesis-sdk) find_package(date REQUIRED) find_package(ed25519 REQUIRED) +find_package(FastFloat REQUIRED) find_package(gRPC REQUIRED) find_package(LibArchive REQUIRED) find_package(lz4 REQUIRED) @@ -102,6 +120,7 @@ target_link_libraries( xrpl_libs INTERFACE ed25519::ed25519 + FastFloat::fast_float lz4::lz4 mpt-crypto::mpt-crypto OpenSSL::Crypto @@ -139,11 +158,19 @@ if(coverage) include(XrplCov) endif() +add_custom_target(tidy_prerequisites) + +if(rust) + add_subdirectory(crates) +endif() include(XrplCore) + include(XrplProtocolAutogen) include(XrplInstall) -include(XrplPackaging) include(XrplValidatorKeys) +# Must come after XrplValidatorKeys: the 'package' target depends on the +# validator-keys target existing. +include(XrplPackaging) if(tests) include(CTest) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7632741e35..35309a9824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,8 +225,9 @@ environment, so you don't need to install most of the individual tools yourself. The version of each hook sourced from an external repository (`clang-format`, `gersemi`, etc.) is pinned in that file, so running the hooks locally uses exactly the same versions as CI. A few `local` hooks — most notably -`clang-tidy` — run tools from your own environment; see -[Installing clang-tidy](#installing-clang-tidy) for how to get those. +`clang-tidy` and `cargo fmt` — run tools from your own environment; see +[Installing clang-tidy](#installing-clang-tidy) and +[Rust](./docs/build/environment.md#rust) for how to get those. To get started, install `pre-commit` and enable the git hook scripts: @@ -255,6 +256,7 @@ The hooks configured in this repository include, among others: - `clang-tidy` — C++ static analysis (see [Clang-tidy](#clang-tidy)); opt in with `TIDY=1` - `fix-include-style`, `fix-pragma-once`, `check-doxygen-style` — C++ hygiene - `gersemi` — CMake formatting +- `cargo fmt` — Rust formatting for the crates in `crates/` - `prettier`, `black`, `shfmt` — formatting for JavaScript/JSON/Markdown, Python, and shell - `cspell` — spell checking @@ -319,7 +321,11 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho ### Running clang-tidy locally -Before running clang-tidy, you must build the project to generate required files (particularly protobuf headers). Refer to [`BUILD.md`](./BUILD.md) for build instructions. +Before running clang-tidy, you must generate the files it depends on (protobuf headers, and, when the project is configured with `-Drust=ON`, the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them: + +```bash +cmake --build build --target tidy_prerequisites +``` #### Via pre-commit (recommended) @@ -348,12 +354,14 @@ run-clang-tidy -p build -allow-no-checks src tests ``` This will check all source files in the `src`, `include` and `tests` directories using the compile commands from your `build` directory. -If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix` to the above command: +If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix -format` to the above command: ``` -run-clang-tidy -p build -quiet -fix -allow-no-checks src tests +run-clang-tidy -p build -quiet -fix -format -allow-no-checks src tests ``` +`-format` reformats the fixed code with [`.clang-format`](./.clang-format); without it the fixes are inserted in LLVM style and the `clang-format` hook rewrites them afterwards. + ## Contracts and instrumentation We are using [Antithesis](https://antithesis.com/) for continuous fuzzing, diff --git a/README.md b/README.md index 88c7943ebb..a0d30ef68b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Here are some good places to start learning the source code: | `./docs` | Source documentation files and doxygen config. | | `./cfg` | Example configuration files. | | `./src` | Source code. | +| `./crates` | Rust source code. | Some of the directories under `src` are external repositories included using git-subtree. See those directories' README files for more details. diff --git a/bin/check-nix-store-refs.sh b/bin/check-nix-store-refs.sh new file mode 100755 index 0000000000..70413df75e --- /dev/null +++ b/bin/check-nix-store-refs.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Fail if a binary under records a /nix/store path it resolves at run +# time. See docs/build/nix.md#prebuilt-packages for why that matters. +# +# is a file or a directory. macOS: nothing may reference the store, so +# point it at whole trees. Linux: the toolchain always writes the store into +# PT_INTERP and RUNPATH, so only at what cmake/PatchNixBinary.cmake retargets. +# +# Only Mach-O / ELF is inspected. Static archives hold store paths in debug info +# alone; the scripts in a Conan cache are all git hook samples and autotools +# scratch, 36 false positives to 0 real. +# +# Usage: bin/check-nix-store-refs.sh + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +if [ ! -e "$1" ]; then + echo "$0: no such path: $1" >&2 + exit 2 +fi + +case "$(uname -s)" in + Darwin) + format=Mach-O + recorded_paths=macho_recorded_paths + tool=otool + ;; + Linux) + format=ELF + recorded_paths=elf_recorded_paths + tool=readelf + ;; + *) + echo "Unsupported OS - skipping the Nix store reference check." + exit 0 + ;; +esac + +# `pipefail` would catch this too, but only as a bare nonzero exit. +if ! command -v "${tool}" >/dev/null; then + echo "$0: ${tool} not found; cannot inspect binaries" >&2 + exit 2 +fi + +# Both list what the file records. `ldd` would answer what this machine resolves +# now, which is wrong both ways: store paths for a correctly patched binary, +# silence for a store RUNPATH that resolves nowhere. + +# `name` covers LC_ID_DYLIB and LC_LOAD*_DYLIB, `path` covers LC_RPATH. +macho_recorded_paths() { + otool -l "$1" | sed -nE 's#^ *(name|path) ([^ ]*).*#\2#p' +} + +# RPATH and RUNPATH are colon-separated. +elf_recorded_paths() { + readelf -ldW "$1" | + sed -nE \ + -e 's#.*program interpreter: ([^]]*)\].*#\1#p' \ + -e 's#.*\((RPATH|RUNPATH|NEEDED)\).*\[([^]]*)\].*#\2#p' | + tr ':' '\n' +} + +checked=0 +skipped=0 +leaked=0 + +while IFS= read -r file; do + case "$(file -b "${file}" 2>/dev/null)" in + *"${format}"*) ;; + *) + skipped=$((skipped + 1)) + continue + ;; + esac + checked=$((checked + 1)) + + # Filter after extracting, or a search path starting elsewhere ($ORIGIN) + # hides the rest. `sed` not `grep`: grep calls "no matches" a failure, and + # the `|| true` that would need masks a broken pipeline too. + refs="$("${recorded_paths}" "${file}" | sed -n '\#^/nix/store/#p' | sort -u)" + if [ -n "${refs}" ]; then + leaked=$((leaked + 1)) + echo "::error file=${file}::references the Nix store at run time" + echo "${file}" + echo "${refs}" | sed 's/^/ /' + fi +done < <(find "$1" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so*' \)) + +echo "$1: checked ${checked}, skipped ${skipped}, ${leaked} with Nix store references." + +if [ "${leaked}" -ne 0 ]; then + cat >&2 <<'EOF' + +Fixes, in order of preference: + - A Conan package built before this check existed: drop it + (`conan remove '/*'`) and rebuild. + - A binary that should have been retargeted to the system loader: check that + cmake/PatchNixBinary.cmake ran for it. + - Link the macOS system library instead of the Nix one - see + libresolvSystemStub in nix/darwin.nix. + - No system library exists (libstdc++): link it statically. + - None of the above: pin the toolchain into the package ID, following + `user.package:libc_version` in conan/profiles/ci. +EOF + exit 1 +fi diff --git a/bin/check-tools.sh b/bin/check-tools.sh index 7886bcf8b0..8273375428 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -15,10 +15,14 @@ # - Windows: the core build tools only (CMake, Conan, Git, Python). # MSVC is expected to be provided separately and is not checked here. # -# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit, -# run-clang-tidy) are present in our Linux CI images and in local development -# setups, but not in the macOS CI environment. They are checked everywhere -# except when running in CI on macOS. +# Some tools (clang-format, clang-tidy, doxygen, gcovr, gh, git-cliff, gpg, +# pre-commit, run-clang-tidy) are present in our Linux CI images and in local +# development setups, but not in the macOS CI environment. They are checked +# everywhere except when running in CI on macOS. +# +# Tools that Nix also exposes under a version-suffixed name (`clang-tidy-22`, +# `g++-15`, ...) are probed under both names: a suffixed name can break while +# the plain one still works (see mkVersionedToolLinks in nix/packages.nix). # # Environment variables: # CI if set, skip the tools above when on macOS. @@ -26,12 +30,27 @@ set -uo pipefail +# Version suffixes of the Nix tool links, tracking nix/packages.nix. +gcc_version=15 +llvm_version=22 + missing=() checked=0 +# tool_path +# Fully resolved path of a tool, so the snapshots record which derivation +# provides it. Prints nothing when it isn't on PATH. +tool_path() { + local path + path="$(command -v "$1" 2>/dev/null)" || return 0 + readlink -f "${path}" 2>/dev/null || printf '%s' "${path}" +} + # check [probe-command...] -# Runs the probe (default: " --version") quietly. Records as -# missing if the command is not found or exits non-zero. +# Runs the probe (default: " --version"), capturing both stdout and +# stderr, and prints three lines: the status and name, the first non-blank line +# of the probe output (its version, or the error when it failed), and the tool's +# resolved path. Records as missing if it is not found or exits non-zero. check() { local name="$1" shift @@ -40,14 +59,18 @@ check() { probe=("${name}" --version) fi - echo "Checking ${name}..." checked=$((checked + 1)) - if "${probe[@]}" | head -n 1; then - printf ' [ ok ] %s\n' "${name}" + local output version path + path="$(tool_path "${name}")" + if output="$("${probe[@]}" 2>&1)"; then + printf ' ✅ %s\n' "${name}" else - printf ' [MISS] %s\n' "${name}" + printf ' ❌ %s\n' "${name}" missing+=("${name}") fi + version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" + printf ' %s\n' "${version:-(no output)}" + printf ' %s\n' "${path:-(not found)}" } case "$(uname -s)" in @@ -79,24 +102,35 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then echo "Development tooling:" check ccache check clang + check "clang-${llvm_version}" check clang++ + check "clang++-${llvm_version}" check ClangBuildAnalyzer check curl check file check less check make - check netstat which netstat + # net-tools netstat reports "net-tools X.Y"; macOS ships BSD netstat with no + # version flag, so fall back to a presence marker there. + check netstat sh -c 'command -v netstat >/dev/null && { netstat --version 2>&1 | grep -m1 -oE "net-tools [0-9.]+" || echo present; }' check ninja - check perl + check perl perl -e 'print "$^V\n"' check pkg-config check vim - check zip + check zip bash -c 'zip --version 2>&1 | grep -m1 -oE "Zip [0-9.]+"' # These tools are present in our Linux CI images and in local development # setups, but not in the macOS CI environment. So check them everywhere # except when running in CI on macOS. if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then + check clang-apply-replacements + check "clang-apply-replacements-${llvm_version}" check clang-format + check "clang-format-${llvm_version}" + # clang-tidy leads --version with the LLVM banner, not the version. + tidy_probe="--version | grep -m1 -oE 'LLVM version [0-9.]+'" + check clang-tidy sh -c "clang-tidy ${tidy_probe}" + check "clang-tidy-${llvm_version}" sh -c "clang-tidy-${llvm_version} ${tidy_probe}" check dot check doxygen check gcovr @@ -107,6 +141,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # pre-commit, or its alternative implementation prek check pre-commit sh -c 'pre-commit --version || prek --version' check run-clang-tidy run-clang-tidy --help + check "run-clang-tidy-${llvm_version}" "run-clang-tidy-${llvm_version}" --help fi fi @@ -121,7 +156,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check cargo-audit cargo audit --version check cargo-llvm-cov cargo llvm-cov --version check cargo-nextest cargo nextest --version - check clippy clippy-driver --version + check clippy-driver check rust-analyzer check rustc check rustfmt @@ -133,7 +168,11 @@ if [ "${os}" = "linux" ]; then echo echo "GCC toolchain:" check gcc + check "gcc-${gcc_version}" check g++ + check "g++-${gcc_version}" + check cpp + check "cpp-${gcc_version}" check gcov echo @@ -158,9 +197,9 @@ else checked=$((checked + 1)) tmp_clone="$(mktemp -d)" if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then - printf ' [ ok ] git clone over HTTPS\n' + printf ' ✅ git clone over HTTPS\n' else - printf ' [MISS] git clone over HTTPS\n' + printf ' ❌ git clone over HTTPS\n' missing+=("git-https-clone") fi rm -rf "${tmp_clone}" @@ -168,9 +207,9 @@ fi echo if [ "${#missing[@]}" -eq 0 ]; then - echo "All ${checked} checked tools are present and runnable." + echo "✅ All ${checked} checked tools are present and runnable." else - echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 + echo "❌ Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 for tool in "${missing[@]}"; do echo " - ${tool}" >&2 done diff --git a/nix/docker/loader-path.sh b/bin/default-loader-path.sh similarity index 100% rename from nix/docker/loader-path.sh rename to bin/default-loader-path.sh diff --git a/bin/pre-commit/clang_tidy_check.py b/bin/pre-commit/clang_tidy_check.py index cf4808d2ea..118d9619e2 100755 --- a/bin/pre-commit/clang_tidy_check.py +++ b/bin/pre-commit/clang_tidy_check.py @@ -144,7 +144,11 @@ def main(): + files ) canonicalize_fix_paths(Path(fixes_dir)) - applied = subprocess.run([clang_apply_replacements, fixes_dir]) + # `FormatStyle` in .clang-tidy does not reach this path, + # so ask for the repository style here. + applied = subprocess.run( + [clang_apply_replacements, "--format", "--style=file", fixes_dir] + ) return result.returncode or applied.returncode diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 9e334e6f4f..747bafe077 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -488,6 +488,17 @@ # Must be a number between 100 and 1000, defaults to 250 # # +# [max_subscriptions_per_connection] +# +# Maximum number of account, real-time account, and account-history +# subscriptions a single client connection may hold at once. Bounds the +# per-connection state torn down when the connection disconnects. Book +# subscriptions are tracked separately and are not counted here. +# +# Defaults to 100000 if not set; large enough for legitimate power users +# such as block explorers. +# +# # [overlay] # # Controls settings related to the peer to peer overlay. @@ -538,6 +549,45 @@ # only be used for local testing and debugging. Do not disable # on mainnet. # +# max_untrusted_count = +# +# The number of manifests the server keeps for validators it does not +# list, and the number it sends and processes in a single peer protocol +# message. Once the server holds this many, a manifest for a new +# unlisted validator is rejected, so peer gossip cannot grow the cache +# without end. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# max_trusted_count = +# +# The number of manifests for listed validators to allow for when +# sizing peer protocol messages. Manifests for listed validators are +# never dropped, whether sending or receiving, because doing so would +# delay a validator key change reaching this server. Set this above the +# number of validators the server lists. +# +# Together the two counts above set the largest manifest message the +# server accepts: bigger messages are discarded without reading them, +# and without penalising the sender. Raising either means the server +# accepts and sends bigger messages than a peer using the defaults, and +# those peers will discard what this server sends. Lowering either below +# what peers send makes this server discard their manifest messages, +# which it does without recording anything. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# NOTE: These two options (max_untrusted_count and max_trusted_count) +# are transitional. They exist to bound manifest-message size and cache +# growth during the network upgrade. They may be removed in a future +# release once the fleet has upgraded, and should not be relied upon as +# stable configuration. # # [transaction_queue] EXPERIMENTAL # diff --git a/cmake/CompilationEnv.cmake b/cmake/CompilationEnv.cmake index 8e69a4dfdd..471c43d6c6 100644 --- a/cmake/CompilationEnv.cmake +++ b/cmake/CompilationEnv.cmake @@ -29,6 +29,27 @@ if(CMAKE_GENERATOR STREQUAL "Xcode") set(is_xcode TRUE) endif() +# -------------------------------------------------------------------- +# Nix toolchain detection +# -------------------------------------------------------------------- +# True when the C++ compiler resolves into the Nix store. CMAKE_CXX_COMPILER may +# be referenced through a symlink outside the store (a Nix profile, a /usr/bin +# alternative, ...), so resolve the real path before matching. +set(is_nix_compiler FALSE) +get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH) +if(_cxx_real MATCHES "^/nix/store/") + set(is_nix_compiler TRUE) +endif() +unset(_cxx_real) + +# True inside the Nix CI Docker image, identified by the /nix/ci-env tree it +# ships (see nix/docker/Dockerfile). The dev shell and bare systems don't have +# it, so it distinguishes the CI image from other Nix-compiler environments. +set(is_ci_image FALSE) +if(EXISTS "/nix/ci-env/bin") + set(is_ci_image TRUE) +endif() + # -------------------------------------------------------------------- # Operating system detection # -------------------------------------------------------------------- diff --git a/cmake/PatchNixBinary.cmake b/cmake/PatchNixBinary.cmake index 79ca0b150c..05d923b74e 100644 --- a/cmake/PatchNixBinary.cmake +++ b/cmake/PatchNixBinary.cmake @@ -1,26 +1,38 @@ #[===================================================================[ Patch executables to run in non-Nix environments. - The Nix-based CI image links binaries against an ELF interpreter (loader) + The Nix toolchain links binaries against an ELF interpreter (loader) that lives in the Nix store, so the resulting binaries don't run elsewhere - (including once installed from the .deb package). `patch_nix_binary` adds a - POST_BUILD step that resets the interpreter to the system default loader and - drops the rpath. + (including once installed from the .deb package). `patch_nix_binary` resets + the interpreter to the system default loader and drops the rpath, once the + binary has been linked. - This is only active inside the Nix-based image, detected by the presence of - /tmp/loader-path.sh (shipped by that image, resolves the default loader). It - is skipped for sanitizer builds, whose runtime libraries are resolved through - the rpath. Everywhere else `patch_nix_binary` is a no-op. + This runs by default for Nix-toolchain builds (determined by whether the compiler resolves under /nix/store/). + Those builds are where binaries get a Nix-store loader. + It is opted out of by setting the XRPLD_NO_PATCH_NIX_BINARY environment variable — + the plain Nix dev shells set it, since their binaries link a newer glibc + and must not be retargeted to the system loader. + + Non-Nix builds (a system compiler, already using the system loader) and sanitizer builds + (runtime libraries resolved through the rpath) are skipped too. + Everywhere else `patch_nix_binary` is a no-op. + + The default loader is resolved by bin/default-loader-path.sh. #]===================================================================] include_guard(GLOBAL) include(CompilationEnv) -# Provided by the Nix-based CI image; prints the system default ELF loader path. -set(_loader_path_script "/tmp/loader-path.sh") +# Resolves the system default ELF loader path for the current architecture. +set(_loader_path_script "${CMAKE_SOURCE_DIR}/bin/default-loader-path.sh") -if(is_linux AND NOT SANITIZERS_ENABLED AND EXISTS "${_loader_path_script}") +if( + is_linux + AND NOT SANITIZERS_ENABLED + AND is_nix_compiler + AND NOT DEFINED ENV{XRPLD_NO_PATCH_NIX_BINARY} +) execute_process( COMMAND "${_loader_path_script}" OUTPUT_VARIABLE DEFAULT_LOADER_PATH @@ -41,13 +53,38 @@ function(patch_nix_binary target) if(NOT PATCH_NIX_BINARIES) return() endif() - add_custom_command( - TARGET ${target} - POST_BUILD - COMMAND - "${PATCHELF_COMMAND}" --set-interpreter "${DEFAULT_LOADER_PATH}" - --remove-rpath "$" - COMMENT "Patching ${target}: set default loader, remove rpath" - VERBATIM + + set(patch_command + "${PATCHELF_COMMAND}" + --set-interpreter + "${DEFAULT_LOADER_PATH}" + --remove-rpath + "$" ) + set(comment "Patching ${target}: set default loader, remove rpath") + + # POST_BUILD is the cheap way to do this: it runs only when the binary is + # relinked. It is also only available in the directory that defined the + # target, so for a target from elsewhere (e.g. a FetchContent subproject) + # fall back to a custom target that runs after the binary is linked. That + # one runs on every build, which is harmless because patchelf is idempotent. + get_target_property(target_source_dir ${target} SOURCE_DIR) + if("${target_source_dir}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + else() + add_custom_target( + ${target}-patch-nix + ALL + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + add_dependencies(${target}-patch-nix ${target}) + endif() endfunction() diff --git a/cmake/XrplAddBenchmark.cmake b/cmake/XrplAddBenchmark.cmake index 1dd875dd61..921deb0658 100644 --- a/cmake/XrplAddBenchmark.cmake +++ b/cmake/XrplAddBenchmark.cmake @@ -1,3 +1,5 @@ +include_guard() + include(isolate_headers) # Define a benchmark executable for the module `name`. diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index cb4e797137..29c1dfe478 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -120,7 +120,10 @@ if(MSVC) _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS $<$,$>:_CRTDBG_MAP_ALLOC> ) - target_link_libraries(common INTERFACE -errorreport:none -machine:X64) + target_link_libraries( + common + INTERFACE -errorreport:none -machine:X64 -ignore:4099 + ) else() target_compile_options( common @@ -171,9 +174,8 @@ else() # Clang wrapper supplies those paths itself (via -nostdinc++), so at compile time the # flag is unused -> Clang errors under our -Werror. At link time the flag IS consumed # (it selects the C++ runtime), so we move it there instead of dropping it entirely. - get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH) if( - _cxx_real MATCHES "^/nix/store/" + is_nix_compiler AND is_linux AND is_clang AND CMAKE_CXX_FLAGS MATCHES "stdlib=libstdc" @@ -267,10 +269,50 @@ elseif(use_lld) ) if("${LD_VERSION}" MATCHES "LLD") target_link_libraries(common INTERFACE -fuse-ld=lld) + # remembered for the linker flag probe below + set(fuse_ld_flag "-fuse-ld=lld") endif() unset(LD_VERSION) endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above). +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + set(probe_flags ${fuse_ld_flag} "${silence_flag}") + include(CheckLinkerFlag) + check_linker_flag( + CXX + "${probe_flags}" + have_deployment_target_mismatches + ) + if(have_deployment_target_mismatches) + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + endif() + unset(probe_flags) + unset(silence_flag) + endif() +endif() +unset(fuse_ld_flag) + if(assert) foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE) string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}") diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 3e49267715..f3951d4eac 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -51,6 +51,8 @@ target_compile_options( target_link_libraries(xrpl.libpb PUBLIC protobuf::libprotobuf gRPC::grpc++) +add_dependencies(tidy_prerequisites xrpl.libpb) + # TODO: Clean up the number of library targets later. add_library(xrpl.imports.main INTERFACE) @@ -133,6 +135,12 @@ target_link_libraries( add_module(xrpl resource) target_link_libraries(xrpl.libxrpl.resource PUBLIC xrpl.libxrpl.protocol) +add_module(xrpl peerfinder) +target_link_libraries( + xrpl.libxrpl.peerfinder + PUBLIC xrpl.libxrpl.basics xrpl.libxrpl.protocol +) + # Level 08 add_module(xrpl net) target_link_libraries( @@ -201,6 +209,16 @@ target_link_libraries( add_module(xrpl tx) target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger) +add_module(xrpl consensus) +target_link_libraries( + xrpl.libxrpl.consensus + PUBLIC + xrpl.libxrpl.basics + xrpl.libxrpl.json + xrpl.libxrpl.protocol + xrpl.libxrpl.ledger +) + add_library(xrpl.libxrpl) set_target_properties(xrpl.libxrpl PROPERTIES OUTPUT_NAME xrpl) @@ -220,6 +238,7 @@ target_link_modules( beast conditions config + consensus core crypto git @@ -227,6 +246,7 @@ target_link_modules( ledger net nodestore + peerfinder protocol protocol_autogen rdb diff --git a/cmake/XrplCov.cmake b/cmake/XrplCov.cmake index 86ba534a88..05d9ed3806 100644 --- a/cmake/XrplCov.cmake +++ b/cmake/XrplCov.cmake @@ -44,6 +44,7 @@ setup_target_for_coverage_gcovr( EXCLUDE "src/test" "src/tests" + "src/benchmarks" "include/xrpl/beast/test" "include/xrpl/beast/unit_test" "${CMAKE_BINARY_DIR}/pb-xrpl.libpb" diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index 8e3861925d..bee7b15791 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -25,6 +25,19 @@ if(NOT (RPMBUILD_EXECUTABLE OR DPKG_BUILDPACKAGE_EXECUTABLE)) return() endif() +if(NOT TARGET xrpld) + message(STATUS "xrpld=ON is required; 'package' target not available") + return() +endif() + +if(NOT TARGET validator-keys) + message( + STATUS + "validator_keys=ON is required; 'package' target not available" + ) + return() +endif() + set(package_env SRC_DIR=${CMAKE_SOURCE_DIR} BUILD_DIR=${CMAKE_BINARY_DIR} @@ -37,7 +50,7 @@ add_custom_target( ${CMAKE_COMMAND} -E env ${package_env} ${CMAKE_SOURCE_DIR}/package/build_pkg.sh WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - DEPENDS xrpld + DEPENDS xrpld validator-keys COMMENT "Building Linux package (deb/rpm inferred from host tooling)" VERBATIM ) diff --git a/cmake/XrplProtocolAutogen.cmake b/cmake/XrplProtocolAutogen.cmake index dd9ef6a9a4..33af560113 100644 --- a/cmake/XrplProtocolAutogen.cmake +++ b/cmake/XrplProtocolAutogen.cmake @@ -2,21 +2,22 @@ Protocol Autogen - Code generation for protocol wrapper classes #]===================================================================] +# The repository root, derived from the location of this file rather than from +# the including project, so that the targets below can also be offered on their +# own by cmake/codegen/CMakeLists.txt. +get_filename_component(XRPL_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + set(CODEGEN_VENV_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/.venv" + "${XRPL_ROOT}/.venv" CACHE PATH "Path to a Python virtual environment for code generation. A venv will be created here by setup_code_gen and used to run generation scripts." ) # Directory paths -set(MACRO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol/detail") -set(AUTOGEN_HEADER_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol_autogen" -) -set(AUTOGEN_TEST_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/src/tests/libxrpl/protocol_autogen" -) -set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/cmake/scripts/codegen") +set(MACRO_DIR "${XRPL_ROOT}/include/xrpl/protocol/detail") +set(AUTOGEN_HEADER_DIR "${XRPL_ROOT}/include/xrpl/protocol_autogen") +set(AUTOGEN_TEST_DIR "${XRPL_ROOT}/src/tests/libxrpl/protocol_autogen") +set(SCRIPTS_DIR "${XRPL_ROOT}/cmake/scripts/codegen") # Input macro files set(TRANSACTIONS_MACRO "${MACRO_DIR}/transactions.macro") @@ -114,14 +115,14 @@ if(CODEGEN_VENV_DIR) setup_code_gen COMMAND ${Python3_EXECUTABLE} -m venv "${CODEGEN_VENV_DIR}" COMMAND ${CODEGEN_PYTHON} -m pip install -r "${REQUIREMENTS_FILE}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Creating venv and installing code generation dependencies..." ) else() add_custom_target( setup_code_gen COMMAND ${Python3_EXECUTABLE} -m pip install -r "${REQUIREMENTS_FILE}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Installing code generation dependencies..." ) endif() @@ -139,8 +140,8 @@ add_custom_target( -DSFIELDS_MACRO=${SFIELDS_MACRO} -DAUTOGEN_HEADER_DIR=${AUTOGEN_HEADER_DIR} -DAUTOGEN_TEST_DIR=${AUTOGEN_TEST_DIR} -P - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/XrplProtocolAutogenRun.cmake" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_LIST_DIR}/XrplProtocolAutogenRun.cmake" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Running protocol code generation..." SOURCES ${ALL_INPUT_FILES} ) diff --git a/cmake/XrplSanity.cmake b/cmake/XrplSanity.cmake index a35645ad5c..ba9f7988bc 100644 --- a/cmake/XrplSanity.cmake +++ b/cmake/XrplSanity.cmake @@ -36,6 +36,19 @@ elseif(is_gcc) endif() endif() +# A Nix compiler is only meant to be used from a managed environment: the xrpld +# dev shell (which exports XRPL_DEVSHELL) or the CI image. Using one from a bare +# shell usually means a leaked toolchain (picked up via PATH or a Conan profile) +# and leads to confusing breakage, so fail early with guidance. +if(is_nix_compiler AND NOT is_ci_image AND NOT DEFINED ENV{XRPL_DEVSHELL}) + message( + FATAL_ERROR + "A Nix compiler (${CMAKE_CXX_COMPILER}) is being used outside the xrpld " + "dev shell. Enter it with `nix develop` (see docs/build/nix.md) before " + "configuring the build." + ) +endif() + # check for in-source build and fail if("${CMAKE_CURRENT_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}") message( diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake index be9bf1fda2..58b902baa1 100644 --- a/cmake/XrplSettings.cmake +++ b/cmake/XrplSettings.cmake @@ -32,6 +32,11 @@ endif() option(benchmark "Build benchmarks" ON) +# When OFF, the crates directory is not added to the build at all: no Rust +# toolchain is required, no cxxbridge bindings are generated, and the C++ tests +# that consume those bindings are left out of the build tree. +option(rust "Build the Rust crates and the C++ code that depends on them" OFF) + # Enabled by default so every header is compiled on its own as the main file of # its own compile_commands.json entry - this is what lets clang-tidy (and clangd # and IDEs) analyse a header's own includes directly. The per-header objects are diff --git a/cmake/XrplValidatorKeys.cmake b/cmake/XrplValidatorKeys.cmake index 0e511b6a88..0acaed1a56 100644 --- a/cmake/XrplValidatorKeys.cmake +++ b/cmake/XrplValidatorKeys.cmake @@ -5,22 +5,39 @@ option( ) if(validator_keys) - git_branch(current_branch) - # default to tracking VK master branch unless we are on release - if(NOT (current_branch STREQUAL "release")) - set(current_branch "master") - endif() - message(STATUS "Tracking ValidatorKeys branch: ${current_branch}") + # Own the install destination below rather than relying on another module + # having pulled this in first. + include(GNUInstallDirs) + + # Pinned to an exact commit, not a branch: the tool ships inside our + # packages, so the same xrpld version must always package the same + # validator-keys. Bump this deliberately. + set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae") + message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}") FetchContent_Declare( validator_keys GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git - GIT_TAG "${current_branch}" + GIT_TAG "${validator_keys_commit}" ) FetchContent_MakeAvailable(validator_keys) + # The tool's own CMakeLists excludes the target from 'all' when it is built + # as a subproject. Undo that, so validator_keys=ON really does build it. set_target_properties( validator-keys - PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + EXCLUDE_FROM_ALL OFF + EXCLUDE_FROM_DEFAULT_BUILD OFF + ) + # We ship this binary, so like xrpld it must not keep the Nix store's ELF + # loader, or it cannot run on the target distro at all. + patch_nix_binary(validator-keys) + + configure_file( + "${validator_keys_SOURCE_DIR}/LICENSE" + "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" + COPYONLY ) install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) endif() diff --git a/cmake/codegen/CMakeLists.txt b/cmake/codegen/CMakeLists.txt new file mode 100644 index 0000000000..f697d67dd8 --- /dev/null +++ b/cmake/codegen/CMakeLists.txt @@ -0,0 +1,21 @@ +#[===================================================================[ + Protocol Autogen - Standalone project + + Exposes the 'setup_code_gen' and 'code_gen' targets on their own, without + configuring the rest of xrpl. Code generation is pure Python, so this needs + neither the dependencies nor a compiler, which makes it usable in CI and by + contributors who only want to regenerate the protocol wrapper classes: + + cmake -S cmake/codegen -B build/codegen + cmake --build build/codegen --target setup_code_gen + cmake --build build/codegen --target code_gen + + The targets are identical to the ones offered by the top-level build, since + both come from cmake/XrplProtocolAutogen.cmake. +#]===================================================================] + +cmake_minimum_required(VERSION 3.16) + +project(xrpl_codegen LANGUAGES NONE) + +include("${CMAKE_CURRENT_LIST_DIR}/../XrplProtocolAutogen.cmake") diff --git a/cmake/scripts/codegen/generate_tx_classes.py b/cmake/scripts/codegen/generate_tx_classes.py index 07baefd8b6..09fb898840 100644 --- a/cmake/scripts/codegen/generate_tx_classes.py +++ b/cmake/scripts/codegen/generate_tx_classes.py @@ -8,6 +8,7 @@ Uses pcpp to preprocess the macro file and pyparsing to parse the DSL. import io import argparse +import re from pathlib import Path import pyparsing as pp @@ -53,28 +54,89 @@ def create_transaction_parser(): return macro_parser +# Defaults for xrpl::TxSettings members, mirroring +# include/xrpl/protocol/TxSettings.h. A transaction's settings blob only names +# the members that differ from these. +SETTING_DEFAULTS = { + "delegable": "Delegation::NotDelegable", + "amendment": "uint256{}", + "privileges": "Privilege::NoPriv", +} + + +def parse_settings(settings_str): + """Parse a TxSettings blob into a dict, filling in defaults. + + Args: + settings_str: A string like '({.delegable = Delegation::NotDelegable, + .privileges = Privilege::CreateAcct})', or '({})'. + + Returns: + A dict with a value for every key in SETTING_DEFAULTS. + """ + body = settings_str.strip() + if not (body.startswith("(") and body.endswith(")")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1].strip() + if not (body.startswith("{") and body.endswith("}")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1] + + # Strip comments, which may be interleaved with the designated initializers. + body = re.sub(r"//[^\n]*", "", body) + + settings = dict(SETTING_DEFAULTS) + seen = set() + # Each entry runs from '.key =' up to the next '.key =' or the end. + for key, value in re.findall( + r"\.(\w+)\s*=\s*(.*?)(?=,\s*\.\w+\s*=|,?\s*$)", body, re.S + ): + if key not in SETTING_DEFAULTS: + raise ValueError(f"Unknown TxSettings member '.{key}' in {settings_str!r}") + settings[key] = " ".join(value.split()).rstrip(",") + seen.add(key) + + # Catch a typo'd or unparsed initializer rather than silently defaulting it. + # Every '.member' in the blob must have been consumed above. + if len(re.findall(r"\.\w+", body)) != len(seen): + raise ValueError(f"Could not parse every setting in {settings_str!r}") + + # A blob with content but no designated initializer is positional, which + # would otherwise be read as "all defaults" and silently generate the + # wrong output. + if body.strip() and not seen: + raise ValueError( + "TxSettings requires designated initializers (.member = value), " + f"got {settings_str!r}" + ) + + return settings + + def parse_transaction_args(args_list): """Parse the arguments of a TRANSACTION macro call. Args: args_list: A list of parsed arguments from pyparsing, e.g., - ['ttPAYMENT', '0', 'Payment', 'Delegation::delegable', - 'uint256{}', 'createAcct', '({...})'] + ['ttPAYMENT', '0', 'Payment', + '({.privileges = Privilege::CreateAcct})', '({...})'] Returns: A dict with parsed transaction information. """ - if len(args_list) < 7: + if len(args_list) < 5: raise ValueError( - f"Expected at least 7 parts in TRANSACTION, got {len(args_list)}: {args_list}" + f"Expected at least 5 parts in TRANSACTION, got {len(args_list)}: {args_list}" ) tag = args_list[0] value = args_list[1] name = args_list[2] - delegable = args_list[3] - amendments = args_list[4] - privileges = args_list[5] + settings = parse_settings(args_list[3]) fields_str = args_list[-1] # Parse fields: ({field1, field2, ...}) @@ -84,9 +146,9 @@ def parse_transaction_args(args_list): "tag": tag, "value": value, "name": name, - "delegable": delegable, - "amendments": amendments, - "privileges": privileges, + "delegable": settings["delegable"], + "amendments": settings["amendment"], + "privileges": settings["privileges"], "fields": fields, } diff --git a/conan.lock b/conan.lock index c6a4070c77..176f0b27cb 100644 --- a/conan.lock +++ b/conan.lock @@ -12,7 +12,7 @@ "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", "openssl/3.6.3#f806de8933e3bf6f01016c6a888cee2e%1783945160.863288", "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1782392402.297166", - "mpt-crypto/0.4.0-rc4#ffdba12f2332357f0d8b0ae944cfff52%1784138702.932355", + "mpt-crypto/1.0.2#b313cef0c1a493eb970ad185b2e9bab7%1784285108.866483", "lz4/1.10.0#982d9b673900f665a1da109e09c17cab%1782392402.164188", "libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1782392792.775744", "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1782392402.420732", @@ -20,8 +20,10 @@ "jemalloc/5.3.1#1fc58d55316041f10fbc1e8a2eae632a%1776700028.228", "gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1782392402.791979", "grpc/1.81.1#f729f6d75992d20f9c72828e9142d62f%1783945160.094135", + "fast_float/8.2.10#f6f28d6bb22112078e7dbda611caf681%1782494504.298", "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562", "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492", + "corrosion/0.6.1#bfa292df0a957bc70a450ff316cd9435%1786119416.131296", "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654", "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1782392402.296732", "boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1782392419.475605", @@ -34,7 +36,7 @@ "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1782395690.33162", "msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649", - "m4/1.4.19#34c4bbc3eeebe98ca6edf2f52d602e7d%1777282960.259", + "m4/1.4.19#1727f439cf74e83826ec96d0b4904eee%1784541921.659", "cmake/4.3.3#840cf00ea09777e05c2050a50a82c722%1782392418.696091", "b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1782392402.624226", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", diff --git a/conan/init.sh b/conan/init.sh new file mode 100755 index 0000000000..287ee83001 --- /dev/null +++ b/conan/init.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Install our Conan configuration, profiles and the xrplf remote into CONAN_HOME. +# Safe to re-run; never deletes the Conan home. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CONAN_DIR="$(conan config home)" + +echo "Installing Conan configuration into ${CONAN_DIR}" +conan config install "${SCRIPT_DIR}/global.conf" +conan config install "${SCRIPT_DIR}/profiles" -tf "${CONAN_DIR}/profiles" +# This script manages these files, so make them read-only - Conan does not +# preserve the source mode. Only the files: the directories must stay writable +# for `conan config install` to replace them. +chmod a-w "${CONAN_DIR}/global.conf" +find "${CONAN_DIR}/profiles" -type f -exec chmod a-w {} + + +echo "Adding the xrplf Conan remote" +# --index 0: our patched recipes must win over Conan Center. +conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ diff --git a/conan/profiles/default b/conan/profiles/default index 6534f8092b..1b7eaff980 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -1,9 +1,13 @@ {% set os = detect_api.detect_os() %} {% set arch = detect_api.detect_arch() %} {% set compiler, version, compiler_exe = detect_api.detect_default_compiler() %} -{% set compiler_version = version %} -{% if os == "Linux" %} {% set compiler_version = detect_api.default_compiler_version(compiler, version) %} +{% if os == "Macos" %} +{# Minimum macOS the dependencies target. #} +{# Without this, Conan builds each dependency against the (possibly newer) host SDK, so the #} +{# dependency objects target a newer macOS than the binary and the linker warns. #} +{# Keep at or below CMAKE_OSX_DEPLOYMENT_TARGET in CMakeLists.txt. #} +{% set min_macos_version = "15.0" %} {% endif %} [settings] @@ -18,6 +22,9 @@ compiler.runtime=static {% else %} compiler.libcxx={{ detect_api.detect_libcxx(compiler, version, compiler_exe) }} {% endif %} +{% if os == "Macos" %} +os.version={{ min_macos_version }} +{% endif %} [conf] {# The Boost recipe builds with b2, which doesn't use Conan's toolchain files. #} @@ -41,3 +48,13 @@ tools.build:compiler_executables={'c':'{{ cc_exe }}','cpp':'{{ cxx_exe }}'} {# More info: https://docs.conan.io/2/reference/extensions/binary_compatibility.html #} user.package:cppstd_version=23 tools.info.package_id:confs+=["user.package:cppstd_version"] + +{% if os == "Macos" %} +[buildenv] +{# os.version adds -mmacosx-version-min to compiler command lines, #} +{# but Boost.Context's b2 assembly (.S) rule ignores it, #} +{# so those objects keep the host SDK version and still warn at link time. #} +{# clang's assembler honors this env var regardless, pinning them. #} +{# Scoped to boost/* since it is the only gap. #} +boost/*:MACOSX_DEPLOYMENT_TARGET={{ min_macos_version }} +{% endif %} diff --git a/conanfile.py b/conanfile.py index f883761f0e..0683a3779f 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,7 +28,9 @@ class Xrpl(ConanFile): } requires = [ + "corrosion/0.6.1", "ed25519/2015.03", + "fast_float/8.2.10", "grpc/1.81.1", "libarchive/3.8.7", "nudb/2.0.9", @@ -138,7 +140,7 @@ class Xrpl(ConanFile): if self.options.jemalloc: self.requires("jemalloc/5.3.1") self.requires("lz4/1.10.0", force=True) - self.requires("mpt-crypto/0.4.0-rc4", transitive_headers=True) + self.requires("mpt-crypto/1.0.2", transitive_headers=True) self.requires("protobuf/6.33.5", force=True) if self.options.rocksdb: self.requires("rocksdb/10.5.1") @@ -211,6 +213,7 @@ class Xrpl(ConanFile): "boost::thread", "date::date", "ed25519::ed25519", + "fast_float::fast_float", "grpc::grpc++", "libarchive::libarchive", "lz4::lz4", diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml new file mode 100644 index 0000000000..fc29aa80f7 --- /dev/null +++ b/crates/.cargo/config.toml @@ -0,0 +1,17 @@ +# The Rust static libraries are linked into C++ targets, so the runtime linkage +# here has to match what the C++ build uses (see cmake/XrplCompiler.cmake). +# +# macOS needs nothing: AppleClang cannot link libgcc/libc++ statically, so the +# C++ build skips those flags on Apple as well. + +# Both amd64 and arm64 Linux builds link libgcc statically. This only affects +# links that rustc itself drives (`cargo test` binaries and the like) — the +# `staticlib` crates consumed by CMake are archived, not linked, so rustc +# silently ignores link args for them. Keeping libgcc_s.so.1 off the xrpld link +# line is handled in crates/CMakeLists.txt instead. +[target.'cfg(target_os = "linux")'] +rustflags = ["-C", "link-args=-static-libgcc"] + +# Windows builds use the static MSVC runtime. +[target.'cfg(windows)'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt new file mode 100644 index 0000000000..3f83045cdb --- /dev/null +++ b/crates/CMakeLists.txt @@ -0,0 +1,104 @@ +find_package(Corrosion REQUIRED) + +corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml) + +# The generated C++ lands in the build tree, so put a .clang-tidy next to it to +# keep clang-tidy from analyzing code we don't own. +configure_file( + generated.clang-tidy + "${CMAKE_CURRENT_BINARY_DIR}/.clang-tidy" + COPYONLY +) + +add_custom_target(xrpl_crates) +add_dependencies(tidy_prerequisites xrpl_crates) + +# On macOS, ld warns `ignoring duplicate libraries` when linking a crate. +# Corrosion is the source of both duplicates it names: +# +# * The crate archive and its cxxbridge archive, because +# `corrosion_add_cxxbridge` makes the two depend on each other, and CMake +# repeats a static library cycle on the link line so single-pass linkers can +# resolve it. (LINK_INTERFACE_MULTIPLICITY can only raise that count.) +# * `-lSystem`, which Corrosion copies from rustc's `native-static-libs` even +# though the compiler driver always links libSystem. +# +# ld needs neither: it resolves the cycle from one copy of each archive and +# links libSystem once. So silence the warning rather than rewrite Corrosion's +# link interface, which the cycle is also part of. The option itself is old — +# Xcode 15 is only where the warning became the default — and the check below +# leaves it out on a linker that does not know it. +if(is_macos) + include(CheckLinkerFlag) + check_linker_flag( + CXX + -Wl,-no_warn_duplicate_libraries + have_no_warn_duplicate_libraries + ) +endif() + +function(_unlink_libgcc_s crate) + if(NOT (is_linux AND static)) + return() + endif() + + # Corrosion exposes a crate's staticlib as an imported `-static` + # target and puts the native libs in its INTERFACE_LINK_LIBRARIES. If either + # of those changes, warn instead of silently letting libgcc_s.so.1 return. + set(imported "${crate}-static") + if(NOT TARGET ${imported}) + message( + FATAL_ERROR + "Corrosion did not create the imported target '${imported}', so " + "libgcc_s cannot be removed from the link interface of '${crate}'. " + "xrpld will link libgcc_s.so.1 dynamically. Check where Corrosion " + "${CORROSION_VERSION} now records `native-static-libs`." + ) + return() + endif() + + get_target_property(libs ${imported} INTERFACE_LINK_LIBRARIES) + if(NOT "gcc_s" IN_LIST libs) + message( + WARNING + "'gcc_s' was not in the link interface of '${imported}' as " + "expected. If the Rust toolchain stopped reporting it this " + "workaround is obsolete and can be deleted; otherwise xrpld may " + "link libgcc_s.so.1 dynamically. Verify with: " + "objdump -p xrpld | grep NEEDED" + ) + return() + endif() + + list(REMOVE_ITEM libs gcc_s) + set_property(TARGET ${imported} PROPERTY INTERFACE_LINK_LIBRARIES ${libs}) +endfunction() + +function(add_xrpl_crate name) + cmake_parse_arguments(ARG "" "CRATE" "FILES" ${ARGN}) + _unlink_libgcc_s(${ARG_CRATE}) + # `cc` picks its runtime flag from `crt-static` alone, so it compiles a + # crate's C++ with `-MT`; Debug needs `-MTd` (to match cmake/XrplCompiler.cmake). + if(is_msvc) + corrosion_set_env_vars( + ${ARG_CRATE} + "$<$:CXXFLAGS=-MTd>" + ) + endif() + corrosion_add_cxxbridge(${name}_cxxbridge CRATE ${ARG_CRATE} FILES + ${ARG_FILES} + ) + # Generated cxxbridge headers don't exist at configure time; CMake 3.28+ + # validates INTERFACE_SOURCES on consuming targets. Clear it to skip the + # existence check — build-time ordering is enforced by the custom commands. + set_target_properties(${name}_cxxbridge PROPERTIES INTERFACE_SOURCES "") + if(have_no_warn_duplicate_libraries) + target_link_options( + ${name}_cxxbridge + INTERFACE -Wl,-no_warn_duplicate_libraries + ) + endif() + add_dependencies(xrpl_crates ${name}_cxxbridge) +endfunction() + +add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs) diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 0000000000..70247f8e19 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,301 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "cxx" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.199" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rs-hello_world" +version = "0.1.0" +dependencies = [ + "cxx", +] + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 0000000000..0bb0e9c550 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = ["hello_world"] +resolver = "3" + +[workspace.dependencies] +cxx = { version = "1.0.198", features = ["c++20"] } + +[workspace.package] +edition = "2024" + +[profile.release] +opt-level = 3 +overflow-checks = true +lto = true +debug = true diff --git a/crates/generated.clang-tidy b/crates/generated.clang-tidy new file mode 100644 index 0000000000..8e2202d44a --- /dev/null +++ b/crates/generated.clang-tidy @@ -0,0 +1,10 @@ +--- +# Neutralizes clang-tidy for the corrosion/cxxbridge-generated C++. Copied into +# the crates build directory by crates/CMakeLists.txt, next to the generated +# sources, so clang-tidy picks it up instead of the top-level configuration. +# +# One check is kept enabled to avoid clang-tidy's "no checks enabled" error. +Checks: "-*,google-readability-todo" +WarningsAsErrors: "" +HeaderFilterRegex: "" +InheritParentConfig: false diff --git a/crates/hello_world/Cargo.toml b/crates/hello_world/Cargo.toml new file mode 100644 index 0000000000..2e5a329c9a --- /dev/null +++ b/crates/hello_world/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rs-hello_world" +version = "0.1.0" +edition.workspace = true + +[lib] +crate-type = ["staticlib"] + +[dependencies] +cxx.workspace = true diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs new file mode 100644 index 0000000000..b1cb121fa0 --- /dev/null +++ b/crates/hello_world/src/lib.rs @@ -0,0 +1,10 @@ +#[cxx::bridge(namespace = "rs::hello_world")] +mod ffi { + extern "Rust" { + fn hello_world() -> String; + } +} + +pub fn hello_world() -> String { + "hello_world".to_string() +} diff --git a/docs/NodeStoreRefactoringCaseStudy.pdf b/docs/NodeStoreRefactoringCaseStudy.pdf deleted file mode 100644 index 6cde8a2eed..0000000000 Binary files a/docs/NodeStoreRefactoringCaseStudy.pdf and /dev/null differ diff --git a/docs/build/environment.md b/docs/build/environment.md index e639ed2d5f..51580b12a5 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -6,22 +6,55 @@ This document explains how to set one up. ## Tested compiler versions -`xrpld` is built in the **C++23** dialect by default. -Make sure your toolchain is recent enough — the compiler versions currently tested in CI are: +`xrpld` is built in the **C++23** dialect by default, so your toolchain has to +support it — see [compiler support for C++23][cpp23-support]. +The versions currently tested in CI are: -| Compiler | Version | -| ----------- | ------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 17 | -| MSVC | 19.44 | +| Compiler | Version | +| ----------- | ------------------ | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 21 | +| MSVC | Visual Studio 2026 | LLVM tools (`clang-tidy` and `clang-format`) are also pinned to version 22. +### Older compilers + Older compilers may fail to build the latest `develop` code: the codebase now relies on C++23 features and has been adjusted for `clang-tidy`. If the latest code doesn't build for you, update your build toolchain first. +If updating isn't an option for you, we do accept pull requests that fix builds +on older compilers, as long as the change is small and doesn't make the code +harder to read. What we can't promise is that older compilers will keep working: +only the versions in the table above are tested in CI, and we won't hold back +the use of C++23 features or add invasive workarounds to keep an untested +compiler building. Treat support for anything outside the table as best-effort. + +## Required tools + +Besides a compiler, building `xrpld` requires: + +| Tool | Minimum version | +| ------------------------------------------- | --------------- | +| [Git](https://git-scm.com/downloads) | any recent | +| [Python](https://www.python.org/downloads/) | 3.11 | +| [Conan](https://conan.io/downloads.html) | 2.17 | +| [CMake](https://cmake.org/download/) | 3.16 | + +On Linux and macOS, the [Nix development shell](./nix.md) provides all of them +(see below). On Windows they have to be installed manually. + +Building with `-Drust=ON` additionally requires a Rust toolchain, see +[Rust](#rust). A default build does not, so it is not in the table above. + +Once they are in place, verify that everything is installed and runnable with: + +```bash +./bin/check-tools.sh +``` + ## Linux and macOS The **recommended way** to get a development environment on Linux and macOS is @@ -39,20 +72,15 @@ Clang. If you instead opt to use your system-wide Apple Clang (via below). See [Using the Nix development shell](./nix.md) for installation and usage -details, including how to select a different compiler. - -> [!NOTE] -> Using Nix is not mandatory. Any custom environment (Homebrew packages or -> anything else) will continue to work, but then it is up to you to keep it in -> sync with the environment used in CI. Nix unifies the development environment -> for everyone and synchronizes updates, which is why we recommend it. +details, including how to select a different compiler and why we recommend Nix +over a hand-maintained environment. ### macOS: managing the Apple Clang version If you use your system-wide Apple Clang on macOS (via `nix develop .#apple-clang`), the compiler version is whatever your installed Xcode (or Command Line Tools) provides. The following command should return a version greater than or equal to -the [minimum required](#tested-compiler-versions): +the [tested one](#tested-compiler-versions): ```bash clang --version @@ -89,23 +117,42 @@ building xrpld. You may want to install and pin a specific version of Xcode: Nix is not available on Windows, so the required tools have to be installed manually: -- [Visual Studio 2022](https://visualstudio.microsoft.com/) with the +- [Visual Studio 2026](https://visualstudio.microsoft.com/) with the **"Desktop development with C++"** workload — this provides MSVC and the - "x64 Native Tools Command Prompt". + "x64 Native Tools Command Prompt". CI configures CMake with the + `Visual Studio 18 2026` generator. - [Git for Windows](https://git-scm.com/download/win) -- [Python 3.11](https://www.python.org/downloads/), or higher -- [Conan 2.17](https://conan.io/downloads.html), or higher -- [CMake 3.22](https://cmake.org/download/), or higher +- Python, Conan, and CMake, at the versions listed in + [Required tools](#required-tools). +- a [Rust toolchain](https://rustup.rs) — only needed to build with + `-Drust=ON`, see [Rust](#rust) -> [!NOTE] -> Windows is used for development only and is not recommended for production. +## Rust + +The repository contains a Rust workspace in [`crates/`](../../crates), whose +crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not** +part of a default build: the CMake `rust` option is OFF by default, and with it +off no Rust toolchain is needed. It is only required when configuring with +`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options). + +The toolchain (`cargo`, `rustc`) is pinned to the channel in +[`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If +you install Rust with [rustup](https://rustup.rs), that file is picked up +automatically, and `cargo`/`rustc` in the repository will use the pinned +version. + +Everything else the Rust build needs on the CMake side comes from Conan along +with the rest of the dependencies, so there is nothing further to install. ## Clang-tidy `clang-tidy` is required to run static analysis checks locally (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the -project. This project currently uses `clang-tidy` version 22. +project. The version this project uses is listed in +[Tested compiler versions](#tested-compiler-versions). -On Linux and macOS, the [Nix development shell](./nix.md) provides `clang-tidy` -22 out of the box — run it via `run-clang-tidy`. No separate installation is -needed. +On Linux and macOS, the [Nix development shell](./nix.md) provides that exact +version out of the box — run it via `run-clang-tidy`. No separate installation +is needed. + +[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 diff --git a/docs/build/nix.md b/docs/build/nix.md index d6e53a254a..0b701b39f3 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -7,7 +7,7 @@ This guide explains how to use Nix to set up a reproducible development environm ## Benefits of Using Nix - **Reproducible environment**: Everyone gets the same versions of tools and compilers -- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment +- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment, and CI builds some macOS configurations in it as well - **No system pollution**: Dependencies are isolated and don't affect your system packages - **Consistent compilers**: The GCC and Clang shells use the same versions as CI - **Quick setup**: Get started with a single command @@ -38,8 +38,10 @@ The first time you run this command, it will take a few minutes to download and ### Platform notes -- **Linux**: `nix develop` gives you a shell with all the tooling necessary to - develop xrpld and with GCC 15.2 (also provided by Nix). There are no caveats. +- **Linux**: `nix develop` gives you a shell with all the tooling necessary to develop xrpld + and with the same GCC/glibc toolchain that Nix builds for CI. + See [Choosing a different compiler](#choosing-a-different-compiler) + for the custom-vs-plain toolchain trade-off. - **macOS**: `nix develop` gives you a full environment too, with Clang (and every other tool, including Conan) provided by Nix. To use your system-wide Apple Clang instead, enter `nix develop .#apple-clang`. Conan has no binary in @@ -63,8 +65,16 @@ The first time you run this command, it will take a few minutes to download and ### Choosing a different compiler A compiler can be chosen by providing its name with the `.#` prefix, e.g. `nix develop .#clang`. -The `.#gcc` and `.#clang` shells provide the same GCC and Clang versions used in CI -(pinned in [`nix/packages.nix`](../../nix/packages.nix)). + +On Linux, `.#gcc` and `.#clang` provide the exact toolchain CI uses: +the compiler (pinned in [`nix/packages.nix`](../../nix/packages.nix)) +rebuilt against the pinned custom glibc (see [`nix/linux.nix`](../../nix/linux.nix)). +Building that toolchain the first time is slow unless it is fetched from a Nix binary cache. +If you don't need the custom glibc, the Linux-only `.#gcc-plain` and `.#clang-plain` +give you the stock nixpkgs compilers of the same versions. +On macOS there is no custom glibc, so `.#gcc` and `.#clang` are already the plain nixpkgs toolchain, +and the `-plain` variants do not exist. + Use `nix flake show` to see all the available development shells. Use `nix develop .#no-compiler` to use the compiler from your system. @@ -72,14 +82,18 @@ Use `nix develop .#no-compiler` to use the compiler from your system. ### Example Usage ```bash -# Use GCC (same version as CI) +# Use GCC — same toolchain as CI (custom glibc on Linux) nix develop .#gcc -# Use Clang (same version as CI) +# Use Clang — same toolchain as CI (custom glibc on Linux) nix develop .#clang # Use default for your platform nix develop + +# Stock nixpkgs GCC/Clang, Linux only — skips the custom-glibc build, but does not match CI +nix develop .#gcc-plain +nix develop .#clang-plain ``` ### Using a different shell @@ -106,10 +120,108 @@ nix develop -c "$SHELL" > > If it doesn't, either adjust your shell configuration so it doesn't override `$PATH`, or use [direnv](#automatic-activation-with-direnv) (below), which loads the environment _after_ your shell config and so takes precedence regardless of the shell you use. -## Building xrpld with Nix +## Building xrpld in the Nix shell Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). +Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Linux): +each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. +The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. + +Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell +provides the Rust toolchain pinned in +[`rust-toolchain.toml`](../../rust-toolchain.toml) (see +[Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and +`cargo-nextest` plugins. + +## Conan configuration + +The shell runs [`conan/init.sh`](../../conan/init.sh) on entry, so +[Set Up Conan](../../BUILD.md#set-up-conan) is already done for you. It installs +into the shell's own Conan home: `CONAN_HOME=~/.conan2-nix`. + +### Prebuilt packages + +On **Linux**, the binaries on the `xrplf` remote are built in this same Nix +environment — CI runs in Docker images that bundle the dev shell's toolchain (see +[`nix/docker`](../../nix/docker)) — so `.#gcc` and `.#clang` can reuse them. The +`-plain` shells do not match that toolchain's glibc, so binaries from the remote +are not a reliable match there. + +On **macOS**, CI also builds in this Nix environment, in Debug and Release (the +`macos-arm64-*-nix` configurations — Debug because the profile defaults to it). +The Nix build resolves to `compiler=clang`, so it gets its own package IDs, +separate from the Apple Clang ones. The +[dependency upload](../../.github/workflows/upload-conan-deps.yml) publishes them +on pushes to `develop` and on manual runs — its nightly run rebuilds everything +from source but uploads nothing — so once a set has been published `nix develop` +can reuse it instead of compiling every dependency locally. These configurations +run outside the reduced pull-request matrix, so label a PR `Full CI build` when it +touches `flake.lock` or `nix/`. + +To compile everything from source, add `--build '*'` to the `conan install` +command. + +### Why the nixpkgs revision is not part of the package ID + +A Conan package ID records the compiler and its major version, but nothing about +the nixpkgs revision the toolchain came from — and `flake.lock` moves far more +often than the toolchain meaningfully changes, so folding it in would rebuild +every dependency on every bump for nothing. + +That is safe as long as no cached artifact resolves a `/nix/store` path at run +time, because store paths change on every update and the old ones disappear with +`nix-collect-garbage`. With the `clang` toolchain macOS CI and the dev shell use, +they do not: it links against `/usr/lib/libc++` and `/usr/lib/libSystem`, and +store paths reach the `.a` files only through debug info, which nothing resolves +at link or run time. + +> [!WARNING] +> This does not hold for `nix develop .#gcc` on macOS. There is no system +> libstdc++, so GCC links its own from the store and every binary keeps a +> `/nix/store` reference. That shell is fine for tooling, but it is not a build +> configuration CI covers, and no dependency binaries are published for it. + +This is checked rather than assumed. +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) takes one file +or directory and fails if a binary under it resolves a store path at run time. +CI runs it over the build output and the Conan cache, and again in the upload job +before anything is published. You can run it yourself: + +```bash +bin/check-nix-store-refs.sh build +bin/check-nix-store-refs.sh ~/.conan2-nix +``` + +It works on Linux too, but asserts something narrower there: the toolchain always +writes the store into `PT_INTERP` and `RUNPATH`, and CI builds inside an image +whose store is fixed for its lifetime, so that is fine. Only the binaries +[`PatchNixBinary.cmake`](../../cmake/PatchNixBinary.cmake) retargets to the +system loader have to be clean, and those are what CI checks: + +```bash +bin/check-nix-store-refs.sh build/xrpld +``` + +### The libresolv stub + +This is not hypothetical: `xrpld` used to be caught by it. The c-ares package +tells the linker to pass `-lresolv`, and nixpkgs keeps `libresolv` out of the +macOS SDK and ships it as an ordinary store dylib — so every Nix-built `xrpld` +recorded a `/nix/store/…-libresolv-93/lib/libresolv.9.dylib` load command and +stopped running once that path was collected. Nothing in the link uses a single +symbol from it. + +Both environments now put a stub on the linker search path +(`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix)): the +same library with its install name set to `/usr/lib/libresolv.9.dylib`, which is +exactly the load command the Apple Clang build records. + +Package IDs did not change, so Conan keeps serving anything built before the +stub landed. If a binary fails to start with `Library not loaded: /nix/store/…`, +see [that entry](./nix_troubleshooting.md#library-not-loaded-nixstore-from-a-binary-that-used-to-work) +in the troubleshooting guide. + ## Automatic Activation with direnv [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory. @@ -124,18 +236,18 @@ The repository already ships an `.envrc` at its root that activates the Nix flak > [!NOTE] > direnv only caches the `.direnv` directory (already listed in `.gitignore`); no other repository files are affected. -## Conan and Prebuilt Packages - -Please note that there is no guarantee that binaries from conan cache will work when using nix. If you encounter any errors, please use `--build '*'` to force conan to compile everything from source: - -```bash -conan install .. --output-folder . --build '*' --settings build_type=Release -``` - ## Updating `flake.lock` file To update `flake.lock` to the latest revision use `nix flake update` command. +## Tooling snapshots + +The tool versions in each Nix environment are recorded in +[`nix/check-tools/`](../../nix/check-tools) and verified by CI. If you change the +environment (bump the CI image tag, update `flake.lock`, or edit the tool list in +`bin/check-tools.sh`), CI fails until you regenerate and commit the affected +snapshot — see [`nix/check-tools/README.md`](../../nix/check-tools/README.md). + ## Troubleshooting See [Troubleshooting Nix problems](./nix_troubleshooting.md) for common issues, diff --git a/docs/build/nix_troubleshooting.md b/docs/build/nix_troubleshooting.md index ae5cb8059a..49088ab6b4 100644 --- a/docs/build/nix_troubleshooting.md +++ b/docs/build/nix_troubleshooting.md @@ -3,6 +3,78 @@ Common issues encountered when using the [Nix development shell](./nix.md), and how to resolve them. +## `command not found: nix` after a macOS update + +If a shell suddenly can't find `nix` at all: + +``` +$ nix develop +zsh: command not found: nix +``` + +then Nix is almost certainly still installed — only the shell hook that puts it +on your `PATH` is gone. Confirm that first: + +```bash +ls -l /nix/var/nix/profiles/default/bin/nix +``` + +If that exists, the installation is fine and this is purely a `PATH` problem. + +### Why it happens + +The installer does not touch your dotfiles. Instead it sources a setup script +from the Nix store by editing **system-wide** rc files: + +| Shell | File the installer edits | +| ----- | ------------------------------------- | +| bash | `/etc/bashrc`, `/etc/bash.bashrc` | +| zsh | `/etc/zshrc` | +| fish | `$__fish_sysconf_dir/conf.d/nix.fish` | + +macOS manages `/etc/zshrc`, so an OS update can replace it with the vendor copy +and silently drop the Nix block. `/etc/bashrc` and the fish file usually survive, +which is why the breakage often shows up in zsh only. You can verify this by +diffing against the backup the installer left behind: + +```bash +diff /etc/zshrc /etc/zshrc.backup-before-nix +``` + +If they are identical, the Nix snippet was wiped. This is upstream issue +[NixOS/nix#3616](https://github.com/NixOS/nix/issues/3616). + +### Fix + +To unblock the current shell: + +```bash +. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +``` + +For a permanent fix, add the snippet to your **user** rc file rather than +restoring `/etc/zshrc` — user dotfiles are not clobbered by OS updates: + +```bash +cat >>~/.zshrc <<'EOF' + +# Nix +if [ -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ]; then + . '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' +fi +# End Nix +EOF +``` + +The scripts guard against double-sourcing via `__ETC_PROFILE_NIX_SOURCED`, so +this is safe even if a system-wide hook is later restored. + +> [!NOTE] +> `/etc/zshrc` and `~/.zshrc` are only read by **interactive** zsh. If the +> snippet is present but `zsh -c '…'`, a script, or an IDE terminal still can't +> find `nix`, that shell is non-interactive — put the snippet in `~/.zshenv` +> instead. + ## Git worktrees If `nix develop` fails with an error like: @@ -59,3 +131,91 @@ once it picks up that rebuild, then re-run the `grep libgit2` check above to confirm it reports `1.9.4` or newer. Until then, prefer the workarounds above. + +## `wint_t` / `uint32_t` errors from the Nix libc++ headers + +A build that mixes the Nix toolchain with the system SDK fails in libc++ itself, +with errors that look nothing like your code: + +``` +/nix/store/...-libcxx-.../include/c++/v1/cwchar:136:9: error: target of using declaration conflicts with declaration already in scope + 136 | using ::wint_t _LIBCPP_USING_IF_EXISTS; +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/_types/_wint_t.h:32:25: note: target of using declaration +... +error: use of undeclared identifier 'UINT32_C' +``` + +The give-away is the second path: Nix's libc++ headers are being combined with +the **Xcode Command Line Tools** SDK instead of the Nix one. + +### Why it happens + +`SDKROOT` and `DEVELOPER_DIR` are what point the toolchain at the Nix SDK, and +they are not baked into the compiler — a dev shell gets them from the +`apple-sdk` setup hook. CMake, finding neither, asks `xcrun`, which answers with +the system SDK. Nix's `libc++` and Apple's headers then declare the same types +twice. + +### Fix + +Run the build from inside the dev shell (`nix develop`), or from an environment +that exports both variables. To confirm which SDK a configured build is using: + +```bash +grep -o '\-isysroot [^ ]*' build/compile_commands.json | sort -u +``` + +It should print a `/nix/store/...-apple-sdk-*` path. If it prints +`/Library/Developer/CommandLineTools/...`, re-configure from within the shell — +CMake caches the sysroot, so an existing `build/` directory keeps the wrong one. + +## `Library not loaded: /nix/store/…` from a binary that used to work + +A binary stops starting after a `nix flake update`, or after +`nix-collect-garbage` removes the paths the previous toolchain used: + +``` +dyld[57271]: Library not loaded: /nix/store/…-libresolv-93/lib/libresolv.9.dylib +``` + +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) finds the same +thing without having to run anything, and names the file: + +``` +$ bin/check-nix-store-refs.sh ~/.conan2-nix +::error file=/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig::references the Nix store at run time +/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig + /nix/store/p4lp3xq4imd1qzqh08x8vcq2zfhi7rca-libresolv-93/lib/libresolv.9.dylib +/Users/you/.conan2-nix: checked 135, skipped 2495, 1 with Nix store references. +``` + +Conan's cache folders are named after a truncated package name plus a hash, so +ask Conan which package the offending one belongs to — pass the folder holding +the hash, not the file itself: + +``` +$ conan cache ref ~/.conan2-nix/p/b/c-area24ded30c388c +c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650:dab5992496abe6d219defb7986ecbf367615a5e5#… +``` + +### Why it happens + +The binary records a store path that no longer exists. Nothing we build should: +see [Prebuilt packages](./nix.md#prebuilt-packages) for why, and +`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix) for the one +dependency that needed help to comply. + +A Conan package ID does not encode the nixpkgs revision, so a package built +before that stub existed stays in your local cache and keeps being reused. The +dev shell is also what tends to produce one: it is a slightly _less_ isolated +build environment than CI's, because `mkShell` puts every tool's headers and +libraries on the compiler's search path — which is how c-ares found the Nix +`libresolv` in the first place. + +### Fix + +Drop that package and let Conan refetch or rebuild it: + +```bash +conan remove 'c-ares/*' +``` diff --git a/docs/build/install.md b/docs/install-legacy.md similarity index 87% rename from docs/build/install.md rename to docs/install-legacy.md index d3ce1e9d87..0a6800f17f 100644 --- a/docs/build/install.md +++ b/docs/install-legacy.md @@ -1,3 +1,10 @@ +# Installing xrpld 3.3.0 and earlier + +> [!IMPORTANT] +> These instructions apply to xrpld 3.3.0 and earlier, published to +> repos.ripple.com. +> For later releases see [install.md](./install.md). + This document contains instructions for installing xrpld. The APT package manager is common on Debian-based Linux distributions like Ubuntu, @@ -52,7 +59,7 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and 5. Add the appropriate XRPL repository for your operating system version: - echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/xrpld-deb focal stable" | \ + echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb focal stable" | \ sudo tee -a /etc/apt/sources.list.d/ripple.list The above example is appropriate for **Ubuntu 20.04 Focal Fossa**. For other operating systems, replace the word `focal` with one of the following: @@ -106,8 +113,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/stable/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/stable/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/stable/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key REPOFILE _Unstable_ @@ -118,8 +125,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/unstable/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/unstable/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/unstable/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/unstable/repodata/repomd.xml.key REPOFILE _Nightly_ @@ -130,8 +137,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and enabled=1 gpgcheck=0 repo_gpgcheck=1 - baseurl=https://repos.ripple.com/repos/xrpld-rpm/nightly/ - gpgkey=https://repos.ripple.com/repos/xrpld-rpm/nightly/repodata/repomd.xml.key + baseurl=https://repos.ripple.com/repos/rippled-rpm/nightly/ + gpgkey=https://repos.ripple.com/repos/rippled-rpm/nightly/repodata/repomd.xml.key REPOFILE 2. Fetch the latest repo updates: diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000000..ee9c31868b --- /dev/null +++ b/docs/install.md @@ -0,0 +1,144 @@ +# Installing xrpld + +> [!NOTE] +> These instructions apply to packages published from 2026-08-19 onwards. +> For xrpld 3.3.0 and earlier see [install-legacy.md](./install-legacy.md). + +`xrpld` is published as DEB and RPM packages for 64-bit x86 Linux. +Use APT on Debian-based distributions such as Debian and Ubuntu, +and YUM on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux. +To build from source instead, see [BUILD.md](../BUILD.md). + +## Release channels + +Packages are published to four channels: + +- `stable` - the latest production release +- `unstable` - release candidates +- `experimental` - beta builds +- `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop) + +See [Publishing packages](../package/README.md#publishing-packages) for how channels are produced. + +The instructions below use `stable`. +To follow another channel, replace `stable` with its name +wherever it appears in the repository configuration. + +> [!WARNING] +> Channels other than `stable` may be broken at any time. +> Do not use them for production servers. + +## Install the xrpld package + +### With the APT package manager + +1. Install utilities: + + ```bash + sudo apt update -y + sudo apt install -y apt-transport-https ca-certificates curl gnupg + ``` + +2. Add the XRPL Foundation package-signing key to your list of trusted keys: + + ```bash + sudo install -d -m 0755 /etc/apt/keyrings + sudo curl -fsS https://packages.xrplf.org/xrplf.asc -o /etc/apt/keyrings/xrplf.asc + ``` + +3. Check the fingerprint of the newly-added key: + + ```bash + gpg --show-keys /etc/apt/keyrings/xrplf.asc + ``` + + The output should be: + + ```text + pub rsa4096 2026-08-18 [SC] + B655416741221F780FBCFBC9AA84D41A11D29FA9 + uid XRPLF Packages + ``` + + In particular, make sure that the fingerprint matches. + +4. Add the repository, using the channel you picked in [Release channels](#release-channels): + + ```bash + echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable any main" | \ + sudo tee /etc/apt/sources.list.d/xrplf.list + ``` + +5. Fetch the repository: + + ```bash + sudo apt -y update + ``` + +6. Install the `xrpld` software package: + + ```bash + sudo apt -y install xrpld + ``` + +### With the YUM package manager + +1. Add the XRPL Foundation package-signing key: + + ```bash + sudo rpm --import https://packages.xrplf.org/xrplf.asc + ``` + +2. Add the repository, using the channel you picked in [Release channels](#release-channels): + + ```bash + cat << 'REPOFILE' | sudo tee /etc/yum.repos.d/xrplf.repo + [xrplf-stable] + name=XRP Ledger Packages + enabled=1 + baseurl=https://packages.xrplf.org/repository/rpm-stable/$basearch/ + gpgcheck=1 + repo_gpgcheck=1 + gpgkey=https://packages.xrplf.org/xrplf.asc + REPOFILE + ``` + + `gpgcheck=1` verifies each package against the key above. + `repo_gpgcheck=1` verifies the repository metadata, which the server signs with the same key. + +3. Install the `xrpld` package: + + ```bash + sudo yum install -y xrpld + ``` + +## The xrpld service + +Both package managers install a systemd unit and enable it, so `xrpld` starts on boot. +Check whether it is already running: + +```bash +systemctl status xrpld.service +``` + +The APT packages start it immediately as well; the YUM packages do not, so start it yourself: + +```bash +sudo systemctl start xrpld.service +``` + +### Optional: binding to privileged ports + +To serve incoming API requests on port 80 or 443, grant the service the capability to bind them. +You must also update the config file's port settings. + +```bash +sudo install -d -m 0755 /etc/systemd/system/xrpld.service.d +sudo tee /etc/systemd/system/xrpld.service.d/privileged-ports.conf >/dev/null <<'EOF' +[Service] +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +EOF +sudo systemctl daemon-reload +sudo systemctl restart xrpld.service +``` diff --git a/docs/sample_chart.doc b/docs/sample_chart.doc deleted file mode 100644 index 631c0554b2..0000000000 --- a/docs/sample_chart.doc +++ /dev/null @@ -1,24 +0,0 @@ -/*! - \page somestatechart Example state diagram - - \startuml SomeState "my state diagram" - scale 600 width - - [*] -> State1 - State1 --> State2 : Succeeded - State1 --> [*] : Aborted - State2 --> State3 : Succeeded - State2 --> [*] : Aborted - state State3 { - state "Accumulate Enough Data\nLong State Name" as long1 - long1 : Just a test - [*] --> long1 - long1 --> long1 : New Data - long1 --> ProcessData : Enough Data - } - State3 --> State3 : Failed - State3 --> [*] : Succeeded / Save Result - State3 --> [*] : Aborted - - \enduml -*/ diff --git a/flake.lock b/flake.lock index 80243ccf15..cd9289c998 100644 --- a/flake.lock +++ b/flake.lock @@ -36,7 +36,28 @@ "root": { "inputs": { "nixpkgs": "nixpkgs", - "nixpkgs-custom-glibc": "nixpkgs-custom-glibc" + "nixpkgs-custom-glibc": "nixpkgs-custom-glibc", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784611586, + "narHash": "sha256-OfqgY+0hp/zseZB7uyH0U8kIDPS4scZZCyAurEplvG0=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "14f58845249f3552a89b07772626b8d3c632fa86", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index c52f4d050e..ee2fd13efc 100644 --- a/flake.nix +++ b/flake.nix @@ -10,12 +10,25 @@ url = "github:NixOS/nixpkgs/9cd98386a38891d1074fc18036b842dc4416f562"; flake = false; }; + # Pinned Rust toolchains, delivered from the Nix store. Lets the Nix CI + # image and dev shell honour the single `rust-toolchain.toml` pin (shared + # with the rustup-based non-Nix runners) while staying hermetic — the + # toolchain lands in the image's Nix closure and is locked by flake.lock. + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = - { nixpkgs, nixpkgs-custom-glibc, ... }: + { + nixpkgs, + nixpkgs-custom-glibc, + rust-overlay, + ... + }: let - forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-custom-glibc; }; + forEachSystem = import ./nix/utils.nix { inherit nixpkgs nixpkgs-custom-glibc rust-overlay; }; in { devShells = forEachSystem (import ./nix/devshell.nix); diff --git a/include/xrpl/basics/Archive.h b/include/xrpl/basics/Archive.h index 66d6a019af..67261352e9 100644 --- a/include/xrpl/basics/Archive.h +++ b/include/xrpl/basics/Archive.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace xrpl { @@ -13,6 +13,6 @@ namespace xrpl { * @throws runtime_error */ void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst); +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst); } // namespace xrpl diff --git a/include/xrpl/basics/Buffer.h b/include/xrpl/basics/Buffer.h index 05af6c409a..00a6b7ecf9 100644 --- a/include/xrpl/basics/Buffer.h +++ b/include/xrpl/basics/Buffer.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -156,6 +157,19 @@ public: } /** @} */ + /** + * Set every byte in the buffer to the given value. + * + * The size is unchanged, and this is a no-op on an empty buffer. + * + * @param value the byte to write to every position. + */ + void + fill(std::uint8_t value) noexcept + { + std::fill_n(p_.get(), size_, value); + } + /** * Reset the buffer. * All memory is deallocated. The resulting size is 0. @@ -226,10 +240,4 @@ operator==(Buffer const& lhs, Buffer const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Buffer const& lhs, Buffer const& rhs) noexcept -{ - return !(lhs == rhs); -} - } // namespace xrpl diff --git a/include/xrpl/basics/FileUtilities.h b/include/xrpl/basics/FileUtilities.h index c7a427b8a9..ca3435be03 100644 --- a/include/xrpl/basics/FileUtilities.h +++ b/include/xrpl/basics/FileUtilities.h @@ -1,24 +1,79 @@ #pragma once -#include -#include - #include +#include #include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize = std::nullopt); void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents); +/** + * Generate a unique, non-existing path under @p base whose filename starts with + * @p prefix and ends with a random hex suffix. + * + * Attempts up to @p maxAttempts paths. Throws `std::runtime_error` if a unique + * path cannot be found or if the filesystem returns an error while checking for + * existence. + */ +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix = "", + std::size_t maxAttempts = 100); + +/** + * RAII temporary directory. + * + * The directory and all its contents are deleted when + * the instance of `TempDir` is destroyed. + */ +class TempDir +{ + std::filesystem::path path_; + +public: +#if !GENERATING_DOCS + TempDir(TempDir const&) = delete; + TempDir& + operator=(TempDir const&) = delete; +#endif + + /** + * Construct a temporary directory. + */ + TempDir(); + + /** + * Destroy a temporary directory. + */ + ~TempDir(); + + /** + * Get the native path for the temporary directory. + */ + [[nodiscard]] std::string + path() const; + + /** + * Get the native path for a file. + * + * The file does not need to exist. + */ + [[nodiscard]] std::string + file(std::string const& name) const; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/IntrusivePointer.h b/include/xrpl/basics/IntrusivePointer.h index 59853ad4d0..b978016860 100644 --- a/include/xrpl/basics/IntrusivePointer.h +++ b/include/xrpl/basics/IntrusivePointer.h @@ -96,9 +96,6 @@ public: SharedIntrusive& operator=(SharedIntrusive const& rhs); - bool - operator!=(std::nullptr_t) const; - bool operator==(std::nullptr_t) const; diff --git a/include/xrpl/basics/IntrusivePointer.ipp b/include/xrpl/basics/IntrusivePointer.ipp index 67d43b05d6..6c2a71f7eb 100644 --- a/include/xrpl/basics/IntrusivePointer.ipp +++ b/include/xrpl/basics/IntrusivePointer.ipp @@ -111,13 +111,6 @@ SharedIntrusive::operator=(SharedIntrusive&& rhs) return *this; } -template -bool -SharedIntrusive::operator!=(std::nullptr_t) const -{ - return this->get() != nullptr; -} - template bool SharedIntrusive::operator==(std::nullptr_t) const diff --git a/include/xrpl/basics/Log.h b/include/xrpl/basics/Log.h index 945dc1b4ec..3aceac5f4a 100644 --- a/include/xrpl/basics/Log.h +++ b/include/xrpl/basics/Log.h @@ -3,8 +3,8 @@ #include #include -#include +#include #include #include #include @@ -84,7 +84,7 @@ private: * @return `true` if the file was opened. */ bool - open(boost::filesystem::path const& path); + open(std::filesystem::path const& path); /** * Close and re-open the system file associated with the log @@ -133,7 +133,7 @@ private: private: std::unique_ptr stream_; - boost::filesystem::path path_; + std::filesystem::path path_; }; std::mutex mutable mutex_; @@ -152,7 +152,7 @@ public: virtual ~Logs() = default; bool - open(boost::filesystem::path const& pathToLogFile); + open(std::filesystem::path const& pathToLogFile); beast::Journal::Sink& get(std::string const& name); diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index f90800c715..f6ce0b300d 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -304,7 +304,7 @@ concept Integral64 = std::is_same_v || std::is_same_v)>; + 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..e3b91c2f25 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -2,7 +2,6 @@ #include -#include #include #include @@ -125,9 +124,31 @@ struct ParsedUrl bool parseUrl(ParsedUrl& pUrl, std::string const& strUrl); +/** + * Remove leading and trailing ASCII whitespace. + * + * Whitespace is the fixed set " \t\n\v\f\r"; the current locale is not + * consulted, so the result depends only on the input. + * + * @param str The string to trim. + * @return @p str without leading or trailing whitespace. + */ std::string trimWhitespace(std::string str); +/** + * Fold ASCII upper case letters to lower case. + * + * Only 'A' through 'Z' are remapped; every other byte is left alone and the + * current locale is not consulted, so the result depends only on the input. + * + * @param str The string to fold. + * @return @p str with each ASCII upper case letter replaced by its lower case + * equivalent. + */ +std::string +toLower(std::string str); + std::optional toUInt64(std::string const& s); diff --git a/include/xrpl/basics/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/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 db10e8cc23..ea271feed0 100644 --- a/include/xrpl/beast/container/detail/aged_unordered_container.h +++ b/include/xrpl/beast/container/detail/aged_unordered_container.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -1339,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..7422778ea2 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; 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 1c33e759a0..97c625ba16 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,7 +119,9 @@ 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"; diff --git a/src/xrpld/app/consensus/RCLCensorshipDetector.h b/include/xrpl/consensus/CensorshipDetector.h similarity index 98% rename from src/xrpld/app/consensus/RCLCensorshipDetector.h rename to include/xrpl/consensus/CensorshipDetector.h index 6d0e20031e..3d2708f68f 100644 --- a/src/xrpld/app/consensus/RCLCensorshipDetector.h +++ b/include/xrpl/consensus/CensorshipDetector.h @@ -10,7 +10,7 @@ namespace xrpl { template -class RCLCensorshipDetector +class CensorshipDetector { public: struct TxIDSeq @@ -49,7 +49,7 @@ private: TxIDSeqVec tracker_; public: - RCLCensorshipDetector() = default; + CensorshipDetector() = default; /** * Add transactions being proposed for the current consensus round. diff --git a/src/xrpld/consensus/Consensus.h b/include/xrpl/consensus/Consensus.h similarity index 98% rename from src/xrpld/consensus/Consensus.h rename to include/xrpl/consensus/Consensus.h index 440191939b..4c48e7f268 100644 --- a/src/xrpld/consensus/Consensus.h +++ b/include/xrpl/consensus/Consensus.h @@ -1,15 +1,14 @@ #pragma once -#include -#include -#include - #include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -22,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -1580,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/src/xrpld/consensus/ConsensusParms.h b/include/xrpl/consensus/ConsensusParms.h similarity index 100% rename from src/xrpld/consensus/ConsensusParms.h rename to include/xrpl/consensus/ConsensusParms.h diff --git a/src/xrpld/consensus/ConsensusProposal.h b/include/xrpl/consensus/ConsensusProposal.h similarity index 100% rename from src/xrpld/consensus/ConsensusProposal.h rename to include/xrpl/consensus/ConsensusProposal.h diff --git a/src/xrpld/consensus/ConsensusTypes.h b/include/xrpl/consensus/ConsensusTypes.h similarity index 74% rename from src/xrpld/consensus/ConsensusTypes.h rename to include/xrpl/consensus/ConsensusTypes.h index 4553e46f48..56739527a1 100644 --- a/src/xrpld/consensus/ConsensusTypes.h +++ b/include/xrpl/consensus/ConsensusTypes.h @@ -1,15 +1,16 @@ #pragma once -#include -#include - #include #include #include +#include +#include #include #include +#include #include +#include #include namespace xrpl { @@ -190,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/src/xrpld/consensus/DisputedTx.h b/include/xrpl/consensus/DisputedTx.h similarity index 99% rename from src/xrpld/consensus/DisputedTx.h rename to include/xrpl/consensus/DisputedTx.h index 12ed00d460..c194716d43 100644 --- a/src/xrpld/consensus/DisputedTx.h +++ b/include/xrpl/consensus/DisputedTx.h @@ -1,9 +1,8 @@ #pragma once -#include - #include #include +#include #include #include diff --git a/src/xrpld/consensus/LedgerTrie.h b/include/xrpl/consensus/LedgerTrie.h similarity index 100% rename from src/xrpld/consensus/LedgerTrie.h rename to include/xrpl/consensus/LedgerTrie.h diff --git a/src/xrpld/consensus/README.md b/include/xrpl/consensus/README.md similarity index 100% rename from src/xrpld/consensus/README.md rename to include/xrpl/consensus/README.md diff --git a/src/xrpld/consensus/Validations.h b/include/xrpl/consensus/Validations.h similarity index 99% rename from src/xrpld/consensus/Validations.h rename to include/xrpl/consensus/Validations.h index 2696804c86..ebb13c5e7c 100644 --- a/src/xrpld/consensus/Validations.h +++ b/include/xrpl/consensus/Validations.h @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include @@ -11,6 +9,7 @@ #include #include #include +#include #include #include diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index f09665e291..dd78a8f9a6 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -4,10 +4,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -44,7 +43,7 @@ public: */ struct Setup { - boost::filesystem::path perfLog; + std::filesystem::path perfLog; // log_interval is in milliseconds to support faster testing. milliseconds logInterval{seconds(1)}; }; @@ -149,7 +148,7 @@ public: }; PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir); +setupPerfLog(Section const& section, std::filesystem::path const& configDir); std::unique_ptr makePerfLog( diff --git a/include/xrpl/core/ServiceRegistry.h b/include/xrpl/core/ServiceRegistry.h index 592964134b..000bdaa7fa 100644 --- a/include/xrpl/core/ServiceRegistry.h +++ b/include/xrpl/core/ServiceRegistry.h @@ -15,12 +15,12 @@ namespace xrpl { // Forward declarations -namespace NodeStore { +namespace node_store { class Database; -} // namespace NodeStore -namespace Resource { +} // namespace node_store +namespace resource { class Manager; -} // namespace Resource +} // namespace resource namespace perf { class PerfLog; } // namespace perf @@ -160,11 +160,11 @@ public: virtual PeerReservationTable& getPeerReservations() = 0; - virtual Resource::Manager& + virtual resource::Manager& getResourceManager() = 0; // Storage services - virtual NodeStore::Database& + virtual node_store::Database& getNodeStore() = 0; virtual SHAMapStore& 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/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index 7d41bfce81..a68171c426 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -226,7 +226,7 @@ getAMMOfferStartWithTakerGets( auto getAmounts = [&pool, &tfee](Number const& nTakerGetsProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerGets is XRP. + // This has the most impact when takerGets is integral. auto const takerGets = toAmount(getAsset(pool.out), nTakerGetsProposed, Number::RoundingMode::Downward); return TAmounts{swapAssetOut(pool, takerGets, tfee), takerGets}; @@ -294,7 +294,7 @@ getAMMOfferStartWithTakerPays( auto getAmounts = [&pool, &tfee](Number const& nTakerPaysProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerPays is XRP. + // This has the most impact when takerPays is integral. auto const takerPays = toAmount(getAsset(pool.in), nTakerPaysProposed, Number::RoundingMode::Downward); return TAmounts{takerPays, swapAssetIn(pool, takerPays, tfee)}; @@ -313,11 +313,11 @@ getAMMOfferStartWithTakerPays( * is equal to LOB quality (in this case AMM offer quality is * better than LOB quality) or AMM offer is equal to LOB quality * (in this case SPQ is better than LOB quality). - * Pre-amendment code calculates takerPays first. If takerGets is XRP, - * it is rounded down, which results in worse offer quality than - * LOB quality, and the offer might fail to generate. - * Post-amendment code calculates the XRP offer side first. The result - * is rounded down, which makes the offer quality better. + * Pre-amendment code calculates takerPays first. If takerGets is the + * economically coarser integral side, it is rounded down, which results in + * worse offer quality than LOB quality, and the offer might fail to generate. + * Post-amendment code calculates the economically coarser integral offer side + * first. The result is rounded down, which makes the offer quality better. * It might not be possible to match either SPQ or AMM offer to LOB * quality. This generally happens at higher fees. * @param pool AMM pool balances @@ -396,10 +396,18 @@ changeSpotPriceQuality( return std::nullopt; } - // Generate the offer starting with XRP side. Return seated offer amounts - // if the offer can be generated, otherwise nullopt. auto amounts = [&]() { - if (isXRP(getAsset(pool.out))) + bool const inIntegral = getAsset(pool.in).integral(); + bool const outIntegral = getAsset(pool.out).integral(); + + // Preserve historical behavior for fractional pairs and XRP/IOU-style + // one-integral-side pairs. For two integral assets, pick the side whose + // minimum unit is economically coarser at this quality. + // + // Quality::rate() is input units per output unit, so one output unit is + // coarser when it costs at least one input unit. Ties use takerGets, + // matching the historical XRP-output behavior. + if (outIntegral && (!inIntegral || Number(quality.rate()) >= 1)) return getAMMOfferStartWithTakerGets(pool, quality, tfee); return getAMMOfferStartWithTakerPays(pool, quality, tfee); }(); diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 8e78a00923..8b1c819bf4 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +35,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); // Amendment and parameters checks for sfCredentialIDs field NotTEC -checkFields(STTx const& tx, beast::Journal j); +checkFields(STTx const& tx, Rules const& rules, beast::Journal j); // Accessing the ledger to check if provided credentials are valid. Do not use // in doApply (only in preclaim) since it does not remove expired credentials. diff --git a/include/xrpl/ledger/helpers/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..4aa89ea672 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -21,6 +22,7 @@ #include #include +#include #include #include @@ -58,6 +60,42 @@ canApplyToBrokerCover( bool checkLendingProtocolDependencies(Rules const& rules, STTx const& tx); +/** + * The accounts and asset that LoanManage::defaultLoan's fixCleanup3_4_0 + * freeze/lock exemption applies to. + * + * `defaultLoan` moves funds from the LoanBroker pseudo-account to the Vault + * pseudo-account via `accountSend`. Since neither is the vault asset's + * issuer, this is a third-party transfer that transits through the issuer in + * two hops (broker -> issuer, issuer -> vault; see + * `directSendNoLimitIOU`/`directSendNoLimitMPT`), so the exemption must cover + * both the issuer/broker and issuer/vault pairs, not a direct broker/vault + * pair. `asset` scopes it further to the vault's own currency/MPT issuance, + * so an unrelated one the same accounts happen to hold is still protected. + */ +struct LoanDefaultFreezeExemptAccounts +{ + AccountID issuer; + AccountID broker; + AccountID vault; + Asset asset; +}; + +/** + * Resolves the accounts and asset a LoanManage default transaction is + * exempt from freeze/lock for. + * + * @param view Ledger view used to resolve the Loan -> LoanBroker -> Vault + * chain. + * @param tx The transaction under invariant review. + * @return The exempt accounts and asset if `tx` is a `ttLOAN_MANAGE` + * transaction with the `tfLoanDefault` flag set, `fixCleanup3_4_0` is + * enabled, and the loan/broker/vault objects it references can all be + * resolved; `std::nullopt` otherwise. + */ +[[nodiscard]] std::optional +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx); + static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60; Number @@ -286,6 +324,77 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +// 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; +}; + +// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is +// recognized into AssetsTotal/DebtTotal up front, at origination. +namespace accrual { + +// 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 accrual + +// 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 accrual::. 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/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 1bd1663314..c898e9e148 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,14 +1,20 @@ #pragma once +#include #include #include +#include +#include #include #include +#include #include namespace xrpl { +class STTx; + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -51,6 +57,38 @@ enum class TruncateShares : bool { No = false, Yes = true }; */ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; +/** + * Returns the effective total of assets backing outstanding shares for the + * purposes of a withdrawal, i.e. sfAssetsTotal, discounted by sfLossUnrealized + * unless waived. This is the numerator used by both withdraw conversion + * helpers (assetsToSharesWithdraw and sharesToAssetsWithdraw) to compute the + * share/asset exchange rate. + * + * @param vault The vault SLE. + * @param waive Whether to waive (i.e. not subtract) the vault's unrealized + * loss. + */ +[[nodiscard]] Number +assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive); + +/** + * Returns whether debiting `amount` from `total` — the current value of a + * vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back + * to the exact same STAmount value it started at. This happens when a + * genuinely non-zero debit is dust relative to a `total` large enough to + * exceed STAmount's significant-digit precision: the shares still move, but + * the stored total doesn't change, which otherwise trips the ValidVault + * invariant after the fact instead of failing cleanly upfront. + * + * @param asset The vault's underlying asset, used to canonicalize both sides + * the same way the ledger will when the field is stored. + * @param total The field's current value. + * @param amount The amount to debit. A value of zero always returns false; + * that case is rejected separately and unconditionally. + */ +[[nodiscard]] bool +debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount); + /** * From the perspective of a vault, return the number of shares to demand from * the depositor when they ask to withdraw a fixed amount of assets. Since @@ -107,4 +145,97 @@ 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 (accrual 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); + } // 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/Backend.h b/include/xrpl/nodestore/Backend.h index 564a874c5e..85d076bcfb 100644 --- a/include/xrpl/nodestore/Backend.h +++ b/include/xrpl/nodestore/Backend.h @@ -13,7 +13,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * A backend used for the NodeStore. @@ -163,4 +163,4 @@ public: fdRequired() const = 0; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/Database.h b/include/xrpl/nodestore/Database.h index 96ba91bd76..902cfc9e03 100644 --- a/include/xrpl/nodestore/Database.h +++ b/include/xrpl/nodestore/Database.h @@ -25,7 +25,7 @@ namespace xrpl { class Section; } // namespace xrpl -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Persistency layer for NodeObject @@ -248,7 +248,7 @@ protected: void storeStats(std::uint64_t count, std::uint64_t sz) { - XRPL_ASSERT(count <= sz, "xrpl::NodeStore::Database::storeStats : valid inputs"); + XRPL_ASSERT(count <= sz, "xrpl::node_store::Database::storeStats : valid inputs"); storeCount_ += count; storeSz_ += sz; } @@ -308,4 +308,4 @@ private: threadEntry(); }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/DatabaseRotating.h b/include/xrpl/nodestore/DatabaseRotating.h index 5381b5c435..21b8b422c7 100644 --- a/include/xrpl/nodestore/DatabaseRotating.h +++ b/include/xrpl/nodestore/DatabaseRotating.h @@ -9,7 +9,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /* This class has two key-value store Backend objects for persisting SHAMap * records. This facilitates online deletion of data. New backends are @@ -38,7 +38,7 @@ public: */ virtual void rotate( - std::unique_ptr&& newBackend, + std::unique_ptr&& newBackend, std::function const& f) = 0; @@ -56,4 +56,4 @@ public: setRotationInFlight(bool inFlight) = 0; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/DummyScheduler.h b/include/xrpl/nodestore/DummyScheduler.h index 49b0d37462..fc7a040b5a 100644 --- a/include/xrpl/nodestore/DummyScheduler.h +++ b/include/xrpl/nodestore/DummyScheduler.h @@ -3,7 +3,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Simple NodeStore Scheduler that just performs the tasks synchronously. @@ -21,4 +21,4 @@ public: onBatchWrite(BatchWriteReport const& report) override; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/Factory.h b/include/xrpl/nodestore/Factory.h index a18023a8a8..568e9b0712 100644 --- a/include/xrpl/nodestore/Factory.h +++ b/include/xrpl/nodestore/Factory.h @@ -14,7 +14,7 @@ namespace xrpl { class Section; } // namespace xrpl -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Base class for backend factories. @@ -70,4 +70,4 @@ public: } }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/Manager.h b/include/xrpl/nodestore/Manager.h index 54d99fe94b..1b869e4bca 100644 --- a/include/xrpl/nodestore/Manager.h +++ b/include/xrpl/nodestore/Manager.h @@ -10,7 +10,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Singleton for managing NodeStore factories and back ends. @@ -98,4 +98,4 @@ public: beast::Journal journal) = 0; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/NodeObject.h b/include/xrpl/nodestore/NodeObject.h index b96d65fa12..db139b9aa8 100644 --- a/include/xrpl/nodestore/NodeObject.h +++ b/include/xrpl/nodestore/NodeObject.h @@ -8,7 +8,7 @@ #include #include -// VFALCO NOTE Intentionally not in the NodeStore namespace +// VFALCO NOTE Intentionally not in the node_store namespace namespace xrpl { diff --git a/include/xrpl/nodestore/Scheduler.h b/include/xrpl/nodestore/Scheduler.h index 5d93a80eaa..40c36ce8ab 100644 --- a/include/xrpl/nodestore/Scheduler.h +++ b/include/xrpl/nodestore/Scheduler.h @@ -4,7 +4,7 @@ #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { enum class FetchType { Synchronous, Async }; @@ -71,4 +71,4 @@ public: onBatchWrite(BatchWriteReport const& report) = 0; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/Task.h b/include/xrpl/nodestore/Task.h index 59fe648476..22cec62eae 100644 --- a/include/xrpl/nodestore/Task.h +++ b/include/xrpl/nodestore/Task.h @@ -1,6 +1,6 @@ #pragma once -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Derived classes perform scheduled tasks. @@ -17,4 +17,4 @@ struct Task performScheduledTask() = 0; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/Types.h b/include/xrpl/nodestore/Types.h index 872d948a36..21af6fa68b 100644 --- a/include/xrpl/nodestore/Types.h +++ b/include/xrpl/nodestore/Types.h @@ -5,7 +5,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { // This is only used to pre-allocate the array for // batch objects and does not affect the amount written. @@ -36,4 +36,4 @@ enum class Status { */ using Batch = std::vector>; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/BatchWriter.h b/include/xrpl/nodestore/detail/BatchWriter.h index b89df0da14..6ac3428752 100644 --- a/include/xrpl/nodestore/detail/BatchWriter.h +++ b/include/xrpl/nodestore/detail/BatchWriter.h @@ -9,7 +9,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Batch-writing assist logic. @@ -86,4 +86,4 @@ private: Batch writeSet_; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index 6f2fca682f..33a2e27939 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -22,7 +22,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { class DatabaseNodeImp : public Database { @@ -68,7 +68,7 @@ public: XRPL_ASSERT( backend_, - "xrpl::NodeStore::DatabaseNodeImp::DatabaseNodeImp : non-null " + "xrpl::node_store::DatabaseNodeImp::DatabaseNodeImp : non-null " "backend"); } @@ -138,4 +138,4 @@ private: } }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h index ecbe9a513d..9b566195ef 100644 --- a/include/xrpl/nodestore/detail/DatabaseRotatingImp.h +++ b/include/xrpl/nodestore/detail/DatabaseRotatingImp.h @@ -16,7 +16,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { class DatabaseRotatingImp : public DatabaseRotating { @@ -41,7 +41,7 @@ public: void rotate( - std::unique_ptr&& newBackend, + std::unique_ptr&& newBackend, std::function const& f) override; @@ -94,4 +94,4 @@ private: forEach(std::function)> f) override; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/DecodedBlob.h b/include/xrpl/nodestore/detail/DecodedBlob.h index d0cc5e3404..bd90ff2f1b 100644 --- a/include/xrpl/nodestore/detail/DecodedBlob.h +++ b/include/xrpl/nodestore/detail/DecodedBlob.h @@ -4,7 +4,7 @@ #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Parsed key/value blob into NodeObject components. @@ -49,4 +49,4 @@ private: int dataBytes_; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/EncodedBlob.h b/include/xrpl/nodestore/detail/EncodedBlob.h index d668cdccd8..d171df3cc9 100644 --- a/include/xrpl/nodestore/detail/EncodedBlob.h +++ b/include/xrpl/nodestore/detail/EncodedBlob.h @@ -12,7 +12,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { /** * Convert a NodeObject from in-memory to database format. @@ -68,7 +68,7 @@ class EncodedBlob public: explicit EncodedBlob(std::shared_ptr const& obj) : size_([&obj]() { - XRPL_ASSERT(obj, "xrpl::NodeStore::EncodedBlob::EncodedBlob : non-null input"); + XRPL_ASSERT(obj, "xrpl::node_store::EncodedBlob::EncodedBlob : non-null input"); if (!obj) throw std::runtime_error("EncodedBlob: unseated std::shared_ptr used."); @@ -88,7 +88,7 @@ public: XRPL_ASSERT( ((ptr_ == payload_.data()) && (size_ <= payload_.size())) || ((ptr_ != payload_.data()) && (size_ > payload_.size())), - "xrpl::NodeStore::EncodedBlob::~EncodedBlob : valid payload " + "xrpl::node_store::EncodedBlob::~EncodedBlob : valid payload " "pointer"); if (ptr_ != payload_.data()) @@ -114,4 +114,4 @@ public: } }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/ManagerImp.h b/include/xrpl/nodestore/detail/ManagerImp.h index fc84b0aa57..f1653b45dc 100644 --- a/include/xrpl/nodestore/detail/ManagerImp.h +++ b/include/xrpl/nodestore/detail/ManagerImp.h @@ -13,7 +13,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { class ManagerImp : public Manager { @@ -57,4 +57,4 @@ public: beast::Journal journal) override; }; -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/varint.h b/include/xrpl/nodestore/detail/Varint.h similarity index 88% rename from include/xrpl/nodestore/detail/varint.h rename to include/xrpl/nodestore/detail/Varint.h index 5a65545d3a..5474cdc8b4 100644 --- a/include/xrpl/nodestore/detail/varint.h +++ b/include/xrpl/nodestore/detail/Varint.h @@ -6,25 +6,25 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { // This is a variant of the base128 varint format from // google protocol buffers: // 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,9 +118,9 @@ 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); } -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/nodestore/detail/codec.h b/include/xrpl/nodestore/detail/codec.h index 2f69d532be..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 @@ -21,7 +21,7 @@ #include #include -namespace xrpl::NodeStore { +namespace xrpl::node_store { template std::pair @@ -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); @@ -269,7 +269,7 @@ nodeobjectCompress(void const* in, std::size_t inSize, BufferFactory&& bf) case 1: // lz4 { std::uint8_t* p = nullptr; - auto const lzr = NodeStore::lz4Compress(in, inSize, [&p, &vn, &bf](std::size_t n) { + auto const lzr = node_store::lz4Compress(in, inSize, [&p, &vn, &bf](std::size_t n) { p = reinterpret_cast(bf(vn + n)); return p + vn; }); @@ -316,4 +316,4 @@ filterInner(void* in, std::size_t inSize) } } -} // namespace xrpl::NodeStore +} // namespace xrpl::node_store diff --git a/include/xrpl/peerfinder/Config.h b/include/xrpl/peerfinder/Config.h new file mode 100644 index 0000000000..3326ae8a97 --- /dev/null +++ b/include/xrpl/peerfinder/Config.h @@ -0,0 +1,163 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::peer_finder { + +struct PeerLimitConfig +{ + std::optional maxPeers; + std::optional inPeers; + std::optional outPeers; +}; + +/** + * PeerFinder configuration settings. + */ +struct Config +{ + /** + * The largest number of public peer slots to allow. + * This includes both inbound and outbound, but does not include + * fixed peers. + */ + std::size_t maxPeers{tuning::kDefaultMaxPeers}; + + /** + * The number of automatic outbound connections to maintain. + * Outbound connections are only maintained if autoConnect + * is `true`. + */ + std::size_t outPeers = calcOutPeers(); // Note: relies on `maxPeers` being initialized + + /** + * The number of automatic inbound connections to maintain. + * Inbound connections are only maintained if wantIncoming + * is `true`. + */ + std::size_t inPeers{0}; + + /** + * `true` if we want our IP address kept private. + */ + bool peerPrivate = true; + + /** + * `true` if we want to accept incoming connections. + */ + bool wantIncoming{true}; + + /** + * `true` if we want to establish connections automatically + */ + bool autoConnect{true}; + + /** + * The listening port number. + */ + std::uint16_t listeningPort{0}; + + /** + * The set of features we advertise. + */ + std::string features; + + /** + * Limit how many incoming connections we allow per IP + */ + int ipLimit{0}; + + /** + * `true` if we want to verify endpoints in TMEndpoints messages + */ + bool verifyEndpoints = true; + + //-------------------------------------------------------------------------- + + /** + * Returns a suitable value for outPeers according to the rules. + */ + [[nodiscard]] std::size_t + calcOutPeers() const; + + /** + * Adjusts the values so they follow the business rules. + */ + void + applyTuning(); + + /** + * Write the configuration into a property stream + */ + void + onWrite(beast::PropertyStream::Map& map) const; + + /** + * Make peer_finder::Config from peer limit and server mode parameters. + */ + static Config + makeConfig( + bool peerPrivate, + bool standalone, + PeerLimitConfig const& limits, + std::uint16_t port, + bool validationPublicKey, + int ipLimit, + bool verifyEndpoints); + + /** + * Compares two configurations for equality field by field. + */ + friend bool + operator==(Config const& lhs, Config const& rhs) = default; +}; + +//------------------------------------------------------------------------------ + +/** + * Possible results from activating a slot. + */ +enum class Result { InboundDisabled, DuplicatePeer, IpLimitExceeded, Full, Success }; + +/** + * @brief Converts a `Result` enum value to its string representation. + * + * This function provides a human-readable string for a given `Result` enum, + * which is useful for logging, debugging, or displaying status messages. + * + * @param result The `Result` enum value to convert. + * @return A `std::string_view` representing the enum value. Returns "unknown" + * if the enum value is not explicitly handled. + * + * @note This function returns a `std::string_view` for performance. + * A `std::string` would need to allocate memory on the heap and copy the + * string literal into it every time the function is called. + */ +inline std::string_view +to_string(Result result) noexcept +{ + switch (result) + { + case Result::InboundDisabled: + return "inbound disabled"; + case Result::DuplicatePeer: + return "peer already connected"; + case Result::IpLimitExceeded: + return "ip limit exceeded"; + case Result::Full: + return "slots full"; + case Result::Success: + return "success"; + } + + return "unknown"; +} + +} // namespace xrpl::peer_finder diff --git a/include/xrpl/peerfinder/PeerfinderManager.h b/include/xrpl/peerfinder/PeerfinderManager.h new file mode 100644 index 0000000000..bb03d85537 --- /dev/null +++ b/include/xrpl/peerfinder/PeerfinderManager.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace xrpl::peer_finder { + +/** + * Maintains a set of IP addresses used for getting into the network. + */ +class Manager : public beast::PropertyStream::Source +{ +protected: + Manager() noexcept; + +public: + /** + * Destroy the object. + * Any pending source fetch operations are aborted. + * There may be some listener calls made before the + * destructor returns. + */ + ~Manager() override = default; + + /** + * Set the configuration for the manager. + * The new settings will be applied asynchronously. + * Thread safety: + * Can be called from any threads at any time. + */ + virtual void + setConfig(Config const& config) = 0; + + /** + * Transition to the started state, synchronously. + */ + virtual void + start() = 0; + + /** + * Transition to the stopped state, synchronously. + */ + virtual void + stop() = 0; + + /** + * Returns the configuration for the manager. + */ + virtual Config + config() = 0; + + /** + * Add a peer that should always be connected. + * This is useful for maintaining a private cluster of peers. + * The string is the name as specified in the configuration + * file, along with the set of corresponding IP addresses. + */ + virtual void + addFixedPeer(std::string_view name, std::vector const& addresses) = 0; + + /** + * 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. + * @param name A label used for diagnostics. + */ + /* VFALCO NOTE Unimplemented + virtual void addFallbackURL (std::string const& name, + std::string const& url) = 0; + */ + + //-------------------------------------------------------------------------- + + /** + * Create a new inbound slot with the specified remote endpoint. + * If nullptr is returned, then the slot could not be assigned. + * Usually this is because of a detected self-connection. + */ + virtual std::pair, Result> + newInboundSlot( + beast::ip::Endpoint const& localEndpoint, + beast::ip::Endpoint const& remoteEndpoint) = 0; + + /** + * Create a new outbound slot with the specified remote endpoint. + * If nullptr is returned, then the slot could not be assigned. + * Usually this is because of a duplicate connection. + */ + virtual std::pair, Result> + newOutboundSlot(beast::ip::Endpoint const& remoteEndpoint) = 0; + + /** + * Called when mtENDPOINTS is received. + */ + virtual void + onEndpoints(std::shared_ptr const& slot, Endpoints const& endpoints) = 0; + + /** + * Called when the slot is closed. + * This always happens when the socket is closed, unless the socket + * was canceled. + */ + virtual void + onClosed(std::shared_ptr const& slot) = 0; + + /** + * Called when an outbound connection is deemed to have failed + */ + virtual void + onFailure(std::shared_ptr const& slot) = 0; + + /** + * Called when we received redirect IPs from a busy peer. + */ + virtual void + onRedirects( + boost::asio::ip::tcp::endpoint const& remoteAddress, + std::vector const& eps) = 0; + + //-------------------------------------------------------------------------- + + /** + * Called when an outbound connection attempt succeeds. + * The local endpoint must be valid. If the caller receives an error + * when retrieving the local endpoint from the socket, it should + * proceed as if the connection attempt failed by calling on_closed + * instead of on_connected. + * @return `true` if the connection should be kept + */ + virtual bool + onConnected(std::shared_ptr const& slot, beast::ip::Endpoint const& localEndpoint) = 0; + + /** + * Request an active slot type. + */ + virtual Result + activate(std::shared_ptr const& slot, PublicKey const& key, bool reserved) = 0; + + /** + * Returns a set of endpoints suitable for redirection. + */ + virtual std::vector + redirect(std::shared_ptr const& slot) = 0; + + /** + * Return a set of addresses we should connect to. + */ + virtual std::vector + autoconnect() = 0; + + virtual std::vector, std::vector>> + buildEndpointsForPeers() = 0; + + /** + * Perform periodic activity. + * This should be called once per second. + */ + virtual void + oncePerSecond() = 0; +}; + +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/Slot.h b/include/xrpl/peerfinder/Slot.h similarity index 89% rename from src/xrpld/peerfinder/Slot.h rename to include/xrpl/peerfinder/Slot.h index 9db39ac94c..58e094afc4 100644 --- a/src/xrpld/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 new file mode 100644 index 0000000000..1327f2564f --- /dev/null +++ b/include/xrpl/peerfinder/Types.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::peer_finder { + +using clock_type = beast::AbstractClock; + +/** + * Represents a set of addresses. + */ +using IPAddresses = std::vector; + +//------------------------------------------------------------------------------ + +/** + * Describes a connectable peer address along with some metadata. + */ +struct Endpoint +{ + Endpoint() = default; + + Endpoint(beast::ip::Endpoint ep, std::uint32_t hops); + + std::uint32_t hops = 0; + beast::ip::Endpoint address; +}; + +inline bool +operator<(Endpoint const& lhs, Endpoint const& rhs) +{ + return lhs.address < rhs.address; +} + +/** + * A set of Endpoint used for connecting. + */ +using Endpoints = std::vector; + +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/Bootcache.h b/include/xrpl/peerfinder/detail/Bootcache.h similarity index 85% rename from src/xrpld/peerfinder/detail/Bootcache.h rename to include/xrpl/peerfinder/detail/Bootcache.h index c84fed42c7..453d9c22d2 100644 --- a/src/xrpld/peerfinder/detail/Bootcache.h +++ b/include/xrpl/peerfinder/detail/Bootcache.h @@ -1,11 +1,11 @@ #pragma once -#include -#include - #include #include #include +#include +#include +#include #include #include @@ -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/src/xrpld/peerfinder/detail/Checker.h b/include/xrpl/peerfinder/detail/Checker.h similarity index 96% rename from src/xrpld/peerfinder/detail/Checker.h rename to include/xrpl/peerfinder/detail/Checker.h index 28ec83adb1..e1ac1d44e0 100644 --- a/src/xrpld/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/src/xrpld/peerfinder/detail/Counts.h b/include/xrpl/peerfinder/detail/Counts.h similarity index 93% rename from src/xrpld/peerfinder/detail/Counts.h rename to include/xrpl/peerfinder/detail/Counts.h index c90598c1a1..035103463e 100644 --- a/src/xrpld/peerfinder/detail/Counts.h +++ b/include/xrpl/peerfinder/detail/Counts.h @@ -1,17 +1,16 @@ #pragma once -#include -#include -#include - #include #include +#include +#include +#include #include #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * Direction of a slot count adjustment. @@ -51,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; @@ -68,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_; } /** @@ -296,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; @@ -304,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; @@ -332,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 }; @@ -392,4 +391,4 @@ private: int closingCount_{0}; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/Fixed.h b/include/xrpl/peerfinder/detail/Fixed.h similarity index 75% rename from src/xrpld/peerfinder/detail/Fixed.h rename to include/xrpl/peerfinder/detail/Fixed.h index 24d54775ef..5a52fd7c3e 100644 --- a/src/xrpld/peerfinder/detail/Fixed.h +++ b/include/xrpl/peerfinder/detail/Fixed.h @@ -1,13 +1,13 @@ #pragma once -#include -#include +#include +#include #include #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/src/xrpld/peerfinder/detail/Handouts.h b/include/xrpl/peerfinder/detail/Handouts.h similarity index 88% rename from src/xrpld/peerfinder/detail/Handouts.h rename to include/xrpl/peerfinder/detail/Handouts.h index 757f1a8e1b..c20d4b2139 100644 --- a/src/xrpld/peerfinder/detail/Handouts.h +++ b/include/xrpl/peerfinder/detail/Handouts.h @@ -1,19 +1,18 @@ #pragma once -#include -#include -#include - #include #include #include +#include +#include +#include #include #include #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { namespace detail { @@ -29,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; @@ -96,7 +95,7 @@ public: [[nodiscard]] bool full() const { - return list_.size() >= Tuning::kRedirectEndpointCount; + return list_.size() >= tuning::kRedirectEndpointCount; } [[nodiscard]] SlotImp::ptr const& @@ -125,7 +124,7 @@ private: template RedirectHandouts::RedirectHandouts(SlotImp::ptr slot) : slot_(std::move(slot)) { - list_.reserve(Tuning::kRedirectEndpointCount); + list_.reserve(tuning::kRedirectEndpointCount); } template @@ -139,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 @@ -182,7 +181,7 @@ public: [[nodiscard]] bool full() const { - return list_.size() >= Tuning::kNumberOfEndpoints; + return list_.size() >= tuning::kNumberOfEndpoints; } void @@ -211,7 +210,7 @@ private: template SlotHandouts::SlotHandouts(SlotImp::ptr slot) : slot_(std::move(slot)) { - list_.reserve(Tuning::kNumberOfEndpoints); + list_.reserve(tuning::kNumberOfEndpoints); } template @@ -221,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)) @@ -260,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_; @@ -275,7 +274,7 @@ public: template bool - tryInsert(beast::IP::Endpoint const& endpoint); + tryInsert(beast::ip::Endpoint const& endpoint); [[nodiscard]] bool empty() const @@ -317,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(); })) @@ -342,4 +341,4 @@ ConnectHandouts::tryInsert(beast::IP::Endpoint const& endpoint) return true; } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/Livecache.h b/include/xrpl/peerfinder/detail/Livecache.h similarity index 90% rename from src/xrpld/peerfinder/detail/Livecache.h rename to include/xrpl/peerfinder/detail/Livecache.h index 2015098847..ec797065e5 100644 --- a/src/xrpld/peerfinder/detail/Livecache.h +++ b/include/xrpl/peerfinder/detail/Livecache.h @@ -1,9 +1,5 @@ #pragma once -#include -#include -#include - #include #include #include @@ -12,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -22,6 +20,8 @@ #include #include #include +#include +#include #include #include #include @@ -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;) { @@ -411,7 +411,7 @@ Livecache::expire() } if (n > 0) { - JLOG(journal_.debug()) << beast::Leftw(18) << "Livecache expired " << n + JLOG(journal_.debug()) << std::left << std::setw(18) << "Livecache expired " << n << ((n > 1) ? " entries" : " entry"); } } @@ -427,14 +427,14 @@ 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) { hops.insert(e); - JLOG(journal_.debug()) << beast::Leftw(18) << "Livecache insert " << ep.address + JLOG(journal_.debug()) << std::left << std::setw(18) << "Livecache insert " << ep.address << " at hops " << ep.hops; return; } @@ -442,7 +442,7 @@ Livecache::insert(Endpoint const& ep) { // Drop duplicates at higher hops std::size_t const excess(ep.hops - e.endpoint.hops); - JLOG(journal_.trace()) << beast::Leftw(18) << "Livecache drop " << ep.address + JLOG(journal_.trace()) << std::left << std::setw(18) << "Livecache drop " << ep.address << " at hops +" << excess; return; } @@ -453,12 +453,12 @@ Livecache::insert(Endpoint const& ep) if (ep.hops < e.endpoint.hops) { hops.reinsert(e, ep.hops); - JLOG(journal_.debug()) << beast::Leftw(18) << "Livecache update " << ep.address + JLOG(journal_.debug()) << std::left << std::setw(18) << "Livecache update " << ep.address << " at hops " << ep.hops; } else { - JLOG(journal_.trace()) << beast::Leftw(18) << "Livecache refresh " << ep.address + JLOG(journal_.trace()) << std::left << std::setw(18) << "Livecache refresh " << ep.address << " at hops " << ep.hops; } } @@ -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/src/xrpld/peerfinder/detail/Logic.h b/include/xrpl/peerfinder/detail/Logic.h similarity index 84% rename from src/xrpld/peerfinder/detail/Logic.h rename to include/xrpl/peerfinder/detail/Logic.h index a7dbbf850d..4821054280 100644 --- a/src/xrpld/peerfinder/detail/Logic.h +++ b/include/xrpl/peerfinder/detail/Logic.h @@ -1,17 +1,5 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - #include #include #include @@ -22,24 +10,40 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#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::PeerFinder { +namespace xrpl::peer_finder { /** * The Logic for maintaining the list of Slot addresses. @@ -53,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; @@ -77,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 @@ -92,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; @@ -166,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); @@ -197,8 +201,8 @@ public: if (result.second) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Logic add fixed '" << name << "' at " << remoteAddress; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic add fixed '" << name + << "' at " << remoteAddress; return; } } @@ -209,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) @@ -221,7 +225,7 @@ public: if (iter == slots.end()) { // The slot disconnected before we finished the check - JLOG(journal.debug()) << beast::Leftw(18) << "Logic tested " << checkedAddress + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic tested " << checkedAddress << " but the connection was closed"; return; } @@ -252,10 +256,10 @@ 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()) << beast::Leftw(18) << "Logic accept" << remoteEndpoint + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic accept" << remoteEndpoint << " on local " << localEndpoint; std::scoped_lock const _(lock); @@ -266,7 +270,7 @@ public: auto const count = connectedAddresses.count(remoteEndpoint.address()); if (count + 1 > config_.ipLimit) { - JLOG(journal.debug()) << beast::Leftw(18) << "Logic dropping inbound " + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic dropping inbound " << remoteEndpoint << " because of ip limits."; return {SlotImp::ptr(), Result::IpLimitExceeded}; } @@ -275,8 +279,8 @@ public: // Check for duplicate connection if (slots.contains(remoteEndpoint)) { - JLOG(journal.debug()) << beast::Leftw(18) << "Logic dropping " << remoteEndpoint - << " as duplicate incoming"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic dropping " + << remoteEndpoint << " as duplicate incoming"; return {SlotImp::ptr(), Result::DuplicatePeer}; } @@ -289,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()); @@ -302,17 +306,17 @@ 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()) << beast::Leftw(18) << "Logic connect " << remoteEndpoint; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic connect " << remoteEndpoint; std::scoped_lock const _(lock); // Check for duplicate connection if (slots.contains(remoteEndpoint)) { - JLOG(journal.debug()) << beast::Leftw(18) << "Logic dropping " << remoteEndpoint - << " as duplicate connect"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic dropping " + << remoteEndpoint << " as duplicate connect"; return {SlotImp::ptr(), Result::DuplicatePeer}; } @@ -325,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 @@ -338,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}; @@ -350,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); @@ -361,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; @@ -389,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)) @@ -421,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 @@ -439,7 +443,7 @@ public: if (iter == fixed_.end()) { logicError( - "PeerFinder::Logic::activate(): remote_endpoint " + "peer_finder::Logic::activate(): remote_endpoint " "missing from fixed_"); } @@ -472,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); @@ -506,15 +510,15 @@ public: if (!h.list().empty()) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Logic connect " << h.list().size() << " fixed"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic connect " + << h.list().size() << " fixed"; return h.list(); } if (counts_.attempts() > 0) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Logic waiting on " << counts_.attempts() << " attempts"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic waiting on " + << counts_.attempts() << " attempts"; return none; } } @@ -535,14 +539,14 @@ public: if (!h.list().empty()) { JLOG(journal.debug()) - << beast::Leftw(18) << "Logic connect " << h.list().size() << " live " + << std::left << std::setw(18) << "Logic connect " << h.list().size() << " live " << ((h.list().size() > 1) ? "endpoints" : "endpoint"); return h.list(); } if (counts_.attempts() > 0) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Logic waiting on " << counts_.attempts() << " attempts"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic waiting on " + << counts_.attempts() << " attempts"; return none; } } @@ -568,8 +572,9 @@ public: if (!h.list().empty()) { - JLOG(journal.debug()) << beast::Leftw(18) << "Logic connect " << h.list().size() - << " boot " << ((h.list().size() > 1) ? "addresses" : "address"); + JLOG(journal.debug()) << std::left << std::setw(18) << "Logic connect " + << h.list().size() << " boot " + << ((h.list().size() > 1) ? "addresses" : "address"); return h.list(); } @@ -630,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); } @@ -651,7 +656,7 @@ public: result.emplace_back(slot, list); } - whenBroadcast = now + Tuning::kSecondsPerMessage; + whenBroadcast = now + tuning::kSecondsPerMessage; } return result; @@ -670,7 +675,7 @@ public: entry.second->expire(); // Expire the recent attempts table - beast::expire(squelches, Tuning::kRecentAttemptDuration); + beast::expire(squelches, tuning::kRecentAttemptDuration); bootcache.periodicActivity(); } @@ -687,10 +692,10 @@ public: Endpoint& ep(*iter); // Enforce hop limit - if (ep.hops > Tuning::kMaxHops) + if (ep.hops > tuning::kMaxHops) { - JLOG(journal.debug()) << beast::Leftw(18) << "Endpoints drop " << ep.address - << " for excess hops " << ep.hops; + JLOG(journal.debug()) << std::left << std::setw(18) << "Endpoints drop " + << ep.address << " for excess hops " << ep.hops; iter = list.erase(iter); continue; } @@ -706,18 +711,18 @@ public: } else { - JLOG(journal.debug()) - << beast::Leftw(18) << "Endpoints drop " << ep.address << " for extra self"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Endpoints drop " + << ep.address << " for extra self"; iter = list.erase(iter); continue; } } // Discard invalid addresses - if (config_.verifyEndpoints && !isValidAddress(ep.address)) + if (!isValidAddress(ep.address)) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Endpoints drop " << ep.address << " as invalid"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Endpoints drop " + << ep.address << " as invalid"; iter = list.erase(iter); continue; } @@ -727,8 +732,8 @@ public: return ep.address == other.address; })) { - JLOG(journal.debug()) - << beast::Leftw(18) << "Endpoints drop " << ep.address << " as duplicate"; + JLOG(journal.debug()) << std::left << std::setw(18) << "Endpoints drop " + << ep.address << " as duplicate"; iter = list.erase(iter); continue; } @@ -749,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() @@ -763,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()); @@ -780,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); @@ -832,7 +837,7 @@ public: bootcache.insert(ep.address); } - slot->whenAcceptEndpoints = now + Tuning::kSecondsPerMessage; + slot->whenAcceptEndpoints = now + tuning::kSecondsPerMessage; } //-------------------------------------------------------------------------- @@ -846,7 +851,7 @@ public: if (iter == slots.end()) { logicError( - "PeerFinder::Logic::remove(): remote_endpoint " + "peer_finder::Logic::remove(): remote_endpoint " "missing from slots_"); } @@ -861,7 +866,7 @@ public: if (iter == keys.end()) { logicError( - "PeerFinder::Logic::remove(): public_key missing " + "peer_finder::Logic::remove(): public_key missing " "from keys_"); } @@ -874,7 +879,7 @@ public: if (iter == connectedAddresses.end()) { logicError( - "PeerFinder::Logic::remove(): remote_endpoint " + "peer_finder::Logic::remove(): remote_endpoint " "address missing from connectedAddresses_"); } @@ -902,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_"); } @@ -938,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 @@ -963,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; }); @@ -1074,13 +1079,13 @@ public: if (!results.error) { int const count(addBootcacheAddresses(results.addresses)); - JLOG(journal.info()) << beast::Leftw(18) << "Logic added " << count << " new " + JLOG(journal.info()) << std::left << std::setw(18) << "Logic added " << count << " new " << ((count == 1) ? "address" : "addresses") << " from " << source->name(); } else { - JLOG(journal.error()) << beast::Leftw(18) << "Logic failed " + JLOG(journal.error()) << std::left << std::setw(18) << "Logic failed " << "'" << source->name() << "' fetch, " << results.error.message(); } @@ -1092,14 +1097,12 @@ 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; - if (isLoopback(address)) - return false; if (!isPublic(address)) return false; if (address.port() == 0) @@ -1217,13 +1220,13 @@ 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) { - JLOG(journal.trace()) << beast::Leftw(18) << "Logic add " << n << " redirect IPs from " - << remoteAddress; + JLOG(journal.trace()) << std::left << std::setw(18) << "Logic add " << n + << " redirect IPs from " << remoteAddress; } } -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/SlotImp.h b/include/xrpl/peerfinder/detail/SlotImp.h similarity index 81% rename from src/xrpld/peerfinder/detail/SlotImp.h rename to include/xrpl/peerfinder/detail/SlotImp.h index 898941b157..db86183f64 100644 --- a/src/xrpld/peerfinder/detail/SlotImp.h +++ b/include/xrpl/peerfinder/detail/SlotImp.h @@ -1,10 +1,9 @@ #pragma once -#include -#include - #include #include +#include +#include #include #include @@ -13,7 +12,7 @@ #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { class SlotImp : public Slot { @@ -22,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 @@ -54,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_; @@ -94,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; } @@ -141,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 @@ -168,11 +167,11 @@ 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 constexpr std::int32_t kUnknownPort = -1; + static std::int32_t constexpr kUnknownPort = -1; std::atomic listeningPort_; public: @@ -197,4 +196,4 @@ public: clock_type::time_point whenAcceptEndpoints; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/Source.h b/include/xrpl/peerfinder/detail/Source.h similarity index 90% rename from src/xrpld/peerfinder/detail/Source.h rename to include/xrpl/peerfinder/detail/Source.h index b205dc8dfb..09aa4e216a 100644 --- a/src/xrpld/peerfinder/detail/Source.h +++ b/include/xrpl/peerfinder/detail/Source.h @@ -1,14 +1,13 @@ #pragma once -#include - #include +#include #include #include -namespace xrpl::PeerFinder { +namespace xrpl::peer_finder { /** * A static or dynamic source of peer addresses. @@ -47,4 +46,4 @@ public: fetch(Results& results, beast::Journal journal) = 0; }; -} // namespace xrpl::PeerFinder +} // namespace xrpl::peer_finder diff --git a/src/xrpld/peerfinder/detail/SourceStrings.h b/include/xrpl/peerfinder/detail/SourceStrings.h similarity index 77% rename from src/xrpld/peerfinder/detail/SourceStrings.h rename to include/xrpl/peerfinder/detail/SourceStrings.h index b79cf0df03..e9783c775f 100644 --- a/src/xrpld/peerfinder/detail/SourceStrings.h +++ b/include/xrpl/peerfinder/detail/SourceStrings.h @@ -1,12 +1,12 @@ #pragma once -#include +#include #include #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/src/xrpld/peerfinder/detail/Store.h b/include/xrpl/peerfinder/detail/Store.h similarity index 75% rename from src/xrpld/peerfinder/detail/Store.h rename to include/xrpl/peerfinder/detail/Store.h index 9393ef6c2b..1f9352c6ec 100644 --- a/src/xrpld/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/src/xrpld/peerfinder/detail/Tuning.h b/include/xrpl/peerfinder/detail/Tuning.h similarity index 97% rename from src/xrpld/peerfinder/detail/Tuning.h rename to include/xrpl/peerfinder/detail/Tuning.h index ea4637dd9d..b4ccfae167 100644 --- a/src/xrpld/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 new file mode 100644 index 0000000000..e514734d8b --- /dev/null +++ b/include/xrpl/peerfinder/make_Manager.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include + +namespace xrpl::peer_finder { + +/** + * @brief Create a new Manager. + * + * @param ioContext The io_context used to schedule asynchronous work. + * @param clock The clock used for timekeeping. + * @param journal The journal used for logging. + * @param store The persistence backend for the bootstrap cache. The caller + * retains ownership and must keep it alive (and opened) for the lifetime of + * the returned Manager. This lets consumers supply their own Store + * implementation (e.g. the SQLite-backed StoreSqdb in xrpld). + * @param collector The collector used to report metrics. + * @return The newly created Manager. + */ +std::unique_ptr +makeManager( + boost::asio::io_context& ioContext, + clock_type& clock, + beast::Journal journal, + Store& store, + beast::insight::Collector::ptr const& collector); + +} // 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/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/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/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/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 index bef05b9a8f..df4fedb707 100644 --- a/include/xrpl/protocol/NFTSyntheticSerializer.h +++ b/include/xrpl/protocol/NFTSyntheticSerializer.h @@ -6,7 +6,7 @@ #include -namespace xrpl::RPC { +namespace xrpl::rpc { /** * Adds common synthetic fields to transaction-related JSON responses @@ -16,4 +16,4 @@ void insertNFTSyntheticInJson(json::Value&, std::shared_ptr const&, TxMeta const&); /** @} */ -} // namespace xrpl::RPC +} // namespace xrpl::rpc diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 703a0939c9..2a3f561a10 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,6 @@ enum GranularPermissionType : std::uint32_t { #pragma pop_macro("GRANULAR_PERMISSION") }; -// Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor -// tricks in tests and macro-generated code; enum class would break that. -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Delegation { Delegable, NotDelegable }; - class Permission { private: @@ -65,7 +61,7 @@ private: struct TxDelegationEntry { uint256 amendment; - Delegation delegable{NotDelegable}; + Delegation delegable{Delegation::NotDelegable}; }; std::unordered_set granularTxTypes_; diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e83e1c97b6..345baef853 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -139,7 +140,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 +237,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 +317,47 @@ 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, + * accrual-basis accounting). + */ +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, +}; + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.count(); +// This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). +constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/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/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..5768721111 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -108,11 +109,11 @@ public: [[nodiscard]] bool isType(Type const& pe) const; - bool - operator==(STPathElement const& t) const; + [[nodiscard]] size_t + getHash() const; bool - operator!=(STPathElement const& t) const; + operator==(STPathElement const& t) const; private: static std::size_t @@ -171,12 +172,23 @@ public: reserve(size_t s); }; +template +void +hash_append(Hasher& h, STPath const& p) noexcept +{ + for (auto const& e : p) + { + beast::hash_append(h, e.getHash()); + } +} + //------------------------------------------------------------------------------ // A set of zero or more payment paths class STPathSet final : public STBase, public CountedObject { std::vector value_; + xrpl::hardened_hash_set seenHashes_; public: STPathSet() = default; @@ -205,9 +217,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; @@ -227,6 +236,9 @@ public: void emplaceBack(Args&&... args); + [[nodiscard]] bool + contains(STPath const& path) const; + private: STBase* copy(std::size_t n, void* buf) const override; @@ -417,12 +429,6 @@ STPathElement::operator==(STPathElement const& t) const accountID_ == t.accountID_ && assetID_ == t.assetID_ && issuerID_ == t.issuerID_; } -inline bool -STPathElement::operator!=(STPathElement const& t) const -{ - return !operator==(t); -} - // ------------ STPath ------------ inline STPath::STPath(std::vector p) : path_(std::move(p)) @@ -515,12 +521,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 { @@ -549,6 +549,7 @@ inline void STPathSet::pushBack(STPath const& e) { value_.push_back(e); + seenHashes_.emplace(value_.back()); } template @@ -556,6 +557,13 @@ inline void STPathSet::emplaceBack(Args&&... args) { value_.emplace_back(std::forward(args)...); + seenHashes_.emplace(value_.back()); +} + +inline bool +STPathSet::contains(STPath const& path) const +{ + return seenHashes_.contains(path); } } // namespace xrpl diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index d329d42eee..e213d4e0b7 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -93,12 +93,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; diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 444fdfa600..8101b27341 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. @@ -163,8 +179,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 +191,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..c1ea5c16ba 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -265,20 +265,10 @@ public: return v == data_; } bool - operator!=(Blob const& v) const - { - return v != data_; - } - bool operator==(Serializer const& v) const { return v.data_ == data_; } - bool - operator!=(Serializer const& v) const - { - return v.data_ != data_; - } static int decodeLengthLength(int b1); diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 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..56f868b665 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -152,10 +152,4 @@ operator==(STVar const& lhs, STVar const& rhs) return lhs.get().isEquivalent(rhs.get()); } -inline bool -operator!=(STVar const& lhs, STVar const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::detail diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index bfe03a6303..de02fed7d8 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +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) @@ -58,7 +59,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) @@ -99,6 +99,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..f166473d7f 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -404,7 +404,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeDefault}, + {sfImmutableFlags, SoeDefault}, {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, @@ -505,6 +505,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..ec05804253 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,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -136,9 +136,7 @@ TYPED_SFIELD(sfNFTokenOfferNode, UINT64, 12) TYPED_SFIELD(sfEmitBurden, UINT64, 13) // 64-bit integers (uncommon) -TYPED_SFIELD(sfHookOn, UINT64, 16) -TYPED_SFIELD(sfHookInstructionCount, UINT64, 17) -TYPED_SFIELD(sfHookReturnCode, UINT64, 18) +// 16 to 18 unused TYPED_SFIELD(sfReferenceCount, UINT64, 19) TYPED_SFIELD(sfXChainClaimID, UINT64, 20) TYPED_SFIELD(sfXChainAccountCreateCount, UINT64, 21) @@ -202,10 +200,7 @@ TYPED_SFIELD(sfPreviousPageMin, UINT256, 26) TYPED_SFIELD(sfNextPageMin, UINT256, 27) TYPED_SFIELD(sfNFTokenBuyOffer, UINT256, 28) TYPED_SFIELD(sfNFTokenSellOffer, UINT256, 29) -TYPED_SFIELD(sfHookStateKey, UINT256, 30) -TYPED_SFIELD(sfHookHash, UINT256, 31) -TYPED_SFIELD(sfHookNamespace, UINT256, 32) -TYPED_SFIELD(sfHookSetTxnID, UINT256, 33) +// 30 to 33 unused TYPED_SFIELD(sfDomainID, UINT256, 34) TYPED_SFIELD(sfVaultID, UINT256, 35, SField::kSmdPseudoAccount | SField::kSmdDefault) @@ -236,8 +231,9 @@ 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) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -259,15 +255,13 @@ TYPED_SFIELD(sfMinimumOffer, AMOUNT, 16) TYPED_SFIELD(sfRippleEscrow, AMOUNT, 17) TYPED_SFIELD(sfDeliveredAmount, AMOUNT, 18) TYPED_SFIELD(sfNFTokenBrokerFee, AMOUNT, 19) - -// Reserve 20 & 21 for Hooks. - +// 20 to 21 unused // currency amount (fees) TYPED_SFIELD(sfBaseFeeDrops, AMOUNT, 22) TYPED_SFIELD(sfReserveBaseDrops, AMOUNT, 23) TYPED_SFIELD(sfReserveIncrementDrops, AMOUNT, 24) -// currency amount (AMM) +// currency amount (more) TYPED_SFIELD(sfLPTokenOut, AMOUNT, 25) TYPED_SFIELD(sfLPTokenIn, AMOUNT, 26) TYPED_SFIELD(sfEPrice, AMOUNT, 27) @@ -277,6 +271,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 +296,7 @@ TYPED_SFIELD(sfMasterSignature, VL, 18, SField::kSmdDefault, SFi TYPED_SFIELD(sfUNLModifyValidator, VL, 19) TYPED_SFIELD(sfValidatorToDisable, VL, 20) TYPED_SFIELD(sfValidatorToReEnable, VL, 21) -TYPED_SFIELD(sfHookStateData, VL, 22) -TYPED_SFIELD(sfHookReturnString, VL, 23) -TYPED_SFIELD(sfHookParameterName, VL, 24) -TYPED_SFIELD(sfHookParameterValue, VL, 25) +// 22 to 25 unused TYPED_SFIELD(sfDIDDocument, VL, 26) TYPED_SFIELD(sfData, VL, 27) TYPED_SFIELD(sfAssetClass, VL, 28) @@ -342,7 +334,7 @@ TYPED_SFIELD(sfHolder, ACCOUNT, 11) TYPED_SFIELD(sfDelegate, ACCOUNT, 12) // account (uncommon) -TYPED_SFIELD(sfHookAccount, ACCOUNT, 16) +// 16 unused TYPED_SFIELD(sfOtherChainSource, ACCOUNT, 18) TYPED_SFIELD(sfOtherChainDestination, ACCOUNT, 19) TYPED_SFIELD(sfAttestationSignerAccount, ACCOUNT, 20) @@ -395,7 +387,7 @@ UNTYPED_SFIELD(sfMemo, OBJECT, 10) UNTYPED_SFIELD(sfSignerEntry, OBJECT, 11) UNTYPED_SFIELD(sfNFToken, OBJECT, 12) UNTYPED_SFIELD(sfEmitDetails, OBJECT, 13) -UNTYPED_SFIELD(sfHook, OBJECT, 14) +// 14 unused UNTYPED_SFIELD(sfPermission, OBJECT, 15) // inner object (uncommon) @@ -403,11 +395,7 @@ UNTYPED_SFIELD(sfSigner, OBJECT, 16) // 17 unused UNTYPED_SFIELD(sfMajority, OBJECT, 18) UNTYPED_SFIELD(sfDisabledValidator, OBJECT, 19) -UNTYPED_SFIELD(sfEmittedTxn, OBJECT, 20) -UNTYPED_SFIELD(sfHookExecution, OBJECT, 21) -UNTYPED_SFIELD(sfHookDefinition, OBJECT, 22) -UNTYPED_SFIELD(sfHookParameter, OBJECT, 23) -UNTYPED_SFIELD(sfHookGrant, OBJECT, 24) +// 20 to 24 unused UNTYPED_SFIELD(sfVoteEntry, OBJECT, 25) UNTYPED_SFIELD(sfAuctionSlot, OBJECT, 26) UNTYPED_SFIELD(sfAuthAccount, OBJECT, 27) @@ -435,16 +423,14 @@ UNTYPED_SFIELD(sfSufficient, ARRAY, 7) UNTYPED_SFIELD(sfAffectedNodes, ARRAY, 8) UNTYPED_SFIELD(sfMemos, ARRAY, 9) UNTYPED_SFIELD(sfNFTokens, ARRAY, 10) -UNTYPED_SFIELD(sfHooks, ARRAY, 11) +// 11 unused UNTYPED_SFIELD(sfVoteSlots, ARRAY, 12) UNTYPED_SFIELD(sfAdditionalBooks, ARRAY, 13) // array of objects (uncommon) UNTYPED_SFIELD(sfMajorities, ARRAY, 16) UNTYPED_SFIELD(sfDisabledValidators, ARRAY, 17) -UNTYPED_SFIELD(sfHookExecutions, ARRAY, 18) -UNTYPED_SFIELD(sfHookParameters, ARRAY, 19) -UNTYPED_SFIELD(sfHookGrants, ARRAY, 20) +// 18 to 20 unused UNTYPED_SFIELD(sfXChainClaimAttestations, ARRAY, 21) UNTYPED_SFIELD(sfXChainCreateAccountAttestations, ARRAY, 22) // 23 unused diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c00..dbf9b66ac7 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,11 +64,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, @@ -61,11 +77,7 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -79,9 +91,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +109,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +119,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +131,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +144,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +154,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +167,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +177,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +190,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +200,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +213,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +226,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +237,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +245,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +256,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +267,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +283,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +299,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +309,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +321,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +329,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +339,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +349,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +367,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +383,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +399,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +418,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +430,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +444,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +459,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +468,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +478,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +489,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +499,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +520,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +544,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +553,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +565,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +577,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +585,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +600,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +610,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,16 +622,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 +641,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,16 +655,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 +672,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +687,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +700,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +711,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +723,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +735,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +746,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +756,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +769,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -862,6 +782,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 +792,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -884,9 +808,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -897,9 +823,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -910,14 +837,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 +855,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -939,9 +870,9 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, + ({ + .amendment = featureBatchV1_1, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -954,9 +885,11 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, {sfData, SoeOptional}, @@ -971,9 +904,11 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, })) @@ -982,9 +917,10 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -994,13 +930,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 +948,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, })) @@ -1021,9 +961,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, {sfCounterparty, SoeOptional}, @@ -1048,9 +990,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1059,12 +1002,14 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1073,9 +1018,11 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -1085,9 +1032,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 +1051,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1116,9 +1061,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1134,9 +1077,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1155,9 +1096,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1170,9 +1109,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1183,15 +1122,13 @@ 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}, })) /** This system-generated transaction type is used to update the status of the various amendments. @@ -1202,9 +1139,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1214,9 +1149,7 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1235,9 +1168,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/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/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8518a0fe14..6a2caf52ae 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); } /** @@ -557,13 +557,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; } 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/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index b994e4ec07..3d28a12cee 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h @@ -21,7 +21,7 @@ class EscrowCreateBuilder; * Type: ttESCROW_CREATE (1) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 2476def5c2..1cbc60c738 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h @@ -21,7 +21,7 @@ class EscrowFinishBuilder; * Type: ttESCROW_FINISH (2) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowFinishBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index af86dea0b0..4c02989f09 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -21,7 +21,7 @@ class LedgerStateFixBuilder; * Type: ttLEDGER_STATE_FIX (53) * Delegable: Delegation::Delegable * Amendment: fixNFTokenPageLinks - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LedgerStateFixBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h index 875e0a4c5e..468ce054c2 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h @@ -21,7 +21,7 @@ class LoanBrokerCoverClawbackBuilder; * Type: ttLOAN_BROKER_COVER_CLAWBACK (78) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h index 38cc113844..0fe1bd7b91 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h @@ -21,7 +21,7 @@ class LoanBrokerCoverDepositBuilder; * Type: ttLOAN_BROKER_COVER_DEPOSIT (76) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 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..9513723e94 100644 --- a/include/xrpl/protocol_autogen/transactions/SetFee.h +++ b/include/xrpl/protocol_autogen/transactions/SetFee.h @@ -21,7 +21,7 @@ class SetFeeBuilder; * Type: ttFEE (101) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetFeeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h index a943bb0279..042676251b 100644 --- a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h +++ b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h @@ -21,7 +21,7 @@ class SetRegularKeyBuilder; * Type: ttREGULAR_KEY_SET (5) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetRegularKeyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SignerListSet.h b/include/xrpl/protocol_autogen/transactions/SignerListSet.h index 6e9d0e41ba..253bcccc1a 100644 --- a/include/xrpl/protocol_autogen/transactions/SignerListSet.h +++ b/include/xrpl/protocol_autogen/transactions/SignerListSet.h @@ -21,7 +21,7 @@ class SignerListSetBuilder; * Type: ttSIGNER_LIST_SET (12) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SignerListSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index 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/Family.h b/include/xrpl/shamap/Family.h index 7624b3e600..467d41e4cc 100644 --- a/include/xrpl/shamap/Family.h +++ b/include/xrpl/shamap/Family.h @@ -26,10 +26,10 @@ public: explicit Family() = default; virtual ~Family() = default; - virtual NodeStore::Database& + virtual node_store::Database& db() = 0; - [[nodiscard]] virtual NodeStore::Database const& + [[nodiscard]] virtual node_store::Database const& db() const = 0; virtual beast::Journal const& diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index a1194ccfd3..05de33ddf3 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -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 @@ -435,31 +484,36 @@ private: // returns the first item at or below this node SHAMapLeafNode* - firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = 0) const; + firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch = 0u) const; // returns the last item at or below this node SHAMapLeafNode* - lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = kBranchFactor) const; + lastBelow( + SHAMapTreeNodePtr node, + SharedPtrNodeStack& stack, + unsigned int branch = kBranchFactor) const; + + // direction in which belowHelper scans an inner node's branches + enum class BelowDirection { First, Last }; // helper function for firstBelow and lastBelow SHAMapLeafNode* belowHelper( SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, - int branch, - std::tuple, std::function> const& loopParams) - const; + unsigned int branch, + BelowDirection direction) const; // Simple descent // Get a child of the specified node SHAMapTreeNode* - descend(SHAMapInnerNode*, int branch) const; + descend(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNode* - descendThrow(SHAMapInnerNode*, int branch) const; + descendThrow(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNodePtr - descend(SHAMapInnerNode&, int branch) const; + descend(SHAMapInnerNode&, unsigned int branch) const; SHAMapTreeNodePtr - descendThrow(SHAMapInnerNode&, int branch) const; + descendThrow(SHAMapInnerNode&, unsigned int branch) const; // Descend with filter // If pending, callback is called as if it called fetchNodeNT @@ -467,7 +521,7 @@ private: SHAMapTreeNode* descendAsync( SHAMapInnerNode* parent, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter, bool& pending, descendCallback&&) const; @@ -476,13 +530,13 @@ private: descend( SHAMapInnerNode* parent, SHAMapNodeID const& parentID, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter) const; // Non-storing // Does not hook the returned node to its parent SHAMapTreeNodePtr - descendNoStore(SHAMapInnerNode&, int branch) const; + descendNoStore(SHAMapInnerNode&, unsigned int branch) const; /** * If there is only one leaf below this node, get its contents @@ -532,8 +586,8 @@ private: using StackEntry = std::tuple< SHAMapInnerNode*, // pointer to the node SHAMapNodeID, // the node's ID - int, // while child we check first - int, // which child we check next + unsigned int, // which child we check first + unsigned int, // which child we check next bool>; // whether we've found any missing children yet // We explicitly choose to specify the use of std::deque here, because @@ -547,7 +601,7 @@ private: using DeferredNode = std::tuple< SHAMapInnerNode*, // parent node SHAMapNodeID, // parent node ID - int, // branch + unsigned int, // branch SHAMapTreeNodePtr>; // node int deferred; @@ -740,12 +794,6 @@ operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) return x.item_ == y.item_; } -inline bool -operator!=(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) -{ - return !(x == y); -} - inline SHAMap::ConstIterator SHAMap::begin() const { diff --git a/include/xrpl/shamap/SHAMapInnerNode.h b/include/xrpl/shamap/SHAMapInnerNode.h index 44d3bd6279..83d039172f 100644 --- a/include/xrpl/shamap/SHAMapInnerNode.h +++ b/include/xrpl/shamap/SHAMapInnerNode.h @@ -62,8 +62,8 @@ private: * * @param i index of the requested child */ - std::optional - getChildIndex(int i) const; + std::optional + getChildIndex(unsigned int i) const; /** * Call the `f` callback for all 16 (branchFactor) branches - even if @@ -125,28 +125,28 @@ public: isEmpty() const; bool - isEmptyBranch(int m) const; + isEmptyBranch(unsigned int branch) const; - int + unsigned int getBranchCount() const; SHAMapHash const& - getChildHash(int m) const; + getChildHash(unsigned int branch) const; void - setChild(int m, SHAMapTreeNodePtr child); + setChild(unsigned int branch, SHAMapTreeNodePtr child); void - shareChild(int m, SHAMapTreeNodePtr const& child); + shareChild(unsigned int branch, SHAMapTreeNodePtr const& child); SHAMapTreeNode* - getChildPointer(int branch); + getChildPointer(unsigned int branch); SHAMapTreeNodePtr - getChild(int branch); + getChild(unsigned int branch); SHAMapTreeNodePtr - canonicalizeChild(int branch, SHAMapTreeNodePtr node); + canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node); // sync functions bool @@ -190,12 +190,12 @@ SHAMapInnerNode::isEmpty() const } inline bool -SHAMapInnerNode::isEmptyBranch(int m) const +SHAMapInnerNode::isEmptyBranch(unsigned int branch) const { - return (isBranch_ & (1 << m)) == 0; + return (isBranch_ & (1u << branch)) == 0u; } -inline int +inline unsigned int SHAMapInnerNode::getBranchCount() const { return popcnt16(isBranch_); diff --git a/include/xrpl/shamap/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..e827e69f01 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -17,7 +17,6 @@ #include #include #include -#include namespace xrpl { @@ -130,16 +129,6 @@ public: view_->rawDestroyXRP(fee); } - /** - * Applies all invariant checkers one by one. - * - * @param result the result generated by processing this transaction. - * @param fee the fee charged for this transaction - * @return the result code that should be returned for this transaction. - */ - TER - checkInvariants(TER const result, XRPAmount const fee); - ApplyViewContext getApplyViewContext() { @@ -150,13 +139,6 @@ public: } private: - static TER - failInvariantCheck(TER const result); - - template - TER - checkInvariantsHelper(TER const result, XRPAmount const fee, std::index_sequence); - OpenView& base_; ApplyFlags flags_; std::optional view_; diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index a71285f70e..96ad7e00bc 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -147,7 +148,7 @@ struct FeePayer FeePayerType type{FeePayerType::Account}; }; -class Transactor +class Transactor : public TxInvariantCheck { protected: ApplyContext& ctx_; @@ -158,7 +159,7 @@ protected: XRPAmount preFeeBalance_{}; // Balance before fees. public: - virtual ~Transactor() = default; + ~Transactor() override = default; Transactor(Transactor const&) = delete; Transactor& operator=(Transactor const&) = delete; @@ -183,20 +184,50 @@ public: return ctx_.view(); } + /** + * Which invariant layers to check. + * + * Full runs the protocol invariants plus the transaction-specific + * check. This is always the scope of the initial pass, even when the + * tentative TER is a tec: a bug or exploit could still mutate ledger + * state, so transaction-specific invariants must run for failed + * transactions too. + * + * ProtocolOnly runs only the protocol invariants and is used + * exclusively for the second invariant pass that follows a + * fee-claim reset — specifically, the reset that + * Transactor::operator() performs when the initial invariant pass + * returns tecINVARIANT_FAILED, rolling the transaction's effects back + * to a fee-claim-only state. In that reduced state the + * transaction-specific post-conditions no longer apply, but the + * protocol invariants must still hold against the fee claim itself. + * ProtocolOnly is not intended for other context discards (e.g. the + * reset used to handle tecOVERSIZE/tecKILLED/etc. in + * processPersistentChanges, or the ctx_.discard() done under + * TapFailHard); those paths do not re-run invariants at all. + */ + enum class InvariantScope { Full, ProtocolOnly }; + /** * Check all invariants for the current transaction. * - * Runs transaction-specific invariants first (visitInvariantEntry + - * finalizeInvariants), then protocol-level invariants. Both layers - * always run; the worst failure code is returned. + * Delegates to the free xrpl::checkInvariants runner. When @p scope is + * InvariantScope::Full, this transactor is passed so both layers + * share a single walk of the modified ledger entries. A failure in + * either layer fails the transaction the same way: tecINVARIANT_FAILED + * on the first pass, which the caller may respond to by rolling the + * transaction back to a fee-claim state and re-invoking this with + * InvariantScope::ProtocolOnly; a failure on that post-reset pass + * escalates to tefINVARIANT_FAILED. * * @param result the tentative TER from transaction processing. * @param fee the fee consumed by the transaction. + * @param scope which invariant layers to check. * * @return the final TER after all invariant checks. */ [[nodiscard]] TER - checkInvariants(TER result, XRPAmount fee); + checkInvariants(TER result, XRPAmount fee, InvariantScope scope); ///////////////////////////////////////////////////// /* @@ -538,20 +569,30 @@ private: preflightUniversal(PreflightContext const& ctx); /** - * Check transaction-specific invariants only. - * - * Walks every modified ledger entry via visitInvariantEntry, then - * calls finalizeInvariants on the derived transactor. Returns - * tecINVARIANT_FAILED if any transaction invariant is violated. - * - * @param result the tentative TER from transaction processing. - * @param fee the fee consumed by the transaction. - * - * @return the original result if all invariants pass, or - * tecINVARIANT_FAILED otherwise. + * Bridges the two-phase TxInvariantCheck interface to this transactor's + * visitInvariantEntry/finalizeInvariants hooks. Declared private (rather + * than protected, like the hooks they forward to) so that neither this + * transactor nor any subclass can call them directly through a + * Transactor& — only through the TxInvariantCheck& that the free + * xrpl::checkInvariants runner holds, which is where the two-phase + * ordering is enforced. */ - [[nodiscard]] TER - checkTransactionInvariants(TER result, XRPAmount fee); + void + visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) final + { + visitInvariantEntry(isDelete, before, after); + } + + [[nodiscard]] bool + finalize( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) final + { + return finalizeInvariants(tx, result, fee, view, j); + } }; inline bool diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 4b3e9beec4..301e464daf 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include namespace xrpl { @@ -69,7 +71,9 @@ private: IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce); + bool enforce, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); static bool validateFrozenState( @@ -78,7 +82,9 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze); + bool globalFreeze, + bool fixOverrideFreeze, + std::optional const& loanDefaultAccounts); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 1239305e79..e8dafbd301 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -198,7 +198,7 @@ public: /** * @brief Invariant: An account XRP balance must be in XRP and take a value - * between 0 and INITIAL_XRP drops, inclusive. + * between 0 and kInitialXRP drops, inclusive. * * We iterate all account roots modified by the transaction and ensure that * their XRP balances are reasonable. @@ -290,7 +290,7 @@ public: /** * @brief Invariant: an escrow entry must take a value between 0 and - * INITIAL_XRP drops exclusive. + * kInitialXRP drops exclusive. */ class NoZeroEscrow { diff --git a/include/xrpl/tx/invariants/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/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..fc72b8d420 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,8 @@ namespace xrpl { * @brief Invariants: Loans are internally consistent * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h index 2763b80a94..ae0d573385 100644 --- a/include/xrpl/tx/invariants/PermissionedDEXInvariant.h +++ b/include/xrpl/tx/invariants/PermissionedDEXInvariant.h @@ -17,7 +17,8 @@ class ValidPermissionedDEX bool regularOffers_ = false; // post-fixCleanup3_2_0: excludes deleted offers bool badHybridsOld_ = false; // pre-fixCleanup3_1_3: missing field/domain or size > 1 bool badHybrids_ = false; // post-fixCleanup3_1_3: also catches size == 0 (size != 1) - hash_set domains_; + hash_set domainsOld_; // pre-fixCleanup3_4_0: also flags deleted domains + hash_set domains_; // post-fixCleanup3_4_0: excludes deleted domains public: void diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..2ba42f0ab4 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,17 @@ namespace xrpl { * - vault set must not alter the vault assets or shares balance * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - a created closed-ended vault must satisfy + * MIN_INVESTMENT_PERIOD <= RedemptionDate - SubscriptionDate < + * MAX_INVESTMENT_PERIOD + * - vault deposit may only succeed when the vault phase is NoPhase or + * Subscription + * - vault withdrawal may not succeed when the vault phase is Investment + * - closed-ended loan origination (ttLOAN_SET) may only succeed when the + * vault phase is Investment * + * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). */ class ValidVault { @@ -55,6 +65,9 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::optional vaultKind; + std::optional subscriptionDate; + std::optional redemptionDate; Vault static make(SLE const&); }; @@ -153,6 +166,17 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * For a closed-ended vault, a loan may only be originated while the vault is in the Investment + * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c + * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c + * RedemptionDate) is enforced by @c ValidLoan. + */ + [[nodiscard]] bool + finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; + public: // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t diff --git a/include/xrpl/tx/paths/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..6861fa7bc4 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -118,6 +118,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -138,6 +139,11 @@ public: * @param view * @param ammSle AMM ledger entry * @param ammAccount AMM account + * @param clawbackIssuer when set (AMMClawback path), the issuer performing + * the clawback. A recreated MPToken is only auto-authorized when the + * asset's issuer matches this account, so a clawback cannot grant + * authorization on behalf of a different (paired-asset) issuer. + * @param account LP account * @param amountBalance current LP asset1 balance * @param amountWithdraw asset1 withdraw amount * @param amount2Withdraw asset2 withdraw amount @@ -153,6 +159,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, diff --git a/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/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 new file mode 100644 index 0000000000..f23b2dcc21 --- /dev/null +++ b/nix/check-tools/README.md @@ -0,0 +1,54 @@ +# check-tools snapshots + +These files capture the output of [`bin/check-tools.sh`](../../bin/check-tools.sh) +— the version and resolved store path of each development tool — in each Nix +environment: + +| 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. +So if you change the environment (bump the image tag in +[`linux.json`](../../.github/scripts/strategy-matrix/linux.json), update +`flake.lock`, change the tool list in `check-tools.sh`, …) you must regenerate +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 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-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-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-ubuntu-${arch}.txt" +done +``` + +(With Docker, replace `container run … -a "${arch}"` with +`docker run … --platform "linux/${arch}"`.) + +The macOS snapshot is generated locally. `CI=` is unset so `check-tools.sh` +checks the full dev-shell tool set (it otherwise skips some tools when `CI` is +set): + +```bash +CI= nix develop -c bash -c 'CHECK_TOOLS_SKIP_CLONE=1 bash bin/check-tools.sh' | + sed -n '/^Detected OS:/,$p' \ + >nix/check-tools/macos.txt +``` diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt new file mode 100644 index 0000000000..8e99aa28e4 --- /dev/null +++ b/nix/check-tools/macos.txt @@ -0,0 +1,143 @@ +Detected OS: macos (Darwin arm64) + +Core build tools: + ✅ 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: + ✅ 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: + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/fpiqdh91gwyxalqp409ynm0s0g086w7w-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (59807616 2026-04-14) + /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt + +Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). + +✅ All 44 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt new file mode 100644 index 0000000000..a5857c93f1 --- /dev/null +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -0,0 +1,171 @@ +Detected OS: linux (Linux x86_64) + +Core build tools: + ✅ 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: + ✅ 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: + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/jjpdf1l6izz6607a346ykra9sndzaw7h-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt + +GCC toolchain: + ✅ 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: + ✅ 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 52 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt new file mode 100644 index 0000000000..820c6de086 --- /dev/null +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -0,0 +1,171 @@ +Detected OS: linux (Linux aarch64) + +Core build tools: + ✅ 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: + ✅ 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: + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/vwjsi159n89szrx4yh5pc3jlf2gp4fld-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt + +GCC toolchain: + ✅ 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: + ✅ 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 52 checked tools are present and runnable. diff --git a/nix/ci-env.nix b/nix/ci-env.nix index 9b754af97d..779b5b7230 100644 --- a/nix/ci-env.nix +++ b/nix/ci-env.nix @@ -1,136 +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 - gccPackage - llvmPackages - llvmVersion - ; + inherit (import ./packages.nix { inherit pkgs; }) commonPackages; - # Underlying compiler toolchains to wrap (versions pinned in packages.nix). - customGccPackage = gccPackage; - customLlvmPackages = llvmPackages; + # 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; }; - # binutils wrapped to emit binaries that reference the custom glibc - # (dynamic linker path, library search path, RPATH). - customBinutils = pkgs.wrapBintoolsWith { - bintools = pkgs.binutils-unwrapped; - libc = customGlibc; - }; - - # Rebuild gcc (specifically libstdc++ / libgcc_s) against the custom - # glibc. The override swaps gcc.cc's bootstrap stdenv for one that uses - # the existing gcc binary but links against the custom glibc, so the - # resulting compiler ships runtime libraries that only reference symbols - # available in that glibc. - customGccCc = customGccPackage.cc.override { - stdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv ( - pkgs.wrapCCWith { - cc = customGccPackage.cc; - libc = customGlibc; - bintools = customBinutils; - } - ); - }; - - # cc-wrapper around the rebuilt compiler, pointing at the custom glibc - # headers and libraries. This is what we actually expose to users. - customGcc = pkgs.wrapCCWith { - cc = customGccCc; - libc = customGlibc; - bintools = customBinutils; - }; - - # gcov ships in gcc's `cc` output, but the cc-wrapper doesn't expose it. - # Surface the gcov from our rebuilt gcc (linked against the custom glibc, so - # it runs under the loader installed in the image) and matching the exact - # compiler version, so gcovr can produce coverage reports in the CI env. - customGcov = pkgs.runCommand "gcov-custom-for-ci-env" { } '' - mkdir -p "$out/bin" - ln -s "${customGccCc}/bin/gcov" "$out/bin/gcov" - ''; - - # stdenv built around the rebuilt gcc / custom glibc. Used to rebuild - # compiler-rt below so its sanitizer runtimes see the custom glibc - # headers. - customStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customGcc; - - # Rebuild compiler-rt against the custom glibc so the sanitizer runtimes - # don't use glibc symbols (or sysconf constants like _SC_SIGSTKSZ) that - # only exist in newer glibc versions. scudo is dropped because its CMake - # includes CheckAtomic with -nostdinc++ in CMAKE_REQUIRED_FLAGS, which - # makes std::atomic unfindable in our stdenv; we don't use scudo (only - # asan/ubsan/tsan etc.). - customCompilerRt = - (customLlvmPackages.compiler-rt.override { - stdenv = customStdenv; - }).overrideAttrs - (old: { - postPatch = (old.postPatch or "") + '' - substituteInPlace lib/CMakeLists.txt \ - --replace-quiet 'add_subdirectory(scudo/standalone)' \ - '# scudo/standalone disabled in xrpld ci-env' - ''; - }); - - # cc-wrapper around clang, pointing at the custom glibc headers and - # libraries. Reuses the rebuilt gcc for libstdc++ / libgcc_s so that - # C++ binaries produced by clang also only reference symbols available - # in the custom glibc. compiler-rt is wired into a resource-root so - # sanitizer runtimes (libclang_rt.*.a) are found at link time; this - # mirrors what nixpkgs does internally when building llvmPackages.clang. - customClang = pkgs.wrapCCWith { - cc = customLlvmPackages.clang-unwrapped; - libc = customGlibc; - bintools = customBinutils; - gccForLibs = customGccCc; - extraPackages = [ customCompilerRt ]; - extraBuildCommands = '' - rsrc="$out/resource-root" - mkdir "$rsrc" - ln -s "${customLlvmPackages.clang-unwrapped.lib}/lib/clang/${toString llvmVersion}/include" "$rsrc/include" - ln -s "${customCompilerRt.out}/lib" "$rsrc/lib" - ln -s "${customCompilerRt.out}/share" "$rsrc/share" || true - echo "-resource-dir=$rsrc" >> $out/nix-support/cc-cflags - # compiler-rt ships the sanitizer/profile/xray interface headers (e.g. - # ) in its `dev` output. In a normal Nix - # build these reach the include path because compiler-rt is propagated - # via depsTargetTargetPropagated and stdenv's setup hooks add its - # dev/include. The CI image runs clang outside a Nix stdenv (binaries - # on PATH, no setup hooks), so that never happens; add the headers - # explicitly. gcc ships its own copy, which is why this is clang-only. - 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 - ''; - }; + # 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 - # 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 34f173ef08..07f7143c5b 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -1,23 +1,98 @@ -{ pkgs, ... }: +{ pkgs, customGlibc, ... }: let inherit (import ./packages.nix { inherit pkgs; }) commonPackages + gccPackage gccVersion + llvmVersion llvmPackages + mkVersionedToolLinks + mkGcov ; - # Plain nixpkgs stdenvs — no custom glibc, unlike ci-env.nix. - gccStdenv = pkgs."gcc${toString gccVersion}Stdenv"; - clangStdenv = llvmPackages.stdenv; + # Plain nixpkgs stdenvs — no custom glibc. + plainGccStdenv = pkgs."gcc${toString gccVersion}Stdenv"; + plainClangStdenv = llvmPackages.stdenv; + + # 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 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." + ''; + + # Tools to expose under version-suffixed names (see mkVersionedToolLinks). + gccVersionedTools = [ + "gcc" + "g++" + "cpp" + ]; + clangVersionedTools = [ + "clang" + "clang++" + ]; # compilerName is the command used to print the version, or null for none. makeShell = { + shellName, stdenv, compilerName, + version ? null, + versionedTools ? [ ], + extraPackages ? [ ], + warningHook ? "", + # Opt out of PatchNixBinary.cmake retargeting binaries to the system + # loader. The plain toolchain links a newer glibc, so it must not be + # patched; the custom toolchain patches by default. + noPatchNixBinary ? false, }: let - compilerVersion = + compilerVersionHook = if compilerName == null then ''echo "No compiler specified - using system compiler"'' else @@ -25,33 +100,83 @@ let echo "Compiler: " ${compilerName} --version ''; + versionedLinks = pkgs.lib.optional (version != null) (mkVersionedToolLinks { + name = compilerName; + package = stdenv.cc; + inherit version; + tools = versionedTools; + }); in - (pkgs.mkShell.override { inherit stdenv; }) { - packages = commonPackages; - shellHook = '' - echo "Welcome to xrpld development shell"; - ${compilerVersion} - ''; - }; + (pkgs.mkShell.override { inherit stdenv; }) ( + { + packages = commonPackages ++ versionedLinks ++ extraPackages; + # Marks a managed dev shell, so the build (XrplSanity.cmake) can tell an + # intentional Nix toolchain from one leaked into a bare shell. + XRPL_DEVSHELL = shellName; + shellHook = '' + echo "Welcome to xrpld development shell"; + ${compilerVersionHook} + ${darwinLibresolvHook} + ${conanHook} + ${warningHook} + ''; + } + // pkgs.lib.optionalAttrs noPatchNixBinary { XRPLD_NO_PATCH_NIX_BINARY = "1"; } + ); in rec { # macOS: Nix Clang. Linux: Nix GCC. default = if pkgs.stdenv.isDarwin then clang else gcc; + # gcc/clang use the custom-glibc toolchain, matching CI. On darwin there is no + # custom glibc, so they fall back to the plain nixpkgs toolchain. gcc = makeShell { - stdenv = gccStdenv; + shellName = "gcc"; + stdenv = customGccStdenv; compilerName = "gcc"; + version = gccVersion; + versionedTools = gccVersionedTools; + extraPackages = [ customGccGcov ]; }; clang = makeShell { - stdenv = clangStdenv; + shellName = "clang"; + stdenv = customClangStdenv; compilerName = "clang"; + version = llvmVersion; + versionedTools = clangVersionedTools; }; # Nix provides no compiler; use the one from your system (e.g. Apple Clang). no-compiler = makeShell { + shellName = "no-compiler"; stdenv = pkgs.stdenvNoCC; compilerName = null; }; apple-clang = no-compiler; } +# The *-plain shells (stock nixpkgs toolchain) exist only on Linux: on darwin +# gcc/clang are already plain, so these would be redundant and are omitted, which +# makes `nix develop .#gcc-plain` fail there rather than silently aliasing gcc. +// pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { + gcc-plain = makeShell { + shellName = "gcc-plain"; + stdenv = plainGccStdenv; + compilerName = "gcc"; + version = gccVersion; + versionedTools = gccVersionedTools; + extraPackages = [ plainGcov ]; + warningHook = plainWarningHook; + noPatchNixBinary = true; + }; + + clang-plain = makeShell { + shellName = "clang-plain"; + stdenv = plainClangStdenv; + compilerName = "clang"; + version = llvmVersion; + versionedTools = clangVersionedTools; + warningHook = plainWarningHook; + noPatchNixBinary = true; + }; +} diff --git a/nix/docker/Dockerfile b/nix/docker/Dockerfile index 7222cc8fa8..5506bc3c77 100644 --- a/nix/docker/Dockerfile +++ b/nix/docker/Dockerfile @@ -8,10 +8,12 @@ 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/linux.nix /tmp/build/nix/linux.nix COPY nix/packages.nix /tmp/build/nix/packages.nix COPY nix/utils.nix /tmp/build/nix/utils.nix COPY flake.nix /tmp/build/ COPY flake.lock /tmp/build/ +COPY rust-toolchain.toml /tmp/build/ WORKDIR /tmp/build FROM builder-source AS builder @@ -56,7 +58,7 @@ ENV GIT_SSL_CAINFO="/nix/ci-env/etc/ssl/certs/ca-bundle.crt" # Externally-built dynamically-linked ELF binaries hard-code the loader path # (e.g. /lib64/ld-linux-x86-64.so.2) in their PT_INTERP header. Install it # from the Nix store when the base image doesn't already provide one. -COPY nix/docker/loader-path.sh /tmp/loader-path.sh +COPY bin/default-loader-path.sh /tmp/loader-path.sh RUN <&2 + exit 1 + fi + + local binary="${dst_dir}/proc_macro" + cp "${built}" "${binary}" + + echo "=== Patching ${binary} to use ${loader} as PT_INTERP ===" + patchelf --set-interpreter "${loader}" --remove-rpath "${binary}" + + rm -rf "${proj}/target" +} + +compile_proc_macro + echo "=== All binaries compiled ===" ls -la "${dst_dir}" diff --git a/nix/docker/test_files/rust/proc_macro/Cargo.lock b/nix/docker/test_files/rust/proc_macro/Cargo.lock new file mode 100644 index 0000000000..acab3fa0b9 --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "echo_macro" +version = "0.0.0" + +[[package]] +name = "test_macro" +version = "0.0.0" +dependencies = [ + "echo_macro", +] diff --git a/nix/docker/test_files/rust/proc_macro/Cargo.toml b/nix/docker/test_files/rust/proc_macro/Cargo.toml new file mode 100644 index 0000000000..d54955de3d --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["echo_macro", "test_macro"] diff --git a/nix/docker/test_files/rust/proc_macro/echo_macro/Cargo.toml b/nix/docker/test_files/rust/proc_macro/echo_macro/Cargo.toml new file mode 100644 index 0000000000..b0f92ce75a --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/echo_macro/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "echo_macro" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true diff --git a/nix/docker/test_files/rust/proc_macro/echo_macro/src/lib.rs b/nix/docker/test_files/rust/proc_macro/echo_macro/src/lib.rs new file mode 100644 index 0000000000..e4b90d58fe --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/echo_macro/src/lib.rs @@ -0,0 +1,6 @@ +use proc_macro::TokenStream; + +#[proc_macro] +pub fn define_echo(item: TokenStream) -> TokenStream { + format!("fn echo() -> u32 {{ {item} }}").parse().unwrap() +} diff --git a/nix/docker/test_files/rust/proc_macro/test_macro/Cargo.toml b/nix/docker/test_files/rust/proc_macro/test_macro/Cargo.toml new file mode 100644 index 0000000000..25b6ba8e45 --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/test_macro/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "test_macro" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +echo_macro = { path = "../echo_macro" } diff --git a/nix/docker/test_files/rust/proc_macro/test_macro/src/main.rs b/nix/docker/test_files/rust/proc_macro/test_macro/src/main.rs new file mode 100644 index 0000000000..77718b9d75 --- /dev/null +++ b/nix/docker/test_files/rust/proc_macro/test_macro/src/main.rs @@ -0,0 +1,9 @@ +use echo_macro::define_echo; + +define_echo!(42); + +fn main() { + let a = echo(); + println!("proc-macro answer = {a}"); + assert_eq!(a, 42, "proc-macro expansion produced the wrong value"); +} diff --git a/nix/docker/test_files/rust/run-binaries.sh b/nix/docker/test_files/rust/run-binaries.sh index b627c12609..4cafee00ec 100755 --- a/nix/docker/test_files/rust/run-binaries.sh +++ b/nix/docker/test_files/rust/run-binaries.sh @@ -1,7 +1,7 @@ #!/bin/bash # Run pre-compiled Rust binaries and confirm each emits its expected diagnostic. # Binaries must already exist in as for name in -# {hello,panic,overflow}. +# {hello,panic,overflow,proc_macro}. set -eo pipefail @@ -54,12 +54,13 @@ declare -A expect=( [hello]="Hello from main thread" [panic]="explicit panic from test" [overflow]="attempt to add with overflow" + [proc_macro]="proc-macro answer = 42" ) -for name in hello panic overflow; do +for name in hello panic overflow proc_macro; do binary="${bins_dir}/${name}" - if [ "${name}" = "hello" ]; then + if [ "${name}" = "hello" ] || [ "${name}" = "proc_macro" ]; then expected_rc=0 else expected_rc=nonzero diff --git a/nix/linux.nix b/nix/linux.nix new file mode 100644 index 0000000000..ea808fbf50 --- /dev/null +++ b/nix/linux.nix @@ -0,0 +1,152 @@ +# 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, +}: +let + inherit (import ./packages.nix { inherit pkgs; }) + gccPackage + gccVersion + llvmPackages + llvmVersion + mkGcov + mkVersionedToolLinks + ; + + # binutils wrapped to emit binaries that reference the custom glibc + # (dynamic linker path, library search path, RPATH). + customBinutils = pkgs.wrapBintoolsWith { + bintools = pkgs.binutils-unwrapped; + libc = customGlibc; + }; + + # Rebuild gcc (specifically libstdc++ / libgcc_s) against the custom + # glibc. The override swaps gcc.cc's bootstrap stdenv for one that uses + # the existing gcc binary but links against the custom glibc, so the + # resulting compiler ships runtime libraries that only reference symbols + # available in that glibc. + customGccCc = gccPackage.cc.override { + stdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv ( + pkgs.wrapCCWith { + cc = gccPackage.cc; + libc = customGlibc; + bintools = customBinutils; + } + ); + }; + + # cc-wrapper around the rebuilt compiler, pointing at the custom glibc + # headers and libraries. This is what we actually expose to users. + customGcc = pkgs.wrapCCWith { + cc = customGccCc; + libc = customGlibc; + bintools = customBinutils; + }; + + # gcov matching the rebuilt gcc (linked against the custom glibc), so gcovr + # can produce coverage reports both in CI and in the dev shell. + customGcov = mkGcov { + name = "custom"; + cc = customGccCc; + }; + + # stdenv built around the rebuilt gcc / custom glibc. Exported as the dev + # shell's gcc stdenv, and used below to rebuild compiler-rt so its sanitizer + # runtimes see the custom glibc headers. + customStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customGcc; + + # Rebuild compiler-rt against the custom glibc so the sanitizer runtimes + # don't use glibc symbols (or sysconf constants like _SC_SIGSTKSZ) that + # only exist in newer glibc versions. scudo is dropped because its CMake + # includes CheckAtomic with -nostdinc++ in CMAKE_REQUIRED_FLAGS, which + # makes std::atomic unfindable in our stdenv; we don't use scudo (only + # asan/ubsan/tsan etc.). + customCompilerRt = + (llvmPackages.compiler-rt.override { + stdenv = customStdenv; + }).overrideAttrs + (old: { + postPatch = (old.postPatch or "") + '' + substituteInPlace lib/CMakeLists.txt \ + --replace-quiet 'add_subdirectory(scudo/standalone)' \ + '# scudo/standalone disabled in xrpld ci-env' + ''; + }); + + # cc-wrapper around clang, pointing at the custom glibc headers and + # libraries. Reuses the rebuilt gcc for libstdc++ / libgcc_s so that + # C++ binaries produced by clang also only reference symbols available + # in the custom glibc. compiler-rt is wired into a resource-root so + # sanitizer runtimes (libclang_rt.*.a) are found at link time; this + # mirrors what nixpkgs does internally when building llvmPackages.clang. + customClang = pkgs.wrapCCWith { + cc = llvmPackages.clang-unwrapped; + libc = customGlibc; + bintools = customBinutils; + gccForLibs = customGccCc; + extraPackages = [ customCompilerRt ]; + extraBuildCommands = '' + rsrc="$out/resource-root" + mkdir "$rsrc" + ln -s "${llvmPackages.clang-unwrapped.lib}/lib/clang/${toString llvmVersion}/include" "$rsrc/include" + ln -s "${customCompilerRt.out}/lib" "$rsrc/lib" + ln -s "${customCompilerRt.out}/share" "$rsrc/share" || true + echo "-resource-dir=$rsrc" >> $out/nix-support/cc-cflags + # compiler-rt ships the sanitizer/profile/xray interface headers (e.g. + # ) in its `dev` output. In a normal Nix + # build these reach the include path because compiler-rt is propagated + # via depsTargetTargetPropagated and stdenv's setup hooks add its + # dev/include. The CI image runs clang outside a Nix stdenv (binaries + # on PATH, no setup hooks), so that never happens; add the headers + # explicitly. gcc ships its own copy, which is why this is clang-only. + 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 +{ + # For an environment that only puts binaries on PATH. + toolchain = [ + 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++" + ]; + }) + ]; + + gccStdenv = customStdenv; + clangStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customClang; + gcov = customGcov; +} diff --git a/nix/packages.nix b/nix/packages.nix index 41d7e97328..c7972c9843 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -15,6 +15,88 @@ let runClangTidy = pkgs.writeShellScriptBin "run-clang-tidy" '' 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 [ ]); + }); + + # Nix wraps its toolchain so that binaries are exposed only under unsuffixed + # names (gcc, g++, clang-tidy, ...). Several tools probe for a + # version-suffixed name first and fall back to a system binary on the PATH + # when Nix doesn't provide it: + # - Conan's Boost recipe looks up `g++-` before plain `g++`. + # - bin/pre-commit/clang_tidy_check.py looks up `run-clang-tidy-` and + # `clang-apply-replacements-` before the unsuffixed names. + # On a host that also has the matching system binary (e.g. Ubuntu's + # `/usr/bin/g++-15` or `clang-tidy-22`) the probe escapes Nix and mixes a + # system tool into the Nix environment. Generate version-suffixed symlinks + # next to a package's tools so those probes resolve to the Nix ones. + # + # Compiler links must point at whichever compiler is active in a given + # 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, + package, + version, + 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. + mkGcov = + { name, cc }: + pkgs.linkFarm "gcov-${name}" [ + { + name = "bin/gcov"; + path = "${cc}/bin/gcov"; + } + ]; + + clangToolLinks = mkVersionedToolLinks { + name = "clang-tools"; + package = clangTools; + version = llvmVersion; + tools = [ + "clang-tidy" + "clang-apply-replacements" + "clang-format" + ]; + }; + runClangTidyLink = mkVersionedToolLinks { + name = "run-clang-tidy"; + package = runClangTidy; + version = llvmVersion; + tools = [ "run-clang-tidy" ]; + }; in { inherit @@ -22,9 +104,13 @@ in llvmVersion gccPackage llvmPackages + mkVersionedToolLinks + mkGcov ; commonPackages = with pkgs; [ + clangToolLinks + runClangTidyLink ccache clangbuildanalyzer clangTools @@ -49,28 +135,14 @@ in perl # needed for openssl pkg-config pre-commit - # protoc generates the Go gRPC bindings and embeds its own version string into every committed - # .pb.go file. To allow CI to verify those files with a plain `git diff`, we pin the version to - # `protobuf_34` rather than the rolling `protobuf` to keep regeneration reproducible across the - # Nix frequently changing unstable channel. The protoc-gen-go* plugins have no versioned - # attributes in nixpkgs; protoc-gen-go's version is in turn constrained by the go.mod require - # on google.golang.org/protobuf. - protobuf_34 # provides protoc - protoc-gen-go # protoc plugin for the Go message bindings - protoc-gen-go-grpc # protoc plugin for the Go gRPC service stubs python3 runClangTidy vim zip # Rust packages - cargo cargo-audit cargo-llvm-cov cargo-nextest - clippy - corrosion - rust-analyzer - rustc - rustfmt + rustToolchain ]; } diff --git a/nix/utils.nix b/nix/utils.nix index d83e612c16..0b70183ef3 100644 --- a/nix/utils.nix +++ b/nix/utils.nix @@ -1,4 +1,8 @@ -{ nixpkgs, nixpkgs-custom-glibc }: +{ + nixpkgs, + nixpkgs-custom-glibc, + rust-overlay, +}: function: nixpkgs.lib.genAttrs [ @@ -10,7 +14,12 @@ nixpkgs.lib.genAttrs ( system: function { - pkgs = import nixpkgs { inherit system; }; + # rust-overlay adds `pkgs.rust-bin`, from which we build the pinned Rust + # toolchain (see packages.nix). Consumed by both the CI image and dev shell. + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; # glibc 2.31 — matches the system libc on Ubuntu 20.04 LTS. Sourced # from the nixpkgs snapshot pinned via the `nixpkgs-custom-glibc` # flake input, so the build uses the compiler from that snapshot diff --git a/package/Dockerfile b/package/Dockerfile index 6cb2a09933..978b569bd8 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -2,13 +2,6 @@ ARG BASE_IMAGE=debian:bookworm FROM ${BASE_IMAGE} -# Packaging runs in a vanilla distro image, so the tooling has to come -# from the distro's archive: debhelper for deb, rpm-build (and the -# systemd / find-debuginfo macros it depends on) for rpm. -# The container also uses git (real history) for -# build_pkg.sh's SOURCE_DATE_EPOCH; otherwise it falls back to a tarball -# download and the timestamp comes from wall-clock time. - COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh RUN /tmp/install-packaging-tools.sh diff --git a/package/README.md b/package/README.md index 4b78106c4c..54b1e57204 100644 --- a/package/README.md +++ b/package/README.md @@ -1,12 +1,16 @@ # 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.sh Staging and build script (called by the CMake `package` target and CI) + sign_rpm.sh Signs the built RPMs (called by CI when publishing) + publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -29,7 +33,7 @@ package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | -| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild` | +| RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | To print the full packaging matrix (artifact names and images) for the current @@ -46,17 +50,28 @@ To print the full packaging matrix (artifact names and images) for the current Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from `package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` 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. +one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` +binary artifacts and runs in that distro's container, so the package format +follows from the container's package manager. The packaging script derives the +package version from the downloaded binary's `xrpld --version` output; no CMake +configure or build step is needed inside the packaging job. + +The binaries come from the `debian` and `rhel` build configurations in +`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +build job produces `validator-keys` next to `xrpld` and uploads it as the +`validator-keys-` artifact. The packaging entry for a distro names +both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a +packaged configuration must keep `-Dvalidator_keys=ON`. + +`validator-keys` 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 @@ -74,7 +89,7 @@ docker run --rm \ ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" # Output: -# build/debbuild/*.deb (DEB + dbgsym .ddeb) +# build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) # build/rpmbuild/RPMS/x86_64/*.rpm ``` @@ -87,6 +102,7 @@ needed, but the host toolchain replaces the pinned CI image: ```bash cmake \ -Dxrpld=ON \ + -Dvalidator_keys=ON \ -Dpkg_release=1 \ -Dtests=OFF \ .. @@ -95,15 +111,67 @@ 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 +least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and +`validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target +builds both binaries before packaging. `build_pkg.sh` then infers the package +format from the host's package manager. The packaging script installs to +FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. The package version is not a CMake input on this path: `build_pkg.sh` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. +## Publishing packages + +Packages are published to the XRPLF repositories on Sonatype Nexus at +`https://packages.xrplf.org`. The `release-info` action decides the channel from +the event, and `publish_pkg.sh` maps that channel to its repositories: + +| Event | Version | Channel | DEB repository | RPM upload repository | +| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- | +| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable-hosted` | +| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable-hosted` | +| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental-hosted` | +| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | +| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | + +Only a tag names a channel — do not extend that to `develop`, where +`BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final +version during a release cycle, which would send develop builds into `stable`. +Versions sort in row order, so moving to a more mature channel never downgrades. + +The action decides the package release number on the same split: a tag's version +is unique, so its packages are release 1, while develop repeats the same version +and takes `github.run_number` so each push supersedes the last. Both reach the +packaging scripts as arguments, so neither script derives anything itself. + +Publishing is the last step of each packaging job, uploading from the container +that built the packages. It runs when the caller passes `publish: true`: +`on-trigger.yml` for develop pushes in `XRPLF/rippled`, `on-tag.yml` for tags in +any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the +`NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the +Conan remote. + +Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: + +- Each apt-hosted repository needs a distribution (ours use `any`) and a PGP + signing keypair configured in Nexus, which rejects one created without a + keypair. Nexus signs the apt metadata with it, never the packages. +- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` + repository sits behind a `rpm-` yum group repository whose metadata + Nexus signs. Uploads go to the hosted repository; clients point at the group + and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs + themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients + verify them with `gpgcheck=1`. +- yum metadata is rebuilt asynchronously, so a successful publish is not + immediately installable. +- Each job uploads only what it built, and uploads are not transactional, so a + failure can leave one format published alone. Re-running is safe: both the apt + POST and the yum PUT replace an existing asset. +- The `develop` repositories gain a package per push, so they need a cleanup + policy to stay bounded; tagged channels publish each version once. + ## How `build_pkg.sh` works `build_pkg.sh` derives the `xrpld` software version from @@ -135,10 +203,9 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the repository component: final releases use -`stable`, `b0` builds, including `b0+metadata`, use `develop`, and `bN`/`rcN` -pre-releases use `unstable`. -Build metadata on a final release, such as `3.2.0+abc123`, is rejected. +The Debian changelog entry carries the channel passed as `--channel` +(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build +metadata on a final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like @@ -152,28 +219,31 @@ fail early. Flags are for explicit invocation; environment variables are intended for CMake/CI integration. The CI workflow and the CMake `package` target both invoke `build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR` and `PKG_RELEASE` via env -and lets the script use defaults for the rest. +`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and +`PKG_CHANNEL` via env and lets the script use defaults for the rest. + +Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +that only runs when publishing, so a published RPM is always signed and a local +build never needs a key. It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls -`stage_common()` to copy the binary, config files, and shared support files -into the staging area, and invokes the platform build tool. +`stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, +and shared support files into the staging area, and invokes the platform build +tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging +environment; a missing or non-runnable one fails early. That runtime check is +what catches a binary still linked against the Nix store's ELF loader (see +`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,23 +252,27 @@ 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`. +2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and + `validator-keys-LICENSE`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. 4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. -7. Output: `debbuild/*.deb` and `debbuild/*.ddeb` (dbgsym package) +7. Output: `debbuild/*.deb`, the binary package and the `-dbgsym` package. + Debian gives dbgsym packages a `.deb` extension; only Ubuntu uses `.ddeb`. ## Post-build verification ```bash # DEB dpkg-deb -c debbuild/*.deb | grep -E 'systemd|sysusers|tmpfiles' -lintian -I debbuild/*.deb # RPM rpm -qlp rpmbuild/RPMS/x86_64/*.rpm + +# Optional, and not in the packaging image: apt-get install -y lintian +lintian -I debbuild/*.deb ``` ## Reproducibility diff --git a/package/build_pkg.sh b/package/build_pkg.sh index 3684fc096a..cca3be7248 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Build an RPM or Debian package from a pre-built xrpld binary. +# Build an RPM or Debian package from the pre-built xrpld and validator-keys +# binaries. # # Flags override env vars; env vars override defaults. @@ -11,8 +12,12 @@ 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] + --build-dir DIR directory holding the + xrpld and validator-keys + binaries [BUILD_DIR; default: ${PWD}/build] --pkg-release N package release iteration [PKG_RELEASE; default: 1] + --channel NAME release channel, written + to debian/changelog [PKG_CHANNEL; default: unstable] --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] -h, --help show this help and exit EOF @@ -29,6 +34,7 @@ need_arg() { SRC_DIR="${SRC_DIR:-}" BUILD_DIR="${BUILD_DIR:-}" PKG_RELEASE="${PKG_RELEASE:-1}" +PKG_CHANNEL="${PKG_CHANNEL:-unstable}" SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" while [[ $# -gt 0 ]]; do @@ -48,6 +54,11 @@ while [[ $# -gt 0 ]]; do PKG_RELEASE="$2" shift 2 ;; + --channel) + need_arg "$@" + PKG_CHANNEL="$2" + shift 2 + ;; --source-date-epoch) need_arg "$@" SOURCE_DATE_EPOCH="$2" @@ -69,15 +80,44 @@ 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 + echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 exit 1 fi BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" xrpld_binary="${BUILD_DIR}/xrpld" -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 +validator_keys_binary="${BUILD_DIR}/validator-keys" + +# Report both binaries at once: they share a single BUILD_DIR, so telling the +# reader to point it at one of them in isolation is advice they cannot follow. +missing=() +[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) +[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) + +if [[ ${#missing[@]} -gt 0 ]]; then + echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 + echo "Both binaries come from a single CMake build directory configured with" >&2 + echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 + echo "directory." >&2 + exit 1 +fi + +# Shipping validator-keys means shipping its notice, so treat it as required +# rather than letting a package go out without the attribution. +validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" +if [[ ! -f "${validator_keys_license}" ]]; then + echo "build_pkg.sh: missing ${validator_keys_license}." >&2 + echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 + echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 + exit 1 +fi + +# The binary must also *run* here. Packaging happens in a vanilla distro +# container, so this is what catches a binary still pointing at the Nix store's +# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is +# covered implicitly by the version query below. +if ! "${validator_keys_binary}" --version >/dev/null; then + echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 exit 1 fi @@ -150,7 +190,9 @@ stage_common() { local dest="$1" mkdir -p "${dest}" - cp "${BUILD_DIR}/xrpld" "${dest}/xrpld" + cp "${xrpld_binary}" "${dest}/xrpld" + cp "${validator_keys_binary}" "${dest}/validator-keys" + cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" @@ -164,7 +206,6 @@ stage_common() { build_rpm() { local topdir="${BUILD_DIR}/rpmbuild" - rm -rf "${topdir}" mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" @@ -180,7 +221,6 @@ build_rpm() { build_deb() { local staging="${BUILD_DIR}/debbuild/source" - rm -rf "${staging}" mkdir -p "${staging}" stage_common "${staging}" @@ -191,25 +231,9 @@ build_deb() { cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - # Choose the Debian repository component for this package. - # 3.2.0 -> stable, *-b0[+metadata] -> develop, - # bN/rcN pre-releases -> unstable. - local deb_component - if [[ -z "${pre_release}" ]]; then - deb_component="stable" - elif [[ "${pre_release}" =~ ^b0(\+.*)?$ ]]; then - deb_component="develop" - elif [[ "${pre_release}" =~ ^(b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - deb_component="unstable" - else - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 - fi - # Debian version is [~
]-.
     cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
@@ -221,4 +245,8 @@ EOF
     (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
 }
 
+# Remove both build directories, because a package left from an earlier build
+# would otherwise be picked up and published alongside this one.
+rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
+
 "build_${pkg_type}"
diff --git a/package/debian/control b/package/debian/control
index 45d2acbbea..62e5d79ef1 100644
--- a/package/debian/control
+++ b/package/debian/control
@@ -18,6 +18,8 @@ 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.
diff --git a/package/debian/copyright b/package/debian/copyright
index ddaa719e3a..2cf673854a 100644
--- a/package/debian/copyright
+++ b/package/debian/copyright
@@ -4,6 +4,25 @@ 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
+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
diff --git a/package/debian/rules b/package/debian/rules
index 16574bca3f..8f880b8192 100644
--- a/package/debian/rules
+++ b/package/debian/rules
@@ -18,6 +18,7 @@ override_dh_installsysusers:
 
 override_dh_install:
 	install -D -m 0755 xrpld            debian/xrpld/usr/bin/xrpld
+	install -D -m 0755 validator-keys   debian/xrpld/usr/bin/validator-keys
 	install -D -m 0644 xrpld.cfg        debian/xrpld/etc/xrpld/xrpld.cfg
 	install -D -m 0644 validators.txt   debian/xrpld/etc/xrpld/validators.txt
 
diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs
index b43bf86b50..77681ddc6e 100644
--- a/package/debian/xrpld.docs
+++ b/package/debian/xrpld.docs
@@ -1 +1,2 @@
 README.md
+validator-keys-LICENSE
diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh
index a26159a204..2326d8f2ac 100755
--- a/package/install-packaging-tools.sh
+++ b/package/install-packaging-tools.sh
@@ -22,12 +22,24 @@ case "${ID}" in
         ;;
 esac
 
+# Packaging runs in a vanilla distro image, so the tooling comes from the distro's
+# archive rather than from nixpkgs:
+#
+#   - debhelper and dpkg-dev build the DEB
+#   - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config
+#     supplying the systemd and find-debuginfo macros the spec uses
+#   - rpm-sign signs the built RPM
+#   - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from;
+#     without one the timestamp falls back to the wall clock
+#   - curl uploads the finished packages in publish_pkg.sh
+#   - ca-certificates lets curl and git verify TLS
 function install() {
     case "${ID}" in
         debian | ubuntu)
             apt-get update -y
             apt-get install -y --no-install-recommends \
                 ca-certificates \
+                curl \
                 debhelper \
                 debhelper-compat \
                 dpkg-dev \
@@ -36,8 +48,10 @@ function install() {
 
         rhel | centos | rocky | almalinux)
             dnf install -y --setopt=install_weak_deps=False \
+                curl-minimal \
                 git \
                 rpm-build \
+                rpm-sign \
                 redhat-rpm-config \
                 systemd-rpm-macros
             ;;
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
new file mode 100755
index 0000000000..8ea9b189f4
--- /dev/null
+++ b/package/publish_pkg.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
+# repositories on Sonatype Nexus.
+#
+# Usage: publish_pkg.sh  [package-dir]
+#
+#   channel      release channel, selecting the 'deb-' and
+#                'rpm--hosted' repositories
+#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
+#                default)
+#
+# RPMs are uploaded to the hosted repository, but yum clients install from the
+# 'rpm-' group repository in front of it, which serves signed metadata.
+#
+# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
+# instance, and DRY_RUN=1 lists the uploads without performing them.
+
+channel="${1:-}"
+pkg_dir="${2:-build}"
+nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
+
+if [[ -z "${channel}" ]]; then
+    echo "usage: publish_pkg.sh  [package-dir]" >&2
+    exit 2
+fi
+
+deb_repo="deb-${channel}"
+rpm_repo="rpm-${channel}-hosted"
+
+if [[ -z "${DRY_RUN:-}" ]]; then
+    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
+fi
+
+# Deliberate curl choices:
+#
+#   - no --fail, which would hide the response body where Nexus explains what it
+#     rejected
+#   - no --location, since curl downgrades a redirected POST to GET and turns an
+#     upload into a no-op that still answers 200
+#   - credentials on stdin, to keep them out of the process list
+upload() {
+    local url="$1"
+    shift
+    [[ -z "${DRY_RUN:-}" ]] || return 0
+
+    local body code status=0
+    body="$(mktemp)"
+    code="$(
+        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
+            curl \
+                --config - \
+                --silent \
+                --show-error \
+                --retry 3 \
+                --retry-delay 5 \
+                --retry-all-errors \
+                --output "${body}" \
+                --write-out '%{http_code}' \
+                "$@" \
+                "${url}"
+    )" || status=$?
+
+    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
+        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
+        cat "${body}" >&2
+        echo >&2
+        rm -f "${body}"
+        exit 1
+    fi
+
+    rm -f "${body}"
+}
+
+echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
+
+count=0
+while IFS= read -r -d '' file; do
+    name="${file##*/}"
+    case "${name}" in
+        # A raw body with a multipart Content-Type, POSTed to the repository root,
+        # is the documented upload for a hosted apt repository:
+        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+        *.deb | *.ddeb)
+            echo "  ${name} -> ${deb_repo}"
+            upload "${nexus_url}/repository/${deb_repo}/" \
+                --header 'Content-Type: multipart/form-data' \
+                --data-binary "@${file}"
+            ;;
+        # yum repositories are addressed by path; the arch comes from the name.
+        *.rpm)
+            arch="${name%.rpm}"
+            arch="${arch##*.}"
+            echo "  ${name} -> ${rpm_repo}/${arch}"
+            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
+                --upload-file "${file}"
+            ;;
+    esac
+    count=$((count + 1))
+done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
+
+# Uploading nothing would otherwise look like a successful publish.
+if [[ ${count} -eq 0 ]]; then
+    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
+    exit 1
+fi
+
+echo "${count} package(s) ${DRY_RUN:+would be }published."
diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec
index 61c2d61ec6..23974c8900 100644
--- a/package/rpm/xrpld.spec
+++ b/package/rpm/xrpld.spec
@@ -19,8 +19,10 @@ BuildRequires: systemd-rpm-macros
 
 %undefine _debugsource_packages
 %debug_package
-# Intentionally trade larger RPM artifacts for faster package validation.
-%global _binary_payload w.ufdio
+# Level 3 rather than the el9 default of 19: it shrinks the multi-gigabyte
+# debuginfo package roughly fourfold in about a second, where 19 would spend
+# minutes on it.
+%global _binary_payload w3.zstdio
 %global _find_debuginfo_dwz_opts %{nil}
 
 %build_mtime_policy clamp_to_source_date_epoch
@@ -32,6 +34,8 @@ BuildRequires: systemd-rpm-macros
 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
 :
@@ -41,6 +45,7 @@ transactions, and maintains the ledger database.
 
 %install
 install -Dm0755 %{_sourcedir}/xrpld                %{buildroot}%{_bindir}/%{name}
+install -Dm0755 %{_sourcedir}/validator-keys       %{buildroot}%{_bindir}/validator-keys
 install -Dm0644 %{_sourcedir}/xrpld.cfg            %{buildroot}%{_sysconfdir}/%{name}/xrpld.cfg
 install -Dm0644 %{_sourcedir}/validators.txt       %{buildroot}%{_sysconfdir}/%{name}/validators.txt
 
@@ -59,6 +64,8 @@ install -Dm0644 %{_sourcedir}/xrpld.logrotate      %{buildroot}%{_sysconfdir}/lo
 # 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.
@@ -80,11 +87,13 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || :
 
 %files
 %license %{_docdir}/%{name}/LICENSE.md
+%license %{_docdir}/%{name}/validator-keys-LICENSE
 %doc %{_docdir}/%{name}/README.md
 
 %dir %{_sysconfdir}/%{name}
 
 %{_bindir}/%{name}
+%{_bindir}/validator-keys
 
 %config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg
 %config(noreplace) %{_sysconfdir}/%{name}/validators.txt
diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service
index f54e47aa14..22e6359ef0 100644
--- a/package/shared/xrpld.service
+++ b/package/shared/xrpld.service
@@ -24,9 +24,5 @@ LogsDirectoryMode=0750
 LimitNOFILE=65536
 SystemCallArchitectures=native
 
-# Uncomment both lines to allow xrpld to bind to privileged ports (<1024)
-#CapabilityBoundingSet=CAP_NET_BIND_SERVICE
-#AmbientCapabilities=CAP_NET_BIND_SERVICE
-
 [Install]
 WantedBy=multi-user.target
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
new file mode 100755
index 0000000000..250e806dd7
--- /dev/null
+++ b/package/sign_rpm.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata
+# (via the 'rpm-' group repository), but never the packages themselves,
+# so they carry their own signature. Clients verify the packages with gpgcheck=1
+# and the metadata with repo_gpgcheck=1.
+#
+# Usage: sign_rpm.sh [package-dir]
+#
+#   package-dir  searched recursively for *.rpm ('build' by default)
+#
+# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
+# the key out of the process list.
+#
+# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+# but apt does not verify them by default and trusts the repository metadata,
+# which Nexus signs, instead.
+
+pkg_dir="${1:-build}"
+
+mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
+
+# Signing nothing would otherwise look like a successful signing.
+if [[ ${#rpms[@]} -eq 0 ]]; then
+    echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
+    exit 1
+fi
+
+: "${PKG_SIGNING_KEY:?is required}"
+
+# Global, and expanded by the trap when it fires: the keyring holds an
+# unencrypted private key, so it must go even if signing fails.
+signing_home="$(mktemp -d)"
+trap 'rm -rf "${signing_home}"' EXIT
+export GNUPGHOME="${signing_home}"
+
+printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
+
+# Exactly one secret key, so that picking the first below is not a guess between
+# several.
+secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
+if [[ "${secrets}" -ne 1 ]]; then
+    echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
+    exit 1
+fi
+
+key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
+echo "Signing ${#rpms[@]} RPM(s) with ${key}."
+
+# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
+rpmsign \
+    --define "_gpg_name ${key}" \
+    --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
+    --addsign "${rpms[@]}"
+
+# rpmsign can exit 0 having attached nothing, and an unsigned package is only
+# rejected later, on the installing machine. Both header tags are checked
+# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
+# DSAHEADER.
+for pkg in "${rpms[@]}"; do
+    signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
+    if [[ "${signature}" == "(none)(none)" ]]; then
+        echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
+        exit 1
+    fi
+done
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index dbc9e74c5d..a82b4734d8 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,8 +1,4 @@
-# Rust toolchain pin for rustup-based CI runners and local development.
-# rustup reads this file and installs the pinned toolchain (see the
-# prepare-runner action in XRPLF/actions, which runs `rustup toolchain install`).
-# NOTE: the Nix CI image and development shell ignore this file; its rustc comes from flake.lock.
 [toolchain]
 channel = "1.95"
-components = ["rustfmt", "clippy"]
+components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"]
 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 7db0185053..9d5937f869 100644
--- a/src/benchmarks/libxrpl/nodestore/Backend.cpp
+++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp
@@ -17,33 +17,35 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+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");
@@ -326,4 +332,4 @@ registerStoreBatch(BackendConfig const& bc)
 }();
 
 }  // namespace
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/benchmarks/libxrpl/nodestore/Database.cpp b/src/benchmarks/libxrpl/nodestore/Database.cpp
index 2303075ab9..cd4337b603 100644
--- a/src/benchmarks/libxrpl/nodestore/Database.cpp
+++ b/src/benchmarks/libxrpl/nodestore/Database.cpp
@@ -18,7 +18,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 namespace {
 
 // Number of distinct objects pre-generated per run.
@@ -240,4 +240,4 @@ registerWorkload(BackendConfig const& bc, Workload const& w)
 }();
 
 }  // namespace
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h
index fe6c2a350e..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,13 +26,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 // Shared helpers for the NodeStore benchmarks.
 //
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 // Fill `bytes` of memory at `buffer` with random bits drawn from `g`.
 template 
@@ -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,15 +297,14 @@ 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;
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
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/xrpld/consensus/Consensus.cpp b/src/libxrpl/consensus/Consensus.cpp
similarity index 98%
rename from src/xrpld/consensus/Consensus.cpp
rename to src/libxrpl/consensus/Consensus.cpp
index d529ab2e44..6f398cf66c 100644
--- a/src/xrpld/consensus/Consensus.cpp
+++ b/src/libxrpl/consensus/Consensus.cpp
@@ -1,10 +1,9 @@
-#include 
-
-#include 
-#include 
+#include 
 
 #include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
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..0cd082ff47 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
diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
index df6d335085..fcad22d2d5 100644
--- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
@@ -11,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -633,7 +634,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const
     return asset.visit(
         [&](MPTIssue const& issue) {
             if (auto const sle = view.read(keylet::mptoken(issue, ammAccountID));
-                sle && !isFrozen(view, ammAccountID, issue))
+                sle && !isFrozen(view, ammAccountID, *sle))
                 return STAmount{issue, (*sle)[sfMPTAmount]};
             return STAmount{asset};
         },
diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
index 226ea100e9..5ba832957d 100644
--- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -52,6 +53,9 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j)
     for (auto const& h : arr)
     {
         // Credentials already checked in preclaim. Look only for expired here.
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+            return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
+
         auto const k = keylet::credential(h);
         auto const sleCred = view.peek(k);
 
@@ -124,7 +128,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j)
 }
 
 NotTEC
-checkFields(STTx const& tx, beast::Journal j)
+checkFields(STTx const& tx, Rules const& rules, beast::Journal j)
 {
     if (!tx.isFieldPresent(sfCredentialIDs))
         return tesSUCCESS;
@@ -137,6 +141,13 @@ checkFields(STTx const& tx, beast::Journal j)
         return temMALFORMED;
     }
 
+    if (rules.enabled(fixCleanup3_4_0) &&
+        std::ranges::any_of(credentials, [](uint256 const& id) { return id.isZero(); }))
+    {
+        JLOG(j.trace()) << "Malformed transaction: zero credential ID.";
+        return temMALFORMED;
+    }
+
     std::unordered_set duplicates;
     for (auto const& cred : credentials)
     {
@@ -160,6 +171,14 @@ valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal
     auto const& credIDs(tx.getFieldV256(sfCredentialIDs));
     for (auto const& h : credIDs)
     {
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+        {
+            // LCOV_EXCL_START
+            JLOG(j.trace()) << "Zero credential ID.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
         auto const sleCred = view.read(keylet::credential(h));
         if (!sleCred)
         {
@@ -234,6 +253,9 @@ authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, Accou
     lifeExtender.reserve(credIDs.size());
     for (auto const& h : credIDs)
     {
+        if (view.rules().enabled(fixCleanup3_4_0) && h.isZero())
+            return tefINTERNAL;  // LCOV_EXCL_LINE
+
         auto sleCred = view.read(keylet::credential(h));
         if (!sleCred)            // already checked in preclaim
             return tefINTERNAL;  // LCOV_EXCL_LINE
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index e6c3d632c1..cf1bd4915f 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,127 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
         roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
 }
 
+namespace accrual {
+
+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 accrual
+
+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 accrual-basis
+// accounting 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)
+        : accrual::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 accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue);
+}
+
+Number
+loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
+{
+    return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle)
+                                      : accrual::loanVaultExposure(loanSle);
+}
+
+AccountingDeltas
+loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts)
+{
+    return cashBasisEnabled(vaultSle) ? cash_basis::loanPaymentDeltas(parts)
+                                      : accrual::loanPaymentDeltas(parts);
+}
+
 namespace detail {
 
 void
@@ -1495,7 +1655,7 @@ makeRegularPayment(
     LoanPaymentType const paymentType,
     beast::Journal j)
 {
-    using namespace Lending;
+    using namespace lending;
 
     XRPL_ASSERT_PARTS(
         paymentType == LoanPaymentType::Regular || paymentType == LoanPaymentType::Overpayment,
diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
index 6fe7328fa7..73d5fdb1d5 100644
--- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
@@ -42,18 +42,35 @@ bool
 isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue)
 {
     if (auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID())))
-        return sle->isFlag(lsfMPTLocked);
+        return isGlobalFrozen(*sle);
     return false;
 }
 
+bool
+isGlobalFrozen(SLE const& issuanceSle)
+{
+    XRPL_ASSERT(
+        issuanceSle.getType() == ltMPTOKEN_ISSUANCE, "xrpl::isGlobalFrozen : MPTokenIssuance SLE");
+
+    return issuanceSle.isFlag(lsfMPTLocked);
+}
+
 bool
 isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue)
 {
     if (auto const sle = view.read(keylet::mptoken(mptIssue.getMptID(), account)))
-        return sle->isFlag(lsfMPTLocked);
+        return isIndividualFrozen(*sle);
     return false;
 }
 
+bool
+isIndividualFrozen(SLE const& mptSle)
+{
+    XRPL_ASSERT(mptSle.getType() == ltMPTOKEN, "xrpl::isIndividualFrozen : MPToken SLE");
+
+    return mptSle.isFlag(lsfMPTLocked);
+}
+
 bool
 isFrozen(
     ReadView const& view,
@@ -65,6 +82,34 @@ isFrozen(
         isVaultPseudoAccountFrozen(view, account, mptIssue, depth);
 }
 
+bool
+isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth)
+{
+    XRPL_ASSERT(
+        sle.getType() == ltMPTOKEN || sle.getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::isFrozen : MPToken or MPTokenIssuance SLE");
+
+    if (sle.getType() == ltMPTOKEN)
+    {
+        XRPL_ASSERT(sle[sfAccount] == account, "xrpl::isFrozen : valid MPToken holder");
+
+        MPTID const mptID = sle[sfMPTokenIssuanceID];
+        auto const issuanceSle = view.read(keylet::mptokenIssuance(mptID));
+
+        if ((issuanceSle && isGlobalFrozen(*issuanceSle)) || isIndividualFrozen(sle))
+            return true;
+
+        if (issuanceSle)
+            return isVaultPseudoAccountFrozen(view, account, *issuanceSle, depth);
+
+        return isVaultPseudoAccountFrozen(view, account, MPTIssue{mptID}, depth);
+    }
+
+    MPTIssue const mptIssue{sle[sfSequence], sle[sfIssuer]};
+    return isGlobalFrozen(sle) || isIndividualFrozen(view, account, mptIssue) ||
+        isVaultPseudoAccountFrozen(view, account, sle, depth);
+}
+
 [[nodiscard]] bool
 isAnyFrozen(
     ReadView const& view,
@@ -72,7 +117,8 @@ isAnyFrozen(
     MPTIssue const& mptIssue,
     std::uint8_t depth)
 {
-    if (isGlobalFrozen(view, mptIssue))
+    auto const issuanceSle = view.read(keylet::mptokenIssuance(mptIssue.getMptID()));
+    if (issuanceSle && isGlobalFrozen(*issuanceSle))
         return true;
 
     for (auto const& account : accounts)
@@ -81,9 +127,15 @@ isAnyFrozen(
             return true;
     }
 
-    return std::ranges::any_of(accounts, [&](auto const& account) {
-        return isVaultPseudoAccountFrozen(view, account, mptIssue, depth);
-    });
+    // Pass the issuance SLE when we have it to avoid re-reading it per account;
+    // otherwise defer to the MPTIssue overload, which handles a missing issuance.
+    auto const anyVaultFrozen = [&](auto const& shareOrIssuance) {
+        return std::ranges::any_of(accounts, [&](auto const& account) {
+            return isVaultPseudoAccountFrozen(view, account, shareOrIssuance, depth);
+        });
+    };
+
+    return issuanceSle ? anyVaultFrozen(*issuanceSle) : anyVaultFrozen(mptIssue);
 }
 
 Rate
@@ -952,6 +1004,7 @@ checkCreateMPT(
     xrpl::MPTIssue const& mptIssue,
     xrpl::AccountID const& holder,
     SLE::ref sponsorSle,
+    std::uint32_t flags,
     beast::Journal j)
 {
     if (mptIssue.getIssuer() == holder)
@@ -961,7 +1014,7 @@ checkCreateMPT(
     auto const mptokenID = keylet::mptoken(mptIssuanceID.key, holder);
     if (!view.exists(mptokenID))
     {
-        if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, 0);
+        if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, flags);
             !isTesSuccess(err))
         {
             return err;
@@ -977,6 +1030,16 @@ checkCreateMPT(
     return tesSUCCESS;
 }
 
+TER
+checkCreateMPT(
+    xrpl::ApplyView& view,
+    xrpl::MPTIssue const& mptIssue,
+    xrpl::AccountID const& holder,
+    beast::Journal j)
+{
+    return checkCreateMPT(view, mptIssue, holder, {}, 0, j);
+}
+
 std::int64_t
 maxMPTAmount(SLE const& sleIssuance)
 {
diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
index 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/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
index 79e10cdf79..7ebfa64bcf 100644
--- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
@@ -309,6 +309,15 @@ getLineIfUsable(
                 }
             }
         }
+
+        // An LPToken whose AMM pool contains an MPT that forbids transfers is not
+        // spendable. Issuer is the LPToken's AMM account; canTransferLPToken is
+        // a no-op for non-AMM issuers and non-MPT pool assets, so this is implicitly
+        // gated by featureMPTokensV2.
+        if (!isTesSuccess(canTransferLPToken(view, account, account, issuer)))
+        {
+            return nullptr;
+        }
     }
 
     return sle;
@@ -430,7 +439,7 @@ accountHolds(
     auto const sleMpt = view.read(keylet::mptoken(mptIssue.getMptID(), account));
 
     if (!sleMpt ||
-        (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue)))
+        (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, *sleMpt)))
     {
         amount.clear(mptIssue);
     }
diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
index b5b076d1cb..b0d835a423 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -3,16 +3,20 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
 #include 
 #include 
 #include 
 #include   // IWYU pragma: keep
+#include 
 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -63,6 +67,23 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
     return assets;
 }
 
+[[nodiscard]] Number
+assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
+{
+    Number assetTotal = vault->at(sfAssetsTotal);
+    if (waive == WaiveUnrealizedLoss::No)
+        assetTotal -= vault->at(sfLossUnrealized);
+    return assetTotal;
+}
+
+[[nodiscard]] bool
+debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount)
+{
+    if (amount == 0)
+        return false;
+    return STAmount{asset, total - amount} == STAmount{asset, total};
+}
+
 [[nodiscard]] std::optional
 assetsToSharesWithdraw(
     SLE::const_ref vault,
@@ -78,9 +99,7 @@ assetsToSharesWithdraw(
     if (assets.negative() || assets.asset() != vault->at(sfAsset))
         return std::nullopt;  // LCOV_EXCL_LINE
 
-    Number assetTotal = vault->at(sfAssetsTotal);
-    if (waive == WaiveUnrealizedLoss::No)
-        assetTotal -= vault->at(sfLossUnrealized);
+    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
     STAmount shares{vault->at(sfShareMPTID)};
     if (assetTotal == 0)
         return shares;
@@ -106,9 +125,7 @@ sharesToAssetsWithdraw(
     if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
         return std::nullopt;  // LCOV_EXCL_LINE
 
-    Number assetTotal = vault->at(sfAssetsTotal);
-    if (waive == WaiveUnrealizedLoss::No)
-        assetTotal -= vault->at(sfLossUnrealized);
+    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
     STAmount assets{vault->at(sfAsset)};
     if (assetTotal == 0)
         return assets;
@@ -137,4 +154,92 @@ 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;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/nodestore/BatchWriter.cpp b/src/libxrpl/nodestore/BatchWriter.cpp
index e0a1fbf20c..92dfa09e0c 100644
--- a/src/libxrpl/nodestore/BatchWriter.cpp
+++ b/src/libxrpl/nodestore/BatchWriter.cpp
@@ -11,7 +11,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 BatchWriter::BatchWriter(Callback& callback, Scheduler& scheduler)
     : callback_(callback), scheduler_(scheduler)
@@ -72,7 +72,7 @@ BatchWriter::writeBatch()
 
             writeSet_.swap(set);
             XRPL_ASSERT(
-                writeSet_.empty(), "xrpl::NodeStore::BatchWriter::writeBatch : writes not set");
+                writeSet_.empty(), "xrpl::node_store::BatchWriter::writeBatch : writes not set");
             writeLoad_ = set.size();
 
             if (set.empty())
@@ -107,4 +107,4 @@ BatchWriter::waitForWriting()
         writeCondition_.wait(sl);
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/Database.cpp b/src/libxrpl/nodestore/Database.cpp
index ac51dbfb2c..f9de660042 100644
--- a/src/libxrpl/nodestore/Database.cpp
+++ b/src/libxrpl/nodestore/Database.cpp
@@ -30,7 +30,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 Database::Database(
     Scheduler& scheduler,
@@ -43,7 +43,7 @@ Database::Database(
     , requestBundle_(get(config, Keys::kRqBundle, 4))
     , readThreads_(std::max(1, readThreads))
 {
-    XRPL_ASSERT(readThreads, "xrpl::NodeStore::Database::Database : nonzero threads input");
+    XRPL_ASSERT(readThreads, "xrpl::node_store::Database::Database : nonzero threads input");
 
     if (earliestLedgerSeq_ < 1)
         Throw("Invalid earliest_seq");
@@ -89,7 +89,7 @@ Database::Database(
                     {
                         XRPL_ASSERT(
                             !it->second.empty(),
-                            "xrpl::NodeStore::Database::Database : non-empty "
+                            "xrpl::node_store::Database::Database : non-empty "
                             "data");
 
                         auto const& hash = it->first;
@@ -164,7 +164,7 @@ Database::stop()
     {
         XRPL_ASSERT(
             steady_clock::now() - start < 30s,
-            "xrpl::NodeStore::Database::stop : maximum stop duration");
+            "xrpl::node_store::Database::stop : maximum stop duration");
         std::this_thread::yield();
     }
 
@@ -213,7 +213,7 @@ Database::importInternal(Backend& dstBackend, Database& srcDB)
     };
 
     srcDB.forEach([&](std::shared_ptr nodeObject) {
-        XRPL_ASSERT(nodeObject, "xrpl::NodeStore::Database::importInternal : non-null node");
+        XRPL_ASSERT(nodeObject, "xrpl::node_store::Database::importInternal : non-null node");
         if (!nodeObject)  // This should never happen
             return;
 
@@ -257,7 +257,7 @@ Database::fetchNodeObject(
 void
 Database::getCountsJson(json::Value& obj)
 {
-    XRPL_ASSERT(obj.isObject(), "xrpl::NodeStore::Database::getCountsJson : valid input type");
+    XRPL_ASSERT(obj.isObject(), "xrpl::node_store::Database::getCountsJson : valid input type");
 
     {
         std::unique_lock const lock(readLock_);
@@ -276,4 +276,4 @@ Database::getCountsJson(json::Value& obj)
     obj[jss::node_reads_duration_us] = std::to_string(fetchDurationUs_);
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/DatabaseNodeImp.cpp b/src/libxrpl/nodestore/DatabaseNodeImp.cpp
index 9323d69131..1b880ac658 100644
--- a/src/libxrpl/nodestore/DatabaseNodeImp.cpp
+++ b/src/libxrpl/nodestore/DatabaseNodeImp.cpp
@@ -15,7 +15,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 void
 DatabaseNodeImp::store(NodeObjectType type, Blob&& data, uint256 const& hash, std::uint32_t)
@@ -125,4 +125,4 @@ DatabaseNodeImp::fetchNodeObject(
     return nodeObject;
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
index 23a48a3bf3..81b1d6b297 100644
--- a/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
+++ b/src/libxrpl/nodestore/DatabaseRotatingImp.cpp
@@ -22,7 +22,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 DatabaseRotatingImp::DatabaseRotatingImp(
     Scheduler& scheduler,
@@ -43,7 +43,7 @@ DatabaseRotatingImp::DatabaseRotatingImp(
 
 void
 DatabaseRotatingImp::rotate(
-    std::unique_ptr&& newBackend,
+    std::unique_ptr&& newBackend,
     std::function const& f)
 {
     // Pass these two names to the callback function
@@ -52,7 +52,7 @@ DatabaseRotatingImp::rotate(
     // Hold on to current archive backend pointer until after the
     // callback finishes. Only then will the archive directory be
     // deleted.
-    std::shared_ptr oldArchiveBackend;
+    std::shared_ptr oldArchiveBackend;
     std::uint64_t copyForwards = 0;
     {
         std::scoped_lock const lock(mutex_);
@@ -232,4 +232,4 @@ DatabaseRotatingImp::forEach(std::function)> f)
     archive->forEach(f);
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/DecodedBlob.cpp b/src/libxrpl/nodestore/DecodedBlob.cpp
index 9740462ae8..321089d40f 100644
--- a/src/libxrpl/nodestore/DecodedBlob.cpp
+++ b/src/libxrpl/nodestore/DecodedBlob.cpp
@@ -10,7 +10,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 DecodedBlob::DecodedBlob(void const* key, void const* value, int valueBytes) : key_(key)
 {
@@ -55,7 +55,7 @@ DecodedBlob::DecodedBlob(void const* key, void const* value, int valueBytes) : k
 std::shared_ptr
 DecodedBlob::createObject()
 {
-    XRPL_ASSERT(success_, "xrpl::NodeStore::DecodedBlob::createObject : valid object type");
+    XRPL_ASSERT(success_, "xrpl::node_store::DecodedBlob::createObject : valid object type");
 
     std::shared_ptr object;
 
@@ -69,4 +69,4 @@ DecodedBlob::createObject()
     return object;
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/DummyScheduler.cpp b/src/libxrpl/nodestore/DummyScheduler.cpp
index 1f93ed3d0f..32cd14cbdc 100644
--- a/src/libxrpl/nodestore/DummyScheduler.cpp
+++ b/src/libxrpl/nodestore/DummyScheduler.cpp
@@ -3,7 +3,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 void
 DummyScheduler::scheduleTask(Task& task)
@@ -22,4 +22,4 @@ DummyScheduler::onBatchWrite(BatchWriteReport const& report)
 {
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/ManagerImp.cpp b/src/libxrpl/nodestore/ManagerImp.cpp
index a3db22ce74..c78ccd5761 100644
--- a/src/libxrpl/nodestore/ManagerImp.cpp
+++ b/src/libxrpl/nodestore/ManagerImp.cpp
@@ -22,7 +22,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 ManagerImp&
 ManagerImp::instance()
@@ -112,7 +112,7 @@ ManagerImp::erase(Factory& factory)
     std::scoped_lock const _(mutex_);
     auto const iter =
         std::ranges::find_if(list_, [&factory](Factory* other) { return other == &factory; });
-    XRPL_ASSERT(iter != list_.end(), "xrpl::NodeStore::ManagerImp::erase : valid input");
+    XRPL_ASSERT(iter != list_.end(), "xrpl::node_store::ManagerImp::erase : valid input");
     list_.erase(iter);
 }
 
@@ -135,4 +135,4 @@ Manager::instance()
     return ManagerImp::instance();
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/backend/MemoryFactory.cpp b/src/libxrpl/nodestore/backend/MemoryFactory.cpp
index 22557d652e..39d2123bc9 100644
--- a/src/libxrpl/nodestore/backend/MemoryFactory.cpp
+++ b/src/libxrpl/nodestore/backend/MemoryFactory.cpp
@@ -24,7 +24,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 struct MemoryDB
 {
@@ -132,7 +132,7 @@ public:
     Status
     fetch(uint256 const& hash, std::shared_ptr* pObject) override
     {
-        XRPL_ASSERT(db_, "xrpl::NodeStore::MemoryBackend::fetch : non-null database");
+        XRPL_ASSERT(db_, "xrpl::node_store::MemoryBackend::fetch : non-null database");
 
         std::scoped_lock const _(db_->mutex);
 
@@ -149,7 +149,7 @@ public:
     void
     store(std::shared_ptr const& object) override
     {
-        XRPL_ASSERT(db_, "xrpl::NodeStore::MemoryBackend::store : non-null database");
+        XRPL_ASSERT(db_, "xrpl::node_store::MemoryBackend::store : non-null database");
         std::scoped_lock const _(db_->mutex);
         db_->table.emplace(object->getHash(), object);
     }
@@ -169,7 +169,7 @@ public:
     void
     forEach(std::function)> f) override
     {
-        XRPL_ASSERT(db_, "xrpl::NodeStore::MemoryBackend::forEach : non-null database");
+        XRPL_ASSERT(db_, "xrpl::node_store::MemoryBackend::forEach : non-null database");
         for (auto const& e : db_->table)
             f(e.second);
     }
@@ -216,4 +216,4 @@ MemoryFactory::createInstance(
     return std::make_unique(keyBytes, keyValues, journal);
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp
index 38ea34258f..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,15 +34,17 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 class NuDBBackend : public Backend
 {
@@ -131,12 +131,12 @@ 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
             UNREACHABLE(
-                "xrpl::NodeStore::NuDBBackend::open : database is already "
+                "xrpl::node_store::NuDBBackend::open : database is already "
                 "open");
             JLOG(j.error()) << "database is already open";
             return;
@@ -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();
 
@@ -441,4 +442,4 @@ registerNuDBFactory(Manager& manager)
     static NuDBFactory const kInstance{manager};
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/backend/NullFactory.cpp b/src/libxrpl/nodestore/backend/NullFactory.cpp
index 0c76cb9938..feef3d37d0 100644
--- a/src/libxrpl/nodestore/backend/NullFactory.cpp
+++ b/src/libxrpl/nodestore/backend/NullFactory.cpp
@@ -13,7 +13,7 @@
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 class NullBackend : public Backend
 {
@@ -125,4 +125,4 @@ registerNullFactory(Manager& manager)
     static NullFactory const kInstance{manager};
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp
index 673b0daae0..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,12 +34,13 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
 class RocksDBEnv : public rocksdb::EnvWrapper
 {
@@ -231,7 +229,7 @@ public:
         {
             // LCOV_EXCL_START
             UNREACHABLE(
-                "xrpl::NodeStore::RocksDBBackend::open : database is already "
+                "xrpl::node_store::RocksDBBackend::open : database is already "
                 "open");
             JLOG(journal.error()) << "database is already open";
             return;
@@ -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);
             }
         }
     }
@@ -279,7 +277,7 @@ public:
     Status
     fetch(uint256 const& hash, std::shared_ptr* pObject) override
     {
-        XRPL_ASSERT(db, "xrpl::NodeStore::RocksDBBackend::fetch : non-null database");
+        XRPL_ASSERT(db, "xrpl::node_store::RocksDBBackend::fetch : non-null database");
         pObject->reset();
 
         Status status = Status::Ok;
@@ -339,7 +337,7 @@ public:
     {
         XRPL_ASSERT(
             db,
-            "xrpl::NodeStore::RocksDBBackend::storeBatch : non-null "
+            "xrpl::node_store::RocksDBBackend::storeBatch : non-null "
             "database");
         rocksdb::WriteBatch wb;
 
@@ -369,7 +367,7 @@ public:
     void
     forEach(std::function)> f) override
     {
-        XRPL_ASSERT(db, "xrpl::NodeStore::RocksDBBackend::forEach : non-null database");
+        XRPL_ASSERT(db, "xrpl::node_store::RocksDBBackend::forEach : non-null database");
         rocksdb::ReadOptions const options;
 
         std::unique_ptr it(db->NewIterator(options));
@@ -468,6 +466,6 @@ registerRocksDBFactory(Manager& manager)
     static RocksDBFactory const kInstance{manager};
 }
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
 
 #endif
diff --git a/src/xrpld/peerfinder/detail/Bootcache.cpp b/src/libxrpl/peerfinder/Bootcache.cpp
similarity index 69%
rename from src/xrpld/peerfinder/detail/Bootcache.cpp
rename to src/libxrpl/peerfinder/Bootcache.cpp
index a0a753530b..4f9b5fc816 100644
--- a/src/xrpld/peerfinder/detail/Bootcache.cpp
+++ b/src/libxrpl/peerfinder/Bootcache.cpp
@@ -1,22 +1,22 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
+#include 
 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
 #include 
+#include 
+#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,29 +78,30 @@ 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)
         {
-            JLOG(this->journal_.error()) << beast::Leftw(18) << "Bootcache discard " << endpoint;
+            JLOG(this->journal_.error())
+                << std::left << std::setw(18) << "Bootcache discard " << endpoint;
         }
     }));
 
     if (n > 0)
     {
-        JLOG(journal_.info()) << beast::Leftw(18) << "Bootcache loaded " << n
+        JLOG(journal_.info()) << std::left << std::setw(18) << "Bootcache loaded " << n
                               << ((n > 1) ? " addresses" : " address");
         prune();
     }
 }
 
 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)
     {
-        JLOG(journal_.trace()) << beast::Leftw(18) << "Bootcache insert " << endpoint;
+        JLOG(journal_.trace()) << std::left << std::setw(18) << "Bootcache insert " << endpoint;
         prune();
         flagForUpdate();
     }
@@ -108,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)));
 
@@ -121,7 +122,7 @@ Bootcache::insertStatic(beast::IP::Endpoint const& endpoint)
 
     if (result.second)
     {
-        JLOG(journal_.trace()) << beast::Leftw(18) << "Bootcache insert " << endpoint;
+        JLOG(journal_.trace()) << std::left << std::setw(18) << "Bootcache insert " << endpoint;
         prune();
         flagForUpdate();
     }
@@ -129,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)
@@ -143,16 +144,17 @@ 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()) << beast::Leftw(18) << "Bootcache connect " << endpoint << " with "
-                          << entry.valence() << ((entry.valence() > 1) ? " successes" : " success");
+    JLOG(journal_.info()) << std::left << std::setw(18) << "Bootcache connect " << endpoint
+                          << " with " << entry.valence()
+                          << ((entry.valence() > 1) ? " successes" : " success");
     flagForUpdate();
 }
 
 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)
@@ -166,12 +168,12 @@ 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()));
-    JLOG(journal_.debug()) << beast::Leftw(18) << "Bootcache failed " << endpoint << " with " << n
-                           << ((n > 1) ? " attempts" : " attempt");
+    JLOG(journal_.debug()) << std::left << std::setw(18) << "Bootcache failed " << endpoint
+                           << " with " << n << ((n > 1) ? " attempts" : " attempt");
     flagForUpdate();
 }
 
@@ -199,27 +201,29 @@ 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
     // erasing using a reverse iterator very well.
     //
-    for (auto iter(map_.right.end()); count-- > 0 && iter != map_.right.begin(); ++pruned)
+    for (auto iter(map_.right.end()); count > 0 && iter != map_.right.begin(); ++pruned)
     {
+        --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()) << beast::Leftw(18) << "Bootcache pruned" << endpoint
+        JLOG(journal_.trace()) << std::left << std::setw(18) << "Bootcache pruned" << endpoint
                                << " at valence " << entry.valence();
         iter = map_.right.erase(iter);
     }
 
-    JLOG(journal_.debug()) << beast::Leftw(18) << "Bootcache pruned " << pruned << " entries total";
+    JLOG(journal_.debug()) << std::left << std::setw(18) << "Bootcache pruned " << pruned
+                           << " entries total";
 }
 
 // Updates the Store with the current set of entries if needed.
@@ -240,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.
@@ -259,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
new file mode 100644
index 0000000000..60ac0ca547
--- /dev/null
+++ b/src/libxrpl/peerfinder/Config.cpp
@@ -0,0 +1,135 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::peer_finder {
+
+std::size_t
+Config::calcOutPeers() const
+{
+    return std::max(
+        ((maxPeers * tuning::kOutPercent) + 50) / 100, std::size_t(tuning::kMinOutCount));
+}
+
+void
+Config::applyTuning()
+{
+    if (ipLimit == 0)
+    {
+        // Unless a limit is explicitly set, we allow between
+        // 2 and 5 connections from non RFC-1918 "private"
+        // IP addresses.
+        ipLimit = 2;
+
+        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,
+    // unless we only have one incoming slot available.
+    ipLimit = std::max(1, std::min(ipLimit, static_cast(inPeers / 2)));
+}
+
+void
+Config::onWrite(beast::PropertyStream::Map& map) const
+{
+    map["max_peers"] = maxPeers;
+    map["out_peers"] = outPeers;
+    map["want_incoming"] = wantIncoming;
+    map["auto_connect"] = autoConnect;
+    map["port"] = listeningPort;
+    map["features"] = features;
+    map["ip_limit"] = ipLimit;
+    map["verify_endpoints"] = verifyEndpoints;
+}
+
+Config
+Config::makeConfig(
+    bool peerPrivate,
+    bool standalone,
+    PeerLimitConfig const& limits,
+    std::uint16_t port,
+    bool validationPublicKey,
+    int ipLimit,
+    bool verifyEndpoints)
+{
+    peer_finder::Config config;
+
+    if (!limits.maxPeers)
+    {
+        if (limits.inPeers && !limits.outPeers)
+            throw std::runtime_error("Both inbound and outbound peer limits must be configured");
+
+        if (limits.outPeers && !limits.inPeers)
+            throw std::runtime_error("Both inbound and outbound peer limits must be configured");
+
+        if (limits.inPeers && *limits.inPeers > 1000)
+            throw std::runtime_error("Inbound peer limit must be less than or equal to 1000");
+
+        if (limits.outPeers && (*limits.outPeers < 10 || *limits.outPeers > 1000))
+            throw std::runtime_error("Outbound peer limit must be in the range 10-1000");
+    }
+
+    config.peerPrivate = peerPrivate;
+
+    // Servers with peer privacy don't want to allow incoming connections
+    config.wantIncoming = (!config.peerPrivate) && (port != 0);
+
+    if (limits.maxPeers || (!limits.inPeers && !limits.outPeers))
+    {
+        if (limits.maxPeers && *limits.maxPeers != 0)
+            config.maxPeers = *limits.maxPeers;
+
+        config.maxPeers = std::max(config.maxPeers, tuning::kMinOutCount);
+        config.outPeers = config.calcOutPeers();
+
+        // Calculate the number of outbound peers we want. If we dont want
+        // or can't accept incoming, this will simply be equal to maxPeers.
+        if (!config.wantIncoming)
+            config.outPeers = config.maxPeers;
+
+        // Calculate the largest number of inbound connections we could
+        // take.
+        if (config.maxPeers >= config.outPeers)
+        {
+            config.inPeers = config.maxPeers - config.outPeers;
+        }
+        else
+        {
+            config.inPeers = 0;
+        }
+    }
+    else
+    {
+        config.outPeers = *limits.outPeers;
+        config.inPeers = *limits.inPeers;
+        config.maxPeers = 0;
+    }
+
+    // This will cause servers configured as validators to request that
+    // peers they connect to never report their IP address. We set this
+    // after we set the 'wantIncoming' because we want a "soft" version
+    // of peer privacy unless the operator explicitly asks for it.
+    if (validationPublicKey)
+        config.peerPrivate = true;
+
+    // if it's a private peer or we are running as standalone
+    // automatic connections would defeat the purpose.
+    config.autoConnect = !standalone && !peerPrivate;
+    config.listeningPort = port;
+    config.features = "";
+    config.ipLimit = ipLimit;
+    config.verifyEndpoints = verifyEndpoints;
+
+    // Enforce business rules
+    config.applyTuning();
+
+    return config;
+}
+
+}  // namespace xrpl::peer_finder
diff --git a/src/libxrpl/peerfinder/Endpoint.cpp b/src/libxrpl/peerfinder/Endpoint.cpp
new file mode 100644
index 0000000000..6f3e2289f9
--- /dev/null
+++ b/src/libxrpl/peerfinder/Endpoint.cpp
@@ -0,0 +1,15 @@
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+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))
+{
+}
+
+}  // namespace xrpl::peer_finder
diff --git a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp b/src/libxrpl/peerfinder/PeerfinderManager.cpp
similarity index 86%
rename from src/xrpld/peerfinder/detail/PeerfinderManager.cpp
rename to src/libxrpl/peerfinder/PeerfinderManager.cpp
index 2727a03013..0cce2389ec 100644
--- a/src/xrpld/peerfinder/detail/PeerfinderManager.cpp
+++ b/src/libxrpl/peerfinder/PeerfinderManager.cpp
@@ -1,11 +1,4 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
+#include 
 
 #include 
 #include 
@@ -13,7 +6,15 @@
 #include 
 #include 
 #include 
-#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
 #include 
 
 #include 
@@ -28,7 +29,7 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 class ManagerImp : public Manager
 {
@@ -38,10 +39,9 @@ public:
     std::optional> work_;
     clock_type& clock_;
     beast::Journal journal_;
-    StoreSqdb store_;
+    Store& store_;
     Checker checker_;
     Logic logic_;
-    BasicConfig const& config_;
     // NOLINTEND(readability-identifier-naming)
 
     //--------------------------------------------------------------------------
@@ -50,16 +50,15 @@ public:
         boost::asio::io_context& ioContext,
         clock_type& clock,
         beast::Journal journal,
-        BasicConfig const& config,
+        Store& store,
         beast::insight::Collector::ptr const& collector)
         : io_context_(ioContext)
         , work_(std::in_place, boost::asio::make_work_guard(io_context_))
         , clock_(clock)
         , journal_(journal)
-        , store_(journal)
+        , store_(store)
         , checker_(io_context_)
         , logic_(clock, store_, checker_, journal)
-        , config_(config)
         , stats_([this] { collectMetrics(); }, collector)
     {
     }
@@ -99,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);
     }
@@ -120,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);
     }
@@ -164,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));
@@ -185,7 +184,7 @@ public:
         return logic_.redirect(impl);
     }
 
-    std::vector
+    std::vector
     autoconnect() override
     {
         return logic_.autoconnect();
@@ -206,7 +205,6 @@ public:
     void
     start() override
     {
-        store_.open(config_);
         logic_.load();
     }
 
@@ -261,10 +259,10 @@ makeManager(
     boost::asio::io_context& ioContext,
     clock_type& clock,
     beast::Journal journal,
-    BasicConfig const& config,
+    Store& store,
     beast::insight::Collector::ptr const& collector)
 {
-    return std::make_unique(ioContext, clock, journal, config, collector);
+    return std::make_unique(ioContext, clock, journal, store, collector);
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/xrpld/peerfinder/detail/SlotImp.cpp b/src/libxrpl/peerfinder/SlotImp.cpp
similarity index 70%
rename from src/xrpld/peerfinder/detail/SlotImp.cpp
rename to src/libxrpl/peerfinder/SlotImp.cpp
index a54ddb56e7..5209bd51ab 100644
--- a/src/xrpld/peerfinder/detail/SlotImp.cpp
+++ b/src/libxrpl/peerfinder/SlotImp.cpp
@@ -1,21 +1,19 @@
-#include 
+#include 
 
-#include 
-#include 
-#include 
-
-#include 
 #include 
 #include 
+#include 
+#include 
+#include 
 
 #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)
@@ -32,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)
@@ -51,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;
@@ -85,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;
@@ -102,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)
@@ -117,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())
@@ -131,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/xrpld/peerfinder/detail/SourceStrings.cpp b/src/libxrpl/peerfinder/SourceStrings.cpp
similarity index 79%
rename from src/xrpld/peerfinder/detail/SourceStrings.cpp
rename to src/libxrpl/peerfinder/SourceStrings.cpp
index 7b28db4306..ca6ff07cba 100644
--- a/src/xrpld/peerfinder/detail/SourceStrings.cpp
+++ b/src/libxrpl/peerfinder/SourceStrings.cpp
@@ -1,15 +1,14 @@
-#include 
-
-#include 
+#include 
 
 #include 
 #include 
+#include 
 
 #include 
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 class SourceStringsImp : public SourceStrings
 {
@@ -34,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);
         }
@@ -55,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..ff4e5aa0ee 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.4.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..ecd4832928 100644
--- a/src/libxrpl/protocol/ConfidentialTransfer.cpp
+++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -124,7 +125,12 @@ std::optional
 makeEcPair(Slice const& buffer)
 {
     if (buffer.length() != 2 * kEcCiphertextComponentLength)
-        return std::nullopt;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::makeEcPair : callers must pre-validate ciphertext length");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
+    }
 
     auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) {
         return secp256k1_ec_pubkey_parse(secp256k1Context(), &out, slice.data(), slice.length());
@@ -266,7 +272,13 @@ std::optional
 encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId)
 {
     if (pubKeySlice.size() != kEcPubKeyLength)
-        return std::nullopt;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : callers must pre-validate public key length");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
+    }
 
     EcPair pair{};
     secp256k1_pubkey pubKey;
@@ -274,14 +286,24 @@ encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, M
             secp256k1Context(), &pubKey, pubKeySlice.data(), kEcPubKeyLength);
         res != 1)
     {
-        return std::nullopt;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : public key read from the ledger must already be "
+            "valid");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
     }
 
     if (auto res = generate_canonical_encrypted_zero(
             secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data());
         res != 1)
     {
-        return std::nullopt;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::encryptCanonicalZeroAmount : canonical zero generation cannot fail for a "
+            "valid public key");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
     }
 
     return serializeEcPair(pair);
@@ -301,7 +323,11 @@ verifyRevealedAmount(
         issuer.publicKey.size() != kEcPubKeyLength ||
         issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyRevealedAmount : callers must pre-validate holder/issuer field lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     auto const holderP = toParticipant(holder);
@@ -313,7 +339,11 @@ verifyRevealedAmount(
         if (auditor->publicKey.size() != kEcPubKeyLength ||
             auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
         {
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::verifyRevealedAmount : callers must pre-validate auditor field lengths");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
         }
         auditorP = toParticipant(*auditor);
         auditorPtr = &auditorP;
@@ -337,7 +367,12 @@ checkEncryptedAmountFormat(STObject const& object)
     if (!object.isFieldPresent(sfHolderEncryptedAmount) ||
         !object.isFieldPresent(sfIssuerEncryptedAmount))
     {
-        return temMALFORMED;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::checkEncryptedAmountFormat : callers already enforce that these fields are "
+            "present");
+        return temMALFORMED;
+        // LCOV_EXCL_STOP
     }
 
     if (object[sfHolderEncryptedAmount].length() != kEcGamalEncryptedTotalLength ||
@@ -366,7 +401,12 @@ TER
 verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash)
 {
     if (proofSlice.size() != kEcSchnorrProofLength || pubKeySlice.size() != kEcPubKeyLength)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::verifySchnorrProof : callers must pre-validate proof/public key length");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0)
         return tecBAD_PROOF;
@@ -385,7 +425,12 @@ verifyClawbackProof(
     if (ciphertext.size() != kEcGamalEncryptedTotalLength ||
         pubKeySlice.size() != kEcPubKeyLength || proof.size() != kEcClawbackProofLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyClawbackProof : callers must pre-validate ciphertext/public "
+            "key/proof length");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     if (mpt_verify_clawback_proof(
@@ -420,7 +465,12 @@ verifySendProof(
         amountCommitment.size() != kEcPedersenCommitmentLength ||
         balanceCommitment.size() != kEcPedersenCommitmentLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifySendProof : callers must pre-validate proof/participant/commitment "
+            "lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     std::vector participants;
@@ -433,12 +483,22 @@ verifySendProof(
         if (auditor->publicKey.size() != kEcPubKeyLength ||
             auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength)
         {
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::verifySendProof : callers must pre-validate auditor field lengths");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
         }
         participants.push_back(toParticipant(*auditor));
     }
     if (participants.size() != recipientCount)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifySendProof : participant count must match the requested recipient "
+            "count");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     if (mpt_verify_send_proof(
             proof.data(),
@@ -468,7 +528,12 @@ verifyConvertBackProof(
         spendingBalance.size() != kEcGamalEncryptedTotalLength ||
         balanceCommitment.size() != kEcPedersenCommitmentLength)
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyConvertBackProof : callers must pre-validate proof/public "
+            "key/balance/commitment lengths");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     if (mpt_verify_convert_back_proof(
diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp
index 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..66fdfd453b 100644
--- a/src/libxrpl/protocol/Indexes.cpp
+++ b/src/libxrpl/protocol/Indexes.cpp
@@ -180,26 +180,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 +273,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 +307,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 +335,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 +376,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 +385,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 +420,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 +494,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 +508,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 +528,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 +558,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
index 4f0a2d5071..fd44ae1f33 100644
--- a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp
+++ b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp
@@ -9,7 +9,7 @@
 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 void
 insertNFTSyntheticInJson(
@@ -21,4 +21,4 @@ insertNFTSyntheticInJson(
     insertNFTokenOfferID(response[jss::meta], transaction, transactionMeta);
 }
 
-}  // namespace xrpl::RPC
+}  // 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 4b3ace2be3..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);
 
@@ -899,6 +911,10 @@ STObject::add(Serializer& s, WhichFields whichFields) const
         XRPL_ASSERT(
             (sType != STI_OBJECT) || (field->getFName().fieldType == STI_OBJECT),
             "xrpl::STObject::add : valid field type");
+        XRPL_ASSERT(
+            getStyle(field->getFName()) != SoeDefault || !field->isDefault(),
+            "xrpl::STObject::add : non-default value");
+
         field->addFieldID(s);
         field->add(s);
         if (sType == STI_ARRAY || sType == STI_OBJECT)
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..658aaa65dd 100644
--- a/src/libxrpl/protocol/STPathSet.cpp
+++ b/src/libxrpl/protocol/STPathSet.cpp
@@ -51,6 +51,12 @@ STPathElement::getHash(STPathElement const& element)
     return (hashAccount ^ hashCurrency ^ hashIssuer);
 }
 
+[[nodiscard]] size_t
+STPathElement::getHash() const
+{
+    return STPathElement::getHash(*this);
+}
+
 STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name)
 {
     std::vector path;
@@ -126,21 +132,15 @@ 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);
+    STPath combined = base;
+    combined.pushBack(tail);
 
-    auto it = value_.rbegin();
-
-    STPath& newPath = *it;
-    newPath.pushBack(tail);
-
-    while (++it != value_.rend())
+    if (!seenHashes_.insert(combined).second)
     {
-        if (*it == newPath)
-        {
-            value_.pop_back();
-            return false;
-        }
+        return false;
     }
+
+    value_.push_back(std::move(combined));
     return true;
 }
 
diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp
index 17d7617590..ce672b515d 100644
--- a/src/libxrpl/protocol/STTx.cpp
+++ b/src/libxrpl/protocol/STTx.cpp
@@ -33,13 +33,13 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -200,22 +200,16 @@ 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};
-}
-
-std::uint32_t
-STTx::getSeqValue() const
-{
-    return getSeqProxy().value();
+    return SeqProxy::rawTicket(*ticketSeq);
 }
 
 void
@@ -405,16 +399,21 @@ STTx::getMetaSQL(
     TxnSql status,
     std::string const& escapedMetaData) const
 {
-    static boost::format const kBfTrans("('%s', '%s', '%s', '%d', '%d', '%c', %s, %s)");
     std::string rTxn = sqlBlobLiteral(rawTxn.peekData());
 
     auto format = TxFormats::getInstance().findByType(txType_);
     XRPL_ASSERT(format, "xrpl::STTx::getMetaSQL : non-null type format");
 
-    return str(
-        boost::format(kBfTrans) % to_string(getTransactionID()) % format->getName() %
-        toBase58(getAccountID(sfAccount)) % getFieldU32(sfSequence) % inLedger %
-        safeCast(status) % rTxn % escapedMetaData);
+    return std::format(
+        "('{}', '{}', '{}', '{}', '{}', '{}', {}, {})",
+        to_string(getTransactionID()),
+        format->getName(),
+        toBase58(getAccountID(sfAccount)),
+        getFieldU32(sfSequence),
+        inLedger,
+        safeCast(status),
+        rTxn,
+        escapedMetaData);
 }
 
 static std::expected
@@ -459,7 +458,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());
 }
@@ -553,7 +552,7 @@ 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,
@@ -812,16 +811,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/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/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..3fa8d66be0 100644
--- a/src/libxrpl/shamap/SHAMap.cpp
+++ b/src/libxrpl/shamap/SHAMap.cpp
@@ -116,8 +116,7 @@ SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNode
         stack.pop();
         XRPL_ASSERT(node, "xrpl::SHAMap::dirtyUp : non-null node");
 
-        int const branch = selectBranch(nodeID, target);
-        XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::dirtyUp : valid branch");
+        auto const branch = selectBranch(nodeID, target);
 
         node = unshareNode(std::move(node), nodeID);
         node->setChild(branch, std::move(child));
@@ -278,7 +277,7 @@ SHAMap::fetchNode(SHAMapHash const& hash) const
 }
 
 SHAMapTreeNode*
-SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const
+SHAMap::descendThrow(SHAMapInnerNode* parent, unsigned int branch) const
 {
     SHAMapTreeNode* ret = descend(parent, branch);  // NOLINT(misc-const-correctness)
 
@@ -289,7 +288,7 @@ SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const
 }
 
 SHAMapTreeNodePtr
-SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const
+SHAMap::descendThrow(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr ret = descend(parent, branch);
 
@@ -300,7 +299,7 @@ SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const
 }
 
 SHAMapTreeNode*
-SHAMap::descend(SHAMapInnerNode* parent, int branch) const
+SHAMap::descend(SHAMapInnerNode* parent, unsigned int branch) const
 {
     SHAMapTreeNode* ret = parent->getChildPointer(branch);  // NOLINT(misc-const-correctness)
     if ((ret != nullptr) || !backed_)
@@ -315,7 +314,7 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const
 }
 
 SHAMapTreeNodePtr
-SHAMap::descend(SHAMapInnerNode& parent, int branch) const
+SHAMap::descend(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr node = parent.getChild(branch);
     if (node || !backed_)
@@ -332,7 +331,7 @@ SHAMap::descend(SHAMapInnerNode& parent, int branch) const
 // Gets the node that would be hooked to this branch,
 // but doesn't hook it up.
 SHAMapTreeNodePtr
-SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const
+SHAMap::descendNoStore(SHAMapInnerNode& parent, unsigned int branch) const
 {
     SHAMapTreeNodePtr ret = parent.getChild(branch);
     if (!ret && backed_)
@@ -344,12 +343,11 @@ std::pair
 SHAMap::descend(
     SHAMapInnerNode* parent,
     SHAMapNodeID const& parentID,
-    int branch,
+    unsigned int branch,
     SHAMapSyncFilter const* filter) const
 {
     XRPL_ASSERT(parent->isInner(), "xrpl::SHAMap::descend : valid parent input");
-    XRPL_ASSERT(
-        (branch >= 0) && (branch < kBranchFactor), "xrpl::SHAMap::descend : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMap::descend : valid branch input");
     XRPL_ASSERT(
         !parent->isEmptyBranch(branch), "xrpl::SHAMap::descend : parent branch is non-empty");
 
@@ -373,7 +371,7 @@ SHAMap::descend(
 SHAMapTreeNode*
 SHAMap::descendAsync(
     SHAMapInnerNode* parent,
-    int branch,
+    unsigned int branch,
     SHAMapSyncFilter const* filter,
     bool& pending,
     descendCallback&& callback) const
@@ -433,10 +431,9 @@ SHAMapLeafNode*
 SHAMap::belowHelper(
     SHAMapTreeNodePtr node,
     SharedPtrNodeStack& stack,
-    int branch,
-    std::tuple, std::function> const& loopParams) const
+    unsigned int branch,
+    BelowDirection direction) const
 {
-    auto& [init, cmp, incr] = loopParams;
     if (node->isLeaf())
     {
         auto n = intr_ptr::staticPointerCast(node);
@@ -452,11 +449,16 @@ SHAMap::belowHelper(
     {
         stack.emplace(inner, stack.top().second.getChildNodeID(branch));
     }
-    for (int i = init; cmp(i);)
+    // `scanned` counts how many branches of `inner` we have examined; the branch we look at is
+    // derived from it, so no index ever goes out of range.
+    for (auto scanned = 0u; scanned < kBranchFactor;)
     {
-        if (!inner->isEmptyBranch(i))
+        auto const childBranch =
+            (direction == BelowDirection::Last) ? (kBranchFactor - 1u - scanned) : scanned;
+
+        if (!inner->isEmptyBranch(childBranch))
         {
-            node.adopt(descendThrow(inner.get(), i));
+            node.adopt(descendThrow(inner.get(), childBranch));
             XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack");
             if (node->isLeaf())
             {
@@ -466,32 +468,24 @@ SHAMap::belowHelper(
             }
             inner = intr_ptr::staticPointerCast(node);
             stack.emplace(inner, stack.top().second.getChildNodeID(branch));
-            i = init;  // descend and reset loop
+            scanned = 0u;  // descend and restart the scan on the new node
         }
         else
         {
-            incr(i);  // scan next branch
+            ++scanned;  // scan next branch
         }
     }
     return nullptr;
 }
 SHAMapLeafNode*
-SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
+SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const
 {
-    auto init = kBranchFactor - 1;
-    auto cmp = [](int i) { return i >= 0; };
-    auto incr = [](int& i) { --i; };
-
-    return belowHelper(node, stack, branch, {init, cmp, incr});
+    return belowHelper(node, stack, branch, BelowDirection::Last);
 }
 SHAMapLeafNode*
-SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const
+SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const
 {
-    auto init = 0;
-    auto cmp = [](int i) { return i <= kBranchFactor; };
-    auto incr = [](int& i) { ++i; };
-
-    return belowHelper(node, stack, branch, {init, cmp, incr});
+    return belowHelper(node, stack, branch, BelowDirection::First);
 }
 static boost::intrusive_ptr const kNoItem;
 
@@ -504,7 +498,7 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const
     {
         SHAMapTreeNode* nextNode = nullptr;
         auto inner = safeDowncast(node);
-        for (int i = 0; i < kBranchFactor; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (!inner->isEmptyBranch(i))
             {
@@ -650,8 +644,9 @@ SHAMap::lowerBound(uint256 const& id) const
         else
         {
             auto inner = intr_ptr::staticPointerCast(node);
-            for (int branch = selectBranch(nodeID, id) - 1; branch >= 0; --branch)
+            for (auto branch = selectBranch(nodeID, id); branch > 0u;)
             {
+                --branch;
                 if (!inner->isEmptyBranch(branch))
                 {
                     node = descendThrow(*inner, branch);
@@ -715,7 +710,7 @@ SHAMap::delItem(uint256 const& id)
         {
             // we may have made this a node with 1 or 0 children
             // And, if so, we need to remove this branch
-            int const bc = node->getBranchCount();
+            auto const bc = node->getBranchCount();
             if (bc == 0)
             {
                 // no children below this branch
@@ -730,7 +725,7 @@ SHAMap::delItem(uint256 const& id)
 
                 if (item)
                 {
-                    for (int i = 0; i < kBranchFactor; ++i)
+                    for (auto i = 0u; i < kBranchFactor; ++i)
                     {
                         if (!node->isEmptyBranch(i))
                         {
@@ -786,7 +781,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr
     {
         // easy case, we end on an inner node
         auto inner = intr_ptr::staticPointerCast(node);
-        int const branch = selectBranch(nodeID, tag);
+        auto const branch = selectBranch(nodeID, tag);
         XRPL_ASSERT(
             inner->isEmptyBranch(branch), "xrpl::SHAMap::addGiveItem : inner branch is empty");
         inner->setChild(branch, makeTypedLeaf(type, std::move(item), cowid_));
@@ -802,7 +797,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr
 
         node = intr_ptr::makeShared(node->cowid());
 
-        unsigned int b1 = 0, b2 = 0;
+        auto b1 = 0u, b2 = 0u;
 
         while ((b1 = selectBranch(nodeID, tag)) == (b2 = selectBranch(nodeID, otherItem->key())))
         {
@@ -1012,12 +1007,12 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t)
 
     // Stack of {parent,index,child} pointers representing
     // inner nodes we are in the process of flushing
-    using StackEntry = std::pair, int>;
+    using StackEntry = std::pair, unsigned int>;
     std::stack> stack;
 
     node = preFlushNode(std::move(node));
 
-    int pos = 0;
+    auto pos = 0u;
 
     // We can't flush an inner node until we flush its children
     while (true)
@@ -1032,7 +1027,7 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t)
             {
                 // No need to do I/O. If the node isn't linked,
                 // it can't need to be flushed
-                int const branch = pos;
+                auto const branch = pos;
                 auto child = node->getChild(pos++);
 
                 if (child && (child->cowid() != 0))
@@ -1126,7 +1121,7 @@ SHAMap::dump(bool hash) const
         if (node->isInner())
         {
             auto inner = safeDowncast(node);
-            for (int i = 0; i < kBranchFactor; ++i)
+            for (auto i = 0u; i < kBranchFactor; ++i)
             {
                 if (!inner->isEmptyBranch(i))
                 {
diff --git a/src/libxrpl/shamap/SHAMapDelta.cpp b/src/libxrpl/shamap/SHAMapDelta.cpp
index 8336ce5481..1306fe6990 100644
--- a/src/libxrpl/shamap/SHAMapDelta.cpp
+++ b/src/libxrpl/shamap/SHAMapDelta.cpp
@@ -54,7 +54,7 @@ SHAMap::walkBranch(
         {
             // This is an inner node, add all non-empty branches
             auto inner = safeDowncast(node);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
             {
                 if (!inner->isEmptyBranch(i))
                     nodeStack.push({descendThrow(inner, i)});
@@ -205,7 +205,7 @@ SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const
         {
             auto ours = safeDowncast(ourNode);
             auto other = safeDowncast(otherNode);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
             {
                 if (ours->getChildHash(i) != other->getChildHash(i))
                 {
@@ -257,7 +257,7 @@ SHAMap::walkMap(std::vector& missingNodes, int maxMissing) co
         intr_ptr::SharedPtr const node = std::move(nodeStack.top());
         nodeStack.pop();
 
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
         {
             if (!node->isEmptyBranch(i))
             {
@@ -286,27 +286,29 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
         return false;
 
     using StackEntry = intr_ptr::SharedPtr;
-    std::array topChildren;
+    std::array topChildren;
     {
         auto const& innerRoot = intr_ptr::staticPointerCast(root_);
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
         {
             if (!innerRoot->isEmptyBranch(i))
                 topChildren[i] = descendNoStore(*innerRoot, i);
         }
     }
     std::vector workers;
-    workers.reserve(16);
+    workers.reserve(SHAMapInnerNode::kBranchFactor);
     std::vector exceptions;
-    exceptions.reserve(16);
+    exceptions.reserve(SHAMapInnerNode::kBranchFactor);
 
-    std::array>, 16> nodeStacks;
+    std::array>, SHAMapInnerNode::kBranchFactor>
+        nodeStacks;
 
     // This mutex is used inside the worker threads to protect `missingNodes`
     // and `maxMissing` from race conditions
     std::mutex m;
 
-    for (int rootChildIndex = 0; rootChildIndex < 16; ++rootChildIndex)
+    for (auto rootChildIndex = 0u; rootChildIndex < SHAMapInnerNode::kBranchFactor;
+         ++rootChildIndex)
     {
         auto const& child = topChildren[rootChildIndex];
         if (!child || !child->isInner())
@@ -327,7 +329,7 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis
                         XRPL_ASSERT(node, "xrpl::SHAMap::walkMapParallel : non-null node");
                         nodeStack.pop();
 
-                        for (int i = 0; i < 16; ++i)
+                        for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i)
                         {
                             if (node->isEmptyBranch(i))
                                 continue;
diff --git a/src/libxrpl/shamap/SHAMapInnerNode.cpp b/src/libxrpl/shamap/SHAMapInnerNode.cpp
index 74a0e4515f..bdd89388b2 100644
--- a/src/libxrpl/shamap/SHAMapInnerNode.cpp
+++ b/src/libxrpl/shamap/SHAMapInnerNode.cpp
@@ -63,8 +63,8 @@ SHAMapInnerNode::resizeChildArrays(std::uint8_t toAllocate)
     hashesAndChildren_ = TaggedPointer(std::move(hashesAndChildren_), isBranch_, toAllocate);
 }
 
-std::optional
-SHAMapInnerNode::getChildIndex(int i) const
+std::optional
+SHAMapInnerNode::getChildIndex(unsigned int i) const
 {
     return hashesAndChildren_.getChildIndex(isBranch_, i);
 }
@@ -89,7 +89,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
 
     if (thisIsSparse)
     {
-        int cloneChildIndex = 0;
+        auto cloneChildIndex = 0u;
         iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) {
             cloneHashes[cloneChildIndex++] = thisHashes[indexNum];
         });
@@ -105,7 +105,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const
 
     if (thisIsSparse)
     {
-        int cloneChildIndex = 0;
+        auto cloneChildIndex = 0u;
         iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) {
             cloneChildren[cloneChildIndex++] = thisChildren[indexNum];
         });
@@ -133,12 +133,12 @@ SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashVali
 
     auto hashes = ret->hashesAndChildren_.getHashes();
 
-    for (int i = 0; i < kBranchFactor; ++i)
+    for (auto i = 0u; i < kBranchFactor; ++i)
     {
         hashes[i].asUInt256() = si.getBitString<256>();
 
         if (hashes[i].isNonZero())
-            ret->isBranch_ |= (1 << i);
+            ret->isBranch_ |= (1u << i);
     }
 
     ret->resizeChildArrays(ret->getBranchCount());
@@ -182,7 +182,7 @@ SHAMapInnerNode::makeCompressedInner(Slice data)
         hashes[pos].asUInt256() = hash;
 
         if (hashes[pos].isNonZero())
-            ret->isBranch_ |= (1 << pos);
+            ret->isBranch_ |= (1u << pos);
     }
 
     ret->resizeChildArrays(ret->getBranchCount());
@@ -267,20 +267,19 @@ SHAMapInnerNode::getString(SHAMapNodeID const& id) const
 
 // We are modifying an inner node
 void
-SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
+SHAMapInnerNode::setChild(unsigned int branch, SHAMapTreeNodePtr child)
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::setChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::setChild : valid branch input");
     XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::setChild : nonzero cowid");
     XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::setChild : valid child input");
 
     auto const dstIsBranch = [&] {
         if (child)
         {
-            return isBranch_ | (1u << m);
+            return isBranch_ | (1u << branch);
         }
 
-        return isBranch_ & ~(1u << m);
+        return isBranch_ & ~(1u << branch);
     }();
 
     auto const dstToAllocate = popcnt16(dstIsBranch);
@@ -293,8 +292,8 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
 
     if (child)
     {
-        auto const childIndex =
-            *getChildIndex(m);  // NOLINT(bugprone-unchecked-optional-access) isBranch_ set above
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isBranch_ set above
+        auto const childIndex = *getChildIndex(branch);
         auto [_, hashes, children] = hashesAndChildren_.getHashesAndChildren();
         hashes[childIndex].zero();
         children[childIndex] = std::move(child);
@@ -309,25 +308,24 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child)
 
 // finished modifying, now make shareable
 void
-SHAMapInnerNode::shareChild(int m, SHAMapTreeNodePtr const& child)
+SHAMapInnerNode::shareChild(unsigned int branch, SHAMapTreeNodePtr const& child)
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::shareChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::shareChild : valid branch input");
     XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::shareChild : nonzero cowid");
     XRPL_ASSERT(child, "xrpl::SHAMapInnerNode::shareChild : non-null child input");
     XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::shareChild : valid child input");
 
-    XRPL_ASSERT(!isEmptyBranch(m), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input");
+    XRPL_ASSERT(
+        !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input");
     // NOLINTNEXTLINE(bugprone-unchecked-optional-access) assert above
-    hashesAndChildren_.getChildren()[*getChildIndex(m)] = child;
+    hashesAndChildren_.getChildren()[*getChildIndex(branch)] = child;
 }
 
 SHAMapTreeNode*
-SHAMapInnerNode::getChildPointer(int branch)
+SHAMapInnerNode::getChildPointer(unsigned int branch)
 {
     XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::getChildPointer : valid branch input");
+        branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildPointer : valid branch input");
     XRPL_ASSERT(
         !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChildPointer : non-empty branch input");
 
@@ -340,11 +338,9 @@ SHAMapInnerNode::getChildPointer(int branch)
 }
 
 SHAMapTreeNodePtr
-SHAMapInnerNode::getChild(int branch)
+SHAMapInnerNode::getChild(unsigned int branch)
 {
-    XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::getChild : valid branch input");
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChild : valid branch input");
     XRPL_ASSERT(!isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChild : non-empty branch input");
 
     auto const index =
@@ -356,23 +352,20 @@ SHAMapInnerNode::getChild(int branch)
 }
 
 SHAMapHash const&
-SHAMapInnerNode::getChildHash(int m) const
+SHAMapInnerNode::getChildHash(unsigned int branch) const
 {
-    XRPL_ASSERT(
-        (m >= 0) && (m < kBranchFactor),
-        "xrpl::SHAMapInnerNode::getChildHash : valid branch input");
-    if (auto const i = getChildIndex(m))
+    XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildHash : valid branch input");
+    if (auto const i = getChildIndex(branch))
         return hashesAndChildren_.getHashes()[*i];
 
     return kZeroShaMapHash;
 }
 
 SHAMapTreeNodePtr
-SHAMapInnerNode::canonicalizeChild(int branch, SHAMapTreeNodePtr node)
+SHAMapInnerNode::canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node)
 {
     XRPL_ASSERT(
-        branch >= 0 && branch < kBranchFactor,
-        "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input");
+        branch < kBranchFactor, "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input");
     XRPL_ASSERT(node != nullptr, "xrpl::SHAMapInnerNode::canonicalizeChild : valid node input");
     XRPL_ASSERT(
         !isEmptyBranch(branch),
@@ -410,7 +403,7 @@ SHAMapInnerNode::invariants(bool isRoot) const
     if (numAllocated != kBranchFactor)
     {
         auto const branchCount = getBranchCount();
-        for (int i = 0; i < branchCount; ++i)
+        for (auto i = 0u; i < branchCount; ++i)
         {
             XRPL_ASSERT(
                 hashes[i].isNonZero(),
@@ -422,12 +415,12 @@ SHAMapInnerNode::invariants(bool isRoot) const
     }
     else
     {
-        for (int i = 0; i < kBranchFactor; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (hashes[i].isNonZero())
             {
                 XRPL_ASSERT(
-                    (isBranch_ & (1 << i)),
+                    (isBranch_ & (1u << i)),
                     "xrpl::SHAMapInnerNode::invariants : valid branch when "
                     "nonzero hash");
                 if (children[i] != nullptr)
@@ -437,7 +430,7 @@ SHAMapInnerNode::invariants(bool isRoot) const
             else
             {
                 XRPL_ASSERT(
-                    (isBranch_ & (1 << i)) == 0,
+                    (isBranch_ & (1u << i)) == 0u,
                     "xrpl::SHAMapInnerNode::invariants : valid branch when "
                     "zero hash");
             }
diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp
index 16aaafe709..8fd7afe8fc 100644
--- a/src/libxrpl/shamap/SHAMapNodeID.cpp
+++ b/src/libxrpl/shamap/SHAMapNodeID.cpp
@@ -16,7 +16,7 @@ namespace xrpl {
 static uint256 const&
 depthMask(unsigned int depth)
 {
-    static constexpr auto kMaskSize = 65;
+    static constexpr auto kMaskSize = SHAMap::kLeafDepth + 1;
 
     struct MasksT
     {
@@ -25,7 +25,7 @@ depthMask(unsigned int depth)
         MasksT()
         {
             uint256 selector;
-            for (int i = 0; i < kMaskSize - 1; i += 2)
+            for (auto i = 0u; i < kMaskSize - 1; i += 2)
             {
                 entry[i] = selector;
                 *(selector.begin() + (i / 2)) = 0xF0;
@@ -46,8 +46,7 @@ SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash),
     XRPL_ASSERT(
         depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::SHAMapNodeID : maximum depth input");
     XRPL_ASSERT(
-        id_ == (id_ & depthMask(depth)),
-        "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match");
+        isPrefixOf(id_), "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match");
 }
 
 std::string
@@ -60,10 +59,10 @@ SHAMapNodeID::getRawString() const
 }
 
 SHAMapNodeID
-SHAMapNodeID::getChildNodeID(unsigned int m) const
+SHAMapNodeID::getChildNodeID(unsigned int branch) const
 {
     XRPL_ASSERT(
-        m < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input");
+        branch < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input");
 
     // A SHAMap has exactly 65 levels, so nodes must not exceed that
     // depth; if they do, this breaks the invariant of never allowing
@@ -79,14 +78,20 @@ SHAMapNodeID::getChildNodeID(unsigned int m) const
     if (depth_ >= SHAMap::kLeafDepth)
         Throw("Request for child node ID of " + to_string(*this));
 
-    if (id_ != (id_ & depthMask(depth_)))
+    if (!isPrefixOf(id_))
         Throw("Incorrect mask for " + to_string(*this));
 
     SHAMapNodeID node{depth_ + 1, id_};
-    node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? m : (m << 4);
+    node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? branch : (branch << 4);
     return node;
 }
 
+bool
+SHAMapNodeID::isPrefixOf(uint256 const& key) const
+{
+    return (key & depthMask(depth_)) == id_;
+}
+
 [[nodiscard]] std::optional
 deserializeSHAMapNodeID(void const* data, std::size_t size)
 {
@@ -127,9 +132,9 @@ selectBranch(SHAMapNodeID const& id, uint256 const& hash)
 }
 
 SHAMapNodeID
-SHAMapNodeID::createID(int depth, uint256 const& key)
+SHAMapNodeID::createID(unsigned int depth, uint256 const& key)
 {
-    XRPL_ASSERT((depth >= 0) && (depth < 65), "xrpl::SHAMapNodeID::createID : valid branch input");
+    XRPL_ASSERT(depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth");
     return SHAMapNodeID(depth, key & depthMask(depth));
 }
 
diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp
index cc30426f9d..a12e524a5f 100644
--- a/src/libxrpl/shamap/SHAMapSync.cpp
+++ b/src/libxrpl/shamap/SHAMapSync.cpp
@@ -54,15 +54,15 @@ SHAMap::visitNodes(std::function const& function) const
     if (!root_->isInner())
         return;
 
-    using StackEntry = std::pair>;
+    using StackEntry = std::pair>;
     std::stack> stack;
 
     auto node = intr_ptr::staticPointerCast(root_);
-    int pos = 0;
+    auto pos = 0u;
 
     while (true)
     {
-        while (pos < 16)
+        while (pos < kBranchFactor)
         {
             if (!node->isEmptyBranch(pos))
             {
@@ -77,10 +77,10 @@ SHAMap::visitNodes(std::function const& function) const
                 else
                 {
                     // If there are no more children, don't push this node
-                    while ((pos != 15) && (node->isEmptyBranch(pos + 1)))
+                    while ((pos != kBranchFactor - 1u) && (node->isEmptyBranch(pos + 1)))
                         ++pos;
 
-                    if (pos != 15)
+                    if (pos != kBranchFactor - 1u)
                     {
                         // save next position to resume at
                         stack.emplace(pos + 1, std::move(node));
@@ -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;
     }
@@ -144,23 +144,20 @@ SHAMap::visitDifferences(
             return;
 
         // 2) push non-matching child inner nodes
-        for (int i = 0; i < 16; ++i)
+        for (auto i = 0u; i < kBranchFactor; ++i)
         {
             if (!node->isEmptyBranch(i))
             {
                 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 +176,13 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se)
 {
     SHAMapInnerNode*& node = std::get<0>(se);
     SHAMapNodeID& nodeID = std::get<1>(se);
-    int& firstChild = std::get<2>(se);
-    int& currentChild = std::get<3>(se);
+    auto& firstChild = std::get<2>(se);
+    auto& currentChild = std::get<3>(se);
     bool& fullBelow = std::get<4>(se);
 
-    while (currentChild < 16)
+    while (currentChild < kBranchFactor)
     {
-        int const branch = (firstChild + currentChild++) % 16;
+        auto const branch = (firstChild + currentChild++) % kBranchFactor;
         if (node->isEmptyBranch(branch))
             continue;
 
@@ -265,7 +262,7 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn)
     int complete = 0;
     while (complete != mn.deferred)
     {
-        std::tuple deferredNode;
+        MissingNodes::DeferredNode deferredNode;
         {
             std::unique_lock lock{mn.deferLock};
 
@@ -414,7 +411,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 +423,7 @@ SHAMap::getNodeFat(
 
     while ((node != nullptr) && node->isInner() && (nodeID.getDepth() < wanted.getDepth()))
     {
-        int const branch = selectBranch(nodeID, wanted.getNodeID());
+        auto const branch = selectBranch(nodeID, wanted.getNodeID());
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;
@@ -447,7 +444,7 @@ SHAMap::getNodeFat(
         return false;
     }
 
-    std::stack> stack;
+    std::stack> stack;
     stack.emplace(node, nodeID, depth);
 
     Serializer s(8192);
@@ -460,19 +457,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 +487,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 +505,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 +548,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 +572,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 +595,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 +684,7 @@ SHAMap::deepCompare(SHAMap& other) const
                 return false;
             auto nodeInner = safeDowncast(node);
             auto otherInner = safeDowncast(otherNode);
-            for (int i = 0; i < 16; ++i)
+            for (auto i = 0u; i < kBranchFactor; ++i)
             {
                 if (nodeInner->isEmptyBranch(i))
                 {
@@ -732,7 +723,7 @@ SHAMap::hasInnerNode(SHAMapNodeID const& targetNodeID, SHAMapHash const& targetN
 
     while (node->isInner() && (nodeID.getDepth() < targetNodeID.getDepth()))
     {
-        int const branch = selectBranch(nodeID, targetNodeID.getNodeID());
+        auto const branch = selectBranch(nodeID, targetNodeID.getNodeID());
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;
@@ -758,7 +749,20 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const
 
     do
     {
-        int const branch = selectBranch(nodeID, tag);
+        // An inner node is only reachable here at a depth below kLeafDepth in a well-formed map,
+        // where the loop always finds a leaf first. A malformed map could still have an inner
+        // node claiming kLeafDepth, and getChildNodeID below throws in that case: reject rather
+        // than let the throw escape uncaught. Not reachable through any public entry point,
+        // since addKnownNode already marks such a map invalid, so no test can cover this.
+        if (nodeID.getDepth() >= kLeafDepth)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::SHAMap::hasLeafNode : inner node at leaf depth");
+            return false;
+            // LCOV_EXCL_STOP
+        }
+
+        auto const branch = selectBranch(nodeID, tag);
         auto inner = safeDowncast(node);
         if (inner->isEmptyBranch(branch))
             return false;  // Dead end, node must not be here
@@ -810,7 +814,7 @@ SHAMap::getProofPath(uint256 const& key) const
 bool
 SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector const& path)
 {
-    if (path.empty() || path.size() > 65)
+    if (path.empty() || path.size() > kLeafDepth + 1u)
         return false;
 
     SHAMapHash hash{rootHash};
@@ -826,10 +830,10 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector
             if (node->getHash() != hash)
                 return false;
 
-            auto depth = std::distance(path.rbegin(), rit);
+            auto const depth = std::distance(path.rbegin(), rit);
             if (node->isInner())
             {
-                auto nodeId = SHAMapNodeID::createID(depth, key);
+                auto nodeId = SHAMapNodeID::createID(static_cast(depth), key);
                 hash = safeDowncast(node.get())
                            ->getChildHash(selectBranch(nodeId, key));
             }
diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp
index 5e5ab90441..50f46fceef 100644
--- a/src/libxrpl/tx/ApplyContext.cpp
+++ b/src/libxrpl/tx/ApplyContext.cpp
@@ -1,27 +1,19 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
 
-#include 
-#include 
 #include 
-#include 
 #include 
 #include 
-#include 
-#include 
 
 namespace xrpl {
 
@@ -75,75 +67,4 @@ ApplyContext::visit(
     view_->visit(base_, func);  // NOLINT(bugprone-unchecked-optional-access)
 }
 
-TER
-ApplyContext::failInvariantCheck(TER const result)
-{
-    // If we already failed invariant checks before and we are now attempting to
-    // only charge a fee, and even that fails the invariant checks something is
-    // very wrong. We switch to tefINVARIANT_FAILED, which does NOT get included
-    // in a ledger.
-
-    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
-        ? TER{tefINVARIANT_FAILED}
-        : TER{tecINVARIANT_FAILED};
-}
-
-template 
-TER
-ApplyContext::checkInvariantsHelper(
-    TER const result,
-    XRPAmount const fee,
-    std::index_sequence)
-{
-    try
-    {
-        auto checkers = getInvariantChecks();
-
-        // call each check's per-entry method
-        visit(
-            [&checkers](
-                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
-                (..., std::get(checkers).visitEntry(isDelete, before, after));
-            });
-
-        // Note: do not replace this logic with a `...&&` fold expression.
-        // The fold expression will only run until the first check fails (it
-        // short-circuits). While the logic is still correct, the log
-        // message won't be. Every failed invariant should write to the log,
-        // not just the first one.
-        std::array const finalizers{{std::get(checkers).finalize(
-            tx, result, fee, *view_, journal)...}};  // NOLINT(bugprone-unchecked-optional-access)
-
-        // call each check's finalizer to see that it passes
-        if (!std::ranges::all_of(finalizers, [](auto const& b) { return b; }))
-        {
-            JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: "
-                                  << to_string(tx.getJson(JsonOptions::Values::None));
-
-            return failInvariantCheck(result);
-        }
-    }
-    catch (std::exception const& ex)
-    {
-        JLOG(journal.fatal()) << "Transaction caused an exception in a global invariant"
-                              << ", ex: " << ex.what()
-                              << ", tx: " << to_string(tx.getJson(JsonOptions::Values::None));
-
-        return failInvariantCheck(result);
-    }
-
-    return result;
-}
-
-TER
-ApplyContext::checkInvariants(TER const result, XRPAmount const fee)
-{
-    XRPL_ASSERT(
-        isTesSuccess(result) || isTecClaim(result),
-        "xrpl::ApplyContext::checkInvariants : is tesSUCCESS or tecCLAIM");
-
-    return checkInvariantsHelper(
-        result, fee, std::make_index_sequence>{});
-}
-
 }  // namespace xrpl
diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
index 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/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp
index 5af4f621a7..2c05c874d3 100644
--- a/src/libxrpl/tx/applySteps.cpp
+++ b/src/libxrpl/tx/applySteps.cpp
@@ -251,7 +251,7 @@ TxConsequences::TxConsequences(NotTEC pfResult)
     : isBlocker_(false)
     , fee_(beast::kZero)
     , potentialSpend_(beast::kZero)
-    , seqProx_(SeqProxy::sequence(0))
+    , seqProx_(SeqProxy::rawSequence(0))
     , sequencesConsumed_(0)
 {
     XRPL_ASSERT(
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..aa4df8db42 100644
--- a/src/libxrpl/tx/invariants/InvariantCheck.cpp
+++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -40,12 +41,15 @@
 
 namespace xrpl {
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \
-    case tag: {                                                              \
-        return (privileges) & priv;                                          \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                  \
+    case tag: {                                                                       \
+        return ((TxSettings UNWRAP settings).privileges & priv) != Privilege::NoPriv; \
     }
 
 bool
@@ -63,6 +67,8 @@ hasPrivilege(STTx const& tx, Privilege priv)
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
 // Returns the human-readable name of a ledger entry's type, falling back to
 // the numeric type if the format is somehow unknown.
@@ -436,7 +442,7 @@ AccountRootsNotDeleted::finalize(
     // transaction when the total AMM LP Tokens balance goes to 0.
     // A successful AccountDelete or AMMDelete MUST delete exactly
     // one account root.
-    if (hasPrivilege(tx, MustDeleteAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::MustDeleteAcct) && isTesSuccess(result))
     {
         if (accountsDeleted_ == 1)
             return true;
@@ -457,7 +463,7 @@ AccountRootsNotDeleted::finalize(
     // A successful AMMWithdraw/AMMClawback MAY delete one account root
     // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw
     // deletes the AMM account, accountsDeleted_ is set if it is deleted.
-    if (hasPrivilege(tx, MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
+    if (hasPrivilege(tx, Privilege::MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
         return true;
 
     if (accountsDeleted_ == 0)
@@ -760,14 +766,15 @@ ValidNewAccountRoot::finalize(
     }
 
     // From this point on we know exactly one account was created.
-    if (hasPrivilege(tx, CreateAcct | CreatePseudoAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::CreateAcct | Privilege::CreatePseudoAcct) &&
+        isTesSuccess(result))
     {
         bool const pseudoAccount =
             (pseudoAccount_ &&
              (view.rules().enabled(featureSingleAssetVault) ||
               view.rules().enabled(featureLendingProtocol)));
 
-        if (pseudoAccount && !hasPrivilege(tx, CreatePseudoAcct))
+        if (pseudoAccount && !hasPrivilege(tx, Privilege::CreatePseudoAcct))
         {
             JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a "
                                "wrong transaction type";
@@ -1126,20 +1133,17 @@ NoModifiedUnmodifiableFields::finalize(
         auto const& before = slePair.first;
         auto const& after = slePair.second;
         auto const type = after->getType();
-        bool bad = false;
-        [[maybe_unused]] bool enforce = false;
+        // featureLendingProtocol gates enforcement, not detection: changes are
+        // always logged, but the transaction is only failed once the amendment
+        // is enabled. Type-specific field lists may add their own gates (see
+        // ltVAULT).
+        bool const enforce = view.rules().enabled(featureLendingProtocol);
+        bool bad = kFieldChanged(before, after, sfLedgerEntryType) ||
+            kFieldChanged(before, after, sfLedgerIndex);
         switch (type)
         {
             case ltLOAN_BROKER:
-                /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex) ||
-                    kFieldChanged(before, after, sfSequence) ||
+                bad = bad || kFieldChanged(before, after, sfSequence) ||
                     kFieldChanged(before, after, sfOwnerNode) ||
                     kFieldChanged(before, after, sfVaultNode) ||
                     kFieldChanged(before, after, sfVaultID) ||
@@ -1150,15 +1154,7 @@ NoModifiedUnmodifiableFields::finalize(
                     kFieldChanged(before, after, sfCoverRateLiquidation);
                 break;
             case ltLOAN:
-                /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex) ||
-                    kFieldChanged(before, after, sfSequence) ||
+                bad = bad || kFieldChanged(before, after, sfSequence) ||
                     kFieldChanged(before, after, sfOwnerNode) ||
                     kFieldChanged(before, after, sfLoanBrokerNode) ||
                     kFieldChanged(before, after, sfLoanBrokerID) ||
@@ -1177,19 +1173,28 @@ NoModifiedUnmodifiableFields::finalize(
                     kFieldChanged(before, after, sfGracePeriod) ||
                     kFieldChanged(before, after, sfLoanScale);
                 break;
-            default:
+            case ltVAULT:
                 /*
-                 * We check this invariant regardless of lending protocol
-                 * amendment status, allowing for detection and logging of
-                 * potential issues even when the amendment is disabled.
-                 *
-                 * We use the lending protocol as a gate, even though
-                 * all transactions are affected because that's when it
-                 * was added.
+                 * sfAccount, sfAsset and sfShareMPTID are already
+                 * captured by VaultInvariant. The additional fields
+                 * below are introduced by featureLendingProtocolV1_1
+                 * and only exist on V1_1 vaults.
                  */
-                enforce = view.rules().enabled(featureLendingProtocol);
-                bad = kFieldChanged(before, after, sfLedgerEntryType) ||
-                    kFieldChanged(before, after, sfLedgerIndex);
+                if (view.rules().enabled(featureLendingProtocolV1_1))
+                {
+                    bad = bad || kFieldChanged(before, after, sfVaultKind) ||
+                        kFieldChanged(before, after, sfSubscriptionDate) ||
+                        kFieldChanged(before, after, sfRedemptionDate) ||
+                        kFieldChanged(before, after, sfSequence) ||
+                        kFieldChanged(before, after, sfOwnerNode) ||
+                        kFieldChanged(before, after, sfOwner) ||
+                        kFieldChanged(before, after, sfWithdrawalPolicy) ||
+                        kFieldChanged(before, after, sfScale) ||
+                        kFieldChanged(before, after, sfLEVersion);
+                }
+                break;
+            default:
+                break;
         }
         XRPL_ASSERT(
             !bad || enforce,
diff --git a/src/libxrpl/tx/invariants/InvariantRunner.cpp b/src/libxrpl/tx/invariants/InvariantRunner.cpp
new file mode 100644
index 0000000000..55bff2d693
--- /dev/null
+++ b/src/libxrpl/tx/invariants/InvariantRunner.cpp
@@ -0,0 +1,110 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+namespace {
+
+TER
+failInvariantCheck(TER const result)
+{
+    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
+        ? TER{tefINVARIANT_FAILED}
+        : TER{tecINVARIANT_FAILED};
+}
+
+template 
+TER
+checkInvariantsHelper(
+    ApplyContext& ctx,
+    TER const result,
+    XRPAmount const fee,
+    std::optional> txCheck,
+    std::index_sequence)
+{
+    bool allOk = true;
+
+    try
+    {
+        auto checkers = getInvariantChecks();
+
+        ctx.visit([&](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
+            if (txCheck)
+                txCheck->get().visitEntry(isDelete, before, after);
+            (..., std::get(checkers).visitEntry(isDelete, before, after));
+        });
+
+        if (txCheck)
+        {
+            if (!txCheck->get().finalize(ctx.tx, result, fee, ctx.view(), ctx.journal))
+            {
+                JLOG(ctx.journal.fatal())
+                    << "Transaction has failed one or more transaction invariants: "
+                    << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+                allOk = false;
+            }
+        }
+
+        // Note: do not replace this logic with a `...&&` fold expression.
+        // The fold expression will only run until the first check fails (it
+        // short-circuits). While the logic is still correct, the log
+        // message won't be. Every failed invariant should write to the log,
+        // not just the first one.
+        std::array const finalizers{
+            {std::get(checkers).finalize(ctx.tx, result, fee, ctx.view(), ctx.journal)...}};
+
+        if (!std::all_of(finalizers.cbegin(), finalizers.cend(), [](auto const& b) { return b; }))
+        {
+            JLOG(ctx.journal.fatal()) << "Transaction has failed one or more global invariants: "
+                                      << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+            allOk = false;
+        }
+    }
+    catch (std::exception const& ex)
+    {
+        JLOG(ctx.journal.fatal()) << "Transaction caused an exception during invariant checks"
+                                  << ", ex: " << ex.what() << ", tx: "
+                                  << to_string(ctx.tx.getJson(JsonOptions::Values::None));
+        return failInvariantCheck(result);
+    }
+
+    return allOk ? result : failInvariantCheck(result);
+}
+
+}  // namespace
+
+TER
+checkInvariants(
+    ApplyContext& ctx,
+    TER const result,
+    XRPAmount const fee,
+    std::optional> txCheck)
+{
+    XRPL_ASSERT(
+        isTesSuccess(result) || isTecClaim(result),
+        "xrpl::checkInvariants : is tesSUCCESS or tecCLAIM");
+
+    return checkInvariantsHelper(
+        ctx, result, fee, txCheck, std::make_index_sequence>{});
+}
+
+}  // namespace xrpl
diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp
index ce9a7c6e03..7b96790570 100644
--- a/src/libxrpl/tx/invariants/LoanInvariant.cpp
+++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp
@@ -4,7 +4,10 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -12,6 +15,8 @@
 #include 
 #include 
 
+#include 
+
 namespace xrpl {
 
 void
@@ -26,7 +31,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after
 bool
 ValidLoan::finalize(
     STTx const& tx,
-    TER const,
+    TER const result,
     XRPAmount const,
     ReadView const& view,
     beast::Journal const& j)
@@ -36,6 +41,35 @@ ValidLoan::finalize(
 
     for (auto const& [before, after] : loans_)
     {
+        // A closed-ended vault must not accept a loan whose final scheduled payment falls on or
+        // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires
+        // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and
+        // PaymentRemaining only decreases, so the bound is preserved.
+        if (!before && isTesSuccess(result))
+        {
+            auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
+            if (broker)
+            {
+                auto const vault = view.read(keylet::vault(broker->at(sfVaultID)));
+                // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will
+                // not exist without the amendment enabled
+                if (vault && getVaultKind(vault) == VaultKind::ClosedEnded)
+                {
+                    std::uint32_t const startDate = after->at(sfStartDate);
+                    std::uint32_t const interval = after->at(sfPaymentInterval);
+                    std::uint32_t const remaining = after->at(sfPaymentRemaining);
+                    std::uint32_t const redemption = vault->at(sfRedemptionDate);
+                    if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >=
+                        redemption)
+                    {
+                        JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment "
+                                           "must precede RedemptionDate";
+                        return false;
+                    }
+                }
+            }
+        }
+
         // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants
         // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off
         if (after->at(sfPaymentRemaining) == 0 &&
diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
index 77c5ad781e..89ade024e6 100644
--- a/src/libxrpl/tx/invariants/MPTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -143,6 +144,8 @@ ValidMPTIssuance::finalize(
     //     must not dangle outside that controlled lifecycle.
     if (rules.enabled(fixCleanup3_2_0))
     {
+        // Not an amendment gate like the same-named flags below, just an
+        // accumulator, so that every violation gets logged before returning.
         bool invariantPasses = true;
         if (referenceHoldingMutated_)
         {
@@ -208,7 +211,7 @@ ValidMPTIssuance::finalize(
         }
 
         auto const txnType = tx.getTxnType();
-        if (hasPrivilege(tx, CreateMptIssuance))
+        if (hasPrivilege(tx, Privilege::CreateMptIssuance))
         {
             if (mptIssuancesCreated_ == 0)
             {
@@ -229,7 +232,7 @@ ValidMPTIssuance::finalize(
             return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
         }
 
-        if (hasPrivilege(tx, DestroyMptIssuance))
+        if (hasPrivilege(tx, Privilege::DestroyMptIssuance))
         {
             if (mptIssuancesDeleted_ == 0)
             {
@@ -256,7 +259,8 @@ ValidMPTIssuance::finalize(
         // non-amendment-gated side effects.
         bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
             (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
-        if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish)
+        if (hasPrivilege(tx, Privilege::MustAuthorizeMpt | Privilege::MayAuthorizeMpt) ||
+            enforceEscrowFinish)
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -272,7 +276,7 @@ ValidMPTIssuance::finalize(
                                    "succeeded but deleted issuances";
                 return false;
             }
-            if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) &&
+            if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) &&
                 (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
             {
                 if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
@@ -282,12 +286,13 @@ ValidMPTIssuance::finalize(
                                        "but created bad number of mptokens";
                     return false;
                 }
-                //  At most one MPToken may be created on withdraw/clawback since:
+                //  At most two MPToken may be created on withdraw/clawback since:
                 //  - Liquidity Provider must have at least one token in order
-                //    participate in AMM pool liquidity.
+                //    participate in AMM pool liquidity or have LPTokens only.
                 //  - At most two MPTokens may be deleted if AMM pool, which has exactly
                 //    two tokens, is empty after withdraw/clawback.
-                if (mptokensCreated_ > 1 || mptokensDeleted_ > 2)
+                SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens");
+                if (mptokensCreated_ > 2 || mptokensDeleted_ > 2)
                 {
                     JLOG(j.fatal()) << "Invariant failed: MPT authorize  succeeded "
                                        "but created/deleted bad number of mptokens";
@@ -307,7 +312,7 @@ ValidMPTIssuance::finalize(
                 return false;
             }
             else if (
-                !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) &&
+                !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) &&
                 (mptokensCreated_ + mptokensDeleted_ != 1))
             {
                 // if the holder submitted this tx, then a mptoken must be
@@ -320,7 +325,7 @@ ValidMPTIssuance::finalize(
             return true;
         }
 
-        if (hasPrivilege(tx, MayCreateMpt))
+        if (hasPrivilege(tx, Privilege::MayCreateMpt))
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -375,7 +380,7 @@ ValidMPTIssuance::finalize(
             return true;
         }
 
-        if (hasPrivilege(tx, MayDeleteMpt) &&
+        if (hasPrivilege(tx, Privilege::MayDeleteMpt) &&
             ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) &&
             mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0)
             return true;
@@ -472,7 +477,9 @@ ValidMPTBalanceChanges::finalize(
     ReadView const& view,
     beast::Journal const& j)
 {
-    if (isTesSuccess(result))
+    auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
+
+    if (isTesSuccess(result) || fix340Enabled)
     {
         // Confidential transactions are validated by ValidConfidentialMPToken.
         // They modify encrypted fields and sfConfidentialOutstandingAmount
@@ -484,7 +491,9 @@ ValidMPTBalanceChanges::finalize(
             return true;
         }
 
-        bool const invariantPasses = !view.rules().enabled(featureMPTokensV2);
+        // Returned when a violation is found below, so this is the log-only
+        // condition. Either amendment makes the checks enforcing.
+        auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
         if (overflow_)
         {
             JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
@@ -508,6 +517,18 @@ ValidMPTBalanceChanges::finalize(
                                 << " " << data.mptAmount;
                 return invariantPasses;
             }
+
+            // A failed transaction must not have moved MPT value; the check
+            // above ties mptAmount to the OutstandingAmount delta. No result
+            // code is exempt: on any tec the transactor discards the view and
+            // re-applies only offer, trust line, NFT offer and credential
+            // deletions (Transactor::typesForResult), none of which touch MPTs.
+            if (!isTesSuccess(result) && data.mptAmount != 0)
+            {
+                JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure "
+                                << tx.getTxnType() << " " << result;
+                return invariantPasses;
+            }
         }
     }
 
@@ -831,14 +852,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 +883,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 +905,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 +933,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 +958,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 +982,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 1014642b36..5c53552a3f 100644
--- a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
+++ b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
@@ -1,11 +1,13 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -17,19 +19,30 @@
 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)
+            domains_.insert(domain);
+    };
+
     if (after && after->getType() == ltDIR_NODE)
     {
         if (after->isFieldPresent(sfDomainID))
-            domains_.insert(after->getFieldH256(sfDomainID));
+            trackDomain(after->getFieldH256(sfDomainID));
     }
 
     if (after && after->getType() == ltOFFER)
     {
         if (after->isFieldPresent(sfDomainID))
         {
-            domains_.insert(after->getFieldH256(sfDomainID));
+            trackDomain(after->getFieldH256(sfDomainID));
         }
         else
         {
@@ -87,7 +100,8 @@ ValidPermissionedDEX::finalize(
 
     // for both payment and offercreate, there shouldn't be another domain
     // that's different from the domain specified
-    for (auto const& d : domains_)
+    auto const& domains = view.rules().enabled(fixCleanup3_4_0) ? domains_ : domainsOld_;
+    for (auto const& d : domains)
     {
         if (d != domain)
         {
diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp
index a9ba0ec874..7ba42383ad 100644
--- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
+++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,11 +25,27 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 namespace xrpl {
 
+namespace {
+
+/*
+ * True iff the recorded sfVaultKind identifies a closed-ended vault.
+ * Centralizes the presence + enum-value check used by the phase-gate
+ * invariants below.
+ */
+[[nodiscard]] bool
+isClosedEnded(std::optional const& vaultKind)
+{
+    return vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded);
+}
+
+}  // namespace
+
 ValidVault::Vault
 ValidVault::Vault::make(SLE const& from)
 {
@@ -44,6 +61,9 @@ ValidVault::Vault::make(SLE const& from)
     self.assetsAvailable = from.at(sfAssetsAvailable);
     self.assetsMaximum = from.at(sfAssetsMaximum);
     self.lossUnrealized = from.at(sfLossUnrealized);
+    self.vaultKind = from[~sfVaultKind];
+    self.subscriptionDate = from[~sfSubscriptionDate];
+    self.redemptionDate = from[~sfRedemptionDate];
     return self;
 }
 
@@ -254,6 +274,37 @@ ValidVault::isVaultEmpty(Vault const& vault)
     return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
 }
 
+bool
+ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const
+{
+    if (afterVault_.empty())
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists");
+        return false;
+        // LCOV_EXCL_STOP
+    }
+
+    auto const& afterVault = afterVault_[0];
+
+    // Loan origination against a closed-ended vault is only permitted while the vault is in the
+    // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended
+    // vaults have NoPhase and are unaffected.
+    auto const phase = getVaultPhase(
+        view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate);
+    if (phase == VaultPhase::NoPhase)
+        return true;
+
+    if (phase != VaultPhase::Investment)
+    {
+        JLOG(j.fatal()) <<  //
+            "Invariant failed: loan origination only allowed in Investment phase";
+        return false;
+    }
+
+    return true;
+}
+
 std::int32_t
 ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
 {
@@ -295,7 +346,7 @@ ValidVault::finalize(
 
     if (afterVault_.empty() && beforeVault_.empty())
     {
-        if (hasPrivilege(tx, MustModifyVault))
+        if (hasPrivilege(tx, Privilege::MustModifyVault))
         {
             JLOG(j.fatal()) <<  //
                 "Invariant failed: vault operation succeeded without modifying "
@@ -306,7 +357,8 @@ ValidVault::finalize(
 
         return true;  // Not a vault operation
     }
-    if (!(hasPrivilege(tx, MustModifyVault) || hasPrivilege(tx, MayModifyVault)))
+    if (!(hasPrivilege(tx, Privilege::MustModifyVault) ||
+          hasPrivilege(tx, Privilege::MayModifyVault)))
     {
         JLOG(j.fatal()) <<  //
             "Invariant failed: vault updated by a wrong transaction type";
@@ -465,7 +517,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;
     }
 
@@ -483,15 +535,21 @@ ValidVault::finalize(
         result = false;
     }
 
+    if (view.rules().enabled(fixCleanup3_4_0) && 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 +572,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 +661,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: {
@@ -660,6 +741,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)
                 {
@@ -798,20 +894,51 @@ ValidVault::finalize(
                     "xrpl::ValidVault::finalize : withdrawal updated a vault");
                 auto const& beforeVault = beforeVault_[0];
 
+                // Withdrawal from a closed-ended vault is not allowed during the Investment phase
+                // (strictly past SubscriptionDate, before RedemptionDate).
+                if (getVaultPhase(
+                        view,
+                        afterVault.vaultKind,
+                        afterVault.subscriptionDate,
+                        afterVault.redemptionDate) == VaultPhase::Investment)
+                {
+                    JLOG(j.fatal()) <<  //
+                        "Invariant failed: withdrawal not allowed during "
+                        "Investment phase";
+                    result = false;
+                }
+
                 auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
-                if (!maybeVaultDeltaAssets)
+
+                // Post-fixCleanup3_4_0: a withdrawal that redeems shares from a
+                // pool with no effective value left to back them (e.g. fully
+                // impaired/insolvent) legitimately moves zero assets on both
+                // sides — VaultWithdraw::doApply does not touch either
+                // balance-holding entry for a zero-value transfer, so no delta
+                // is recorded. VaultWithdraw::doApply separately rejects
+                // (tecPRECISION_LOSS) the case where a *positive* per-share
+                // value merely rounds down to zero, so a missing delta while
+                // the pool still held positive effective value indicates a
+                // real accounting bug, not this exception.
+                bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) &&
+                    !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized;
+
+                if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate)
                 {
                     JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
                     return false;  // That's all we can do
                 }
 
+                DeltaInfo const vaultDeltaAssets = maybeVaultDeltaAssets.value_or(
+                    DeltaInfo{.delta = kNumZero, .scale = std::nullopt});
+
                 // Get the posterior scale to round calculations to
-                auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
+                auto const minScale = computeVaultMinScale(vaultDeltaAssets, view.rules());
 
                 auto const vaultPseudoDeltaAssets =
-                    roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
+                    roundToAsset(vaultAsset, vaultDeltaAssets.delta, minScale);
 
-                if (vaultPseudoDeltaAssets >= kZero)
+                if (!zeroDeltaIsLegitimate && vaultPseudoDeltaAssets >= kZero)
                 {
                     JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
                     result = false;
@@ -838,63 +965,76 @@ ValidVault::finalize(
 
                     if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must change one destination balance";
-                        return false;
+                        // Both changed is always a bug. Neither changed is
+                        // consistent only with a legitimate zero-value
+                        // withdrawal, which moves nothing on either side —
+                        // there is nothing left to cross-check.
+                        if (!zeroDeltaIsLegitimate || maybeAccDelta.has_value())
+                        {
+                            JLOG(j.fatal()) <<  //
+                                "Invariant failed: withdrawal must change one destination balance";
+                            return false;
+                        }
                     }
-
-                    auto const destinationDelta =  //
-                        maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
-
-                    // the scale of destinationDelta can be coarser than
-                    // minScale, so we take that into account when rounding
-                    auto const destinationScale = computeCoarsestScale({destinationDelta});
-                    auto const localMinScale = std::max(minScale, destinationScale);
-
-                    auto const roundedDestinationDelta =
-                        roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
-
-                    // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
-                    // If the receiver's trust line sits at a coarser scale, the inflow may
-                    // safely round down to zero.
-                    //
-                    // XRP and MPT remain strict. Because they are integer-exact, a zero
-                    // destination delta indicates a true accounting bug, not a rounding artifact.
-                    bool const tolerateZeroDelta =
-                        view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
-                    auto const invalidBalanceChange = tolerateZeroDelta
-                        ? roundedDestinationDelta < kZero
-                        : roundedDestinationDelta <= kZero;
-                    if (invalidBalanceChange)
+                    else
                     {
-                        JLOG(j.fatal()) <<  //
-                            "Invariant failed: withdrawal must increase destination balance";
-                        result = false;
-                    }
+                        // A one-sided change is cross-checked even for a
+                        // legitimate zero vault delta: the destination must
+                        // then have moved by (rounded) zero as well.
+                        auto const destinationDelta =
+                            *maybeAccDelta.or_else([&] { return maybeOtherAccDelta; });
 
-                    auto const localPseudoDeltaAssets =
-                        roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
-                    // For IOU assets near a precision boundary the destination's STAmount
-                    // exponent can shift, making part of the sent value unrepresentable at the
-                    // receiver's new scale — that portion is irreversibly absorbed by the IOU
-                    // rail.  Tolerate the mismatch only when the destroyed amount (vault outflow
-                    // minus destination inflow, in Number space) is itself sub-ULP at the
-                    // destination's scale.  Floor rounding is used so that values exactly at the
-                    // step boundary are not mistakenly dismissed.  Any representable discrepancy
-                    // indicates a real accounting bug and must be caught.
-                    auto const destroyedIsSubUlp = tolerateZeroDelta &&
-                        roundToAsset(
-                            vaultAsset,
-                            maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
-                            destinationScale,
-                            Number::RoundingMode::Downward) == kZero;
-                    if (!destroyedIsSubUlp &&
-                        localPseudoDeltaAssets * -1 != roundedDestinationDelta)
-                    {
-                        JLOG(j.fatal()) << "Invariant failed: " <<  //
-                            "withdrawal must change vault and destination balance by equal "
-                            "amount";
-                        result = false;
+                        // the scale of destinationDelta can be coarser than
+                        // minScale, so we take that into account when rounding
+                        auto const destinationScale = computeCoarsestScale({destinationDelta});
+                        auto const localMinScale = std::max(minScale, destinationScale);
+
+                        auto const roundedDestinationDelta =
+                            roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
+
+                        // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs
+                        // only. If the receiver's trust line sits at a coarser scale, the inflow
+                        // may safely round down to zero.
+                        //
+                        // XRP and MPT remain strict. Because they are integer-exact, a zero
+                        // destination delta indicates a true accounting bug, not a rounding
+                        // artifact.
+                        bool const tolerateZeroDelta =
+                            view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
+                        auto const invalidBalanceChange = tolerateZeroDelta
+                            ? roundedDestinationDelta < kZero
+                            : roundedDestinationDelta <= kZero;
+                        if (invalidBalanceChange)
+                        {
+                            JLOG(j.fatal()) <<  //
+                                "Invariant failed: withdrawal must increase destination balance";
+                            result = false;
+                        }
+
+                        auto const localPseudoDeltaAssets =
+                            roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
+                        // For IOU assets near a precision boundary the destination's STAmount
+                        // exponent can shift, making part of the sent value unrepresentable at
+                        // the receiver's new scale — that portion is irreversibly absorbed by the
+                        // IOU rail.  Tolerate the mismatch only when the destroyed amount (vault
+                        // outflow minus destination inflow, in Number space) is itself sub-ULP at
+                        // the destination's scale.  Floor rounding is used so that values exactly
+                        // at the step boundary are not mistakenly dismissed.  Any representable
+                        // discrepancy indicates a real accounting bug and must be caught.
+                        auto const destroyedIsSubUlp = tolerateZeroDelta &&
+                            roundToAsset(
+                                vaultAsset,
+                                vaultDeltaAssets.delta * -1 - destinationDelta.delta,
+                                destinationScale,
+                                Number::RoundingMode::Downward) == kZero;
+                        if (!destroyedIsSubUlp &&
+                            localPseudoDeltaAssets * -1 != roundedDestinationDelta)
+                        {
+                            JLOG(j.fatal()) << "Invariant failed: " <<  //
+                                "withdrawal must change vault and destination balance by equal "
+                                "amount";
+                            result = false;
+                        }
                     }
                 }
 
@@ -1046,6 +1186,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..2823627108 100644
--- a/src/libxrpl/tx/paths/BookStep.cpp
+++ b/src/libxrpl/tx/paths/BookStep.cpp
@@ -44,7 +44,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -653,7 +655,15 @@ limitStepIn(
         // under an amendment.
         ofrAmt = offer.limitIn(ofrAmt, inLmt, /* roundUp */ false);
         stpAmt.out = ofrAmt.out;
-        ownerGives = mulRatio(ofrAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false);
+        // Round up for MPT output so the offer owner pays the full
+        // ceil(amount × rate) fee, matching direct Payment semantics.  IOU uses
+        // floating-point arithmetic so the floor/ceil distinction is sub-epsilon
+        // there; preserve the historical false to avoid changing IOU behavior.
+        ownerGives = mulRatio(
+            ofrAmt.out,
+            transferRateOut,
+            QUALITY_ONE,
+            /*roundUp*/ std::is_same_v);
     }
 }
 
@@ -672,7 +682,11 @@ limitStepOut(
     if (limit < stpAmt.out)
     {
         stpAmt.out = limit;
-        ownerGives = mulRatio(stpAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false);
+        ownerGives = mulRatio(
+            stpAmt.out,
+            transferRateOut,
+            QUALITY_ONE,
+            /*roundUp*/ std::is_same_v);
         ofrAmt = offer.limitOut(
             ofrAmt,
             stpAmt.out,
@@ -727,17 +741,20 @@ BookStep::forEachOffer(
         bool const isAssetInMPT = assetIn.holds();
         auto const& owner = offer.owner();
 
-        if (isAssetInMPT)
-        {
-            // Create MPToken for the offer's owner. No need to check
-            // for the reserve since the offer is removed if it is consumed.
-            // Therefore, the owner count remains the same.
-            if (auto const err = checkCreateMPT(sb, assetIn.get(), owner, {}, j_);
-                !isTesSuccess(err))
+        auto removeOffer = [&](std::string_view logMessage = {}) {
+            auto const key = offer.key();
+            if (!logMessage.empty())
             {
-                return true;
+                JLOG(j_.trace()) << logMessage << (key ? " " + to_string(*key) : "");
             }
-        }
+            if (key)
+                offers.permRmOffer(*key);
+            if (!offerAttempted)
+            {
+                // Change quality only if no previous offers were tried.
+                ofrQ = std::nullopt;
+            }
+        };
 
         // It shouldn't matter from auth point of view whether it's sb
         // or afView. Amendment guard this change just in case.
@@ -745,17 +762,15 @@ BookStep::forEachOffer(
         // Make sure offer owner has authorization to own Assets from issuer
         // and MPT assets can be traded/transferred.
         // An account can always own XRP or their own Assets.
-        if (!isTesSuccess(requireAuth(applyView, assetIn, owner)) || !checkMPTDEX(sb, owner))
+        // Missing MPTokens are allowed during offer discovery; they are
+        // created later if the offer is actually consumed.
+        auto const authType = isAssetInMPT ? AuthType::WeakAuth : AuthType::Legacy;
+        if (!isTesSuccess(requireAuth(applyView, assetIn, owner, authType)) ||
+            !checkMPTDEX(sb, owner))
         {
             // Offer owner not authorized to hold IOU/MPT from issuer.
             // Remove this offer even if no crossing occurs.
-            if (auto const key = offer.key())
-                offers.permRmOffer(*key);
-            if (!offerAttempted)
-            {
-                // Change quality only if no previous offers were tried.
-                ofrQ = std::nullopt;
-            }
+            removeOffer();
             // Returning true causes offers.step() to delete the offer.
             return true;
         }
@@ -768,52 +783,88 @@ BookStep::forEachOffer(
             static_cast(this)->getOfrOutRate(prevStep_, owner, strandDst_, trOut));
 
         auto ofrAmt = offer.amount();
-        TAmounts stpAmt{mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), ofrAmt.out};
-
-        // owner pays the transfer fee.
-        auto ownerGives = mulRatio(ofrAmt.out, ofrOutRate, QUALITY_ONE, /*roundUp*/ false);
-
-        auto const funds = offer.isFunded()
-            ? ownerGives  // Offer owner is issuer; they have unlimited funds
-            : offers.ownerFunds();
-
-        // Only if CLOB offer
-        if (funds < ownerGives)
+        TAmounts stpAmt{ofrAmt.in, ofrAmt.out};
+        auto ownerGives = ofrAmt.out;
+        try
         {
-            // We already know offer.owner()!=offer.issueOut().account
-            ownerGives = funds;
-            stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false);
-
-            // It turns out we can prevent order book blocking by (strictly)
-            // rounding down the ceil_out() result.  This adjustment changes
-            // transaction outcomes, so it must be made under an amendment.
-            ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false);
-
+            // All arithmetic in this block runs before the offer is consumed.
+            // A crafted MPTokensV2 offer can overflow while transfer rates or
+            // crossing limits are applied; remove that unusable offer instead
+            // of letting it persist as a tecINTERNAL source.
             stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
-        }
 
-        // Limit offer's input if MPT, BookStep is the first step (an issuer
-        // is making a cross-currency payment), and this offer is not owned
-        // by the issuer. Otherwise, OutstandingAmount may overflow.
-        auto const& issuer = assetIn.getIssuer();
-        if (isAssetInMPT && !prevStep_ && offer.owner() != issuer)
-        {
-            // Funds available to issue
-            auto const available = toAmount(accountFunds(
-                sb,
-                issuer,
-                assetIn,  // STAmount{0}, but the default is not used
-                FreezeHandling::IgnoreFreeze,
-                AuthHandling::IgnoreAuth,
-                j_));
-            if (stpAmt.in > available)
+            // owner pays the transfer fee.
+            ownerGives = mulRatio(
+                ofrAmt.out,
+                ofrOutRate,
+                QUALITY_ONE,
+                /*roundUp*/ std::is_same_v);
+
+            auto const funds = offer.isFunded()
+                ? ownerGives  // Offer owner is issuer; they have unlimited funds
+                : offers.ownerFunds();
+
+            // Only if CLOB offer
+            if (funds < ownerGives)
             {
-                limitStepIn(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available);
-            }
-        }
+                // We already know offer.owner()!=offer.issueOut().account
+                ownerGives = funds;
+                stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false);
 
-        offerAttempted = true;
-        return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
+                // It turns out we can prevent order book blocking by (strictly)
+                // rounding down the ceil_out() result.  This adjustment changes
+                // transaction outcomes, so it must be made under an amendment.
+                ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false);
+
+                stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
+            }
+
+            // Limit offer's input if MPT, BookStep is the first step (an issuer
+            // is making a cross-currency payment), and this offer is not owned
+            // by the issuer. Otherwise, OutstandingAmount may overflow.
+            auto const& issuer = assetIn.getIssuer();
+            if (isAssetInMPT && !prevStep_ && offer.owner() != issuer)
+            {
+                // Funds available to issue
+                auto const available = toAmount(accountFunds(
+                    sb,
+                    issuer,
+                    assetIn,  // STAmount{0}, but the default is not used
+                    FreezeHandling::IgnoreFreeze,
+                    AuthHandling::IgnoreAuth,
+                    j_));
+                if (stpAmt.in > available)
+                {
+                    limitStepIn(
+                        offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available);
+                }
+            }
+
+            offerAttempted = true;
+            return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
+        }
+        catch (std::overflow_error const&)
+        {
+            if (sb.rules().enabled(featureMPTokensV2))
+            {
+                SOMETIMES(
+                    true,
+                    "BookStep::forEachOffer removed MPT offer after "
+                    "overflow during crossing");
+                removeOffer("Removing offer with overflowing amount calculation");
+                return true;
+            }
+            // An overflow can only be produced by a crafted MPT offer, and MPT
+            // offers require featureMPTokensV2 (enforced at OfferCreate
+            // preflight). So the amendment is always enabled when we get here
+            // and this legacy re-throw is unreachable in practice.
+            // LCOV_EXCL_START
+            XRPL_ASSERT(
+                sb.rules().enabled(featureMPTokensV2),
+                "xrpl::BookStep::forEachOffer : overflow implies MPTokensV2");
+            throw;
+            // LCOV_EXCL_STOP
+        }
     };
 
     // At any payment engine iteration, AMM offer can only be consumed once.
@@ -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);
 
diff --git a/src/libxrpl/tx/paths/DirectStep.cpp b/src/libxrpl/tx/paths/DirectStep.cpp
index f8f12bd421..1854bd3632 100644
--- a/src/libxrpl/tx/paths/DirectStep.cpp
+++ b/src/libxrpl/tx/paths/DirectStep.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -845,8 +846,14 @@ DirectStepI::check(StrandContext const& ctx) const
     // pure issue/redeem can't be frozen
     if (!(ctx.isLast && ctx.isFirst))
     {
-        auto const ter = checkFreeze(ctx.view, src_, dst_, currency_);
-        if (!isTesSuccess(ter))
+        if (auto const ter = checkFreeze(ctx.view, src_, dst_, currency_); !isTesSuccess(ter))
+            return ter;
+
+        // An LPToken redeemed against its AMM (dst_ is the LPToken issuer on
+        // this hop) cannot move if a pool asset is an MPT that forbids
+        // transfers between these accounts. A no-op unless dst_ is an AMM whose
+        // pool holds such an MPT (so it is implicitly gated by featureMPTokensV2).
+        if (auto const ter = canTransferLPToken(ctx.view, src_, dst_, dst_); !isTesSuccess(ter))
             return ter;
     }
 
diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
index 0a0f6a9f27..a47cfa15a5 100644
--- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
@@ -410,8 +410,7 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio
         // for the reserve since the offer doesn't go on the books
         // if crossed. Insufficient reserve is allowed if the offer
         // crossed. See CreateOffer::applyGuts() for reserve check.
-        if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, {}, j_);
-            !isTesSuccess(err))
+        if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err))
         {
             JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT";
             resetCache(srcDebtDir);
diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp
index ecc8416a2b..6884a113bd 100644
--- a/src/libxrpl/tx/paths/OfferStream.cpp
+++ b/src/libxrpl/tx/paths/OfferStream.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -25,10 +26,14 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 
 #include 
 #include 
+#include 
+#include 
 
 namespace xrpl {
 
@@ -136,17 +141,17 @@ template 
 TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
 {
     // Consider removing the offer if:
-    //  o `TakerPays` is XRP (because of XRP drops granularity) or
+    //  o `TakerPays` is integral (because XRP/MPT have indivisible units) or
     //  o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets`
-    static constexpr bool kInIsXrp = std::is_same_v;
-    static constexpr bool kOutIsXrp = std::is_same_v;
+    constexpr bool const kInIsIntegral = !std::is_same_v;
+    constexpr bool const kOutIsIntegral = !std::is_same_v;
 
-    if constexpr (kOutIsXrp)
+    if constexpr (!kInIsIntegral && kOutIsIntegral)
     {
-        // If `TakerGets` is XRP, the worst this offer's quality can change is
-        // to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be
-        // remarkably good quality for any realistic asset, so these offers
-        // don't need this extra check.
+        // If only `TakerGets` is integral, the worst this offer's quality can
+        // change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This
+        // will be perfect quality for any realistic asset, so these
+        // offers don't need this extra check.
         return false;
     }
 
@@ -156,7 +161,7 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
     TAmounts const ofrAmts{
         toAmount(offer_.amount().in), toAmount(offer_.amount().out)};
 
-    if constexpr (!kInIsXrp && !kOutIsXrp)
+    if constexpr (!kInIsIntegral && !kOutIsIntegral)
     {
         if (Number(ofrAmts.in) >= Number(ofrAmts.out))
             return false;
@@ -165,7 +170,12 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const
     TTakerGets const ownerFunds = toAmount(*ownerFunds_);
 
     auto const effectiveAmounts = [&] {
-        if (offer_.owner() != offer_.assetOut().getIssuer() && ownerFunds < ofrAmts.out)
+        // Issuer-owned IOU offers are self-funded without a limit. MPT issuer
+        // offers are bounded by remaining issuance capacity, so they still need
+        // to be clipped by ownerFunds.
+        bool const issuerHasUnlimitedFunds = offer_.owner() == offer_.assetOut().getIssuer() &&
+            offer_.assetOut().template holds();
+        if (!issuerHasUnlimitedFunds && ownerFunds < ofrAmts.out)
         {
             // adjust the amounts by owner funds.
             //
@@ -250,6 +260,23 @@ TOfferStreamBase::step()
             continue;
         }
 
+        // Post-fixCleanup3_4_0 defensive check: an offer indexed in a domain
+        // book must claim that same domain. This can only happen if the book
+        // directory is corrupt (i.e. a separate book indexing bug). An offer
+        // with no sfDomainID at all is just as wrong here: the domain
+        // membership check below is gated on that field being present, so
+        // such an offer would otherwise be consumed from a domain book
+        // without any credential check.
+        if (view_.rules().enabled(fixCleanup3_4_0) && book_.domain.has_value() &&
+            (!entry->isFieldPresent(sfDomainID) ||
+             entry->getFieldH256(sfDomainID) != *book_.domain))
+        {
+            JLOG(j_.error()) << "Offer " << entry->key()
+                             << " domain missing or does not match book domain";
+            Throw(
+                tecINTERNAL, "Offer domain missing or does not match book domain.");
+        }
+
         // Pre-fixCleanup3_3_0: validate domain membership for any book.
         // Post-fixCleanup3_3_0: only validate when walking a domain book.
         // Hybrid offers carry sfDomainID but also participate in the open
@@ -305,7 +332,41 @@ TOfferStreamBase::step()
             continue;
         }
 
-        if (shouldRmSmallIncreasedQOffer())
+        // Partially funded offers can be reduced before BookStep sees them.
+        // If that strict reduction overflows under MPTokensV2, remove the
+        // unusable offer instead of leaving it at the book tip.
+        bool shouldRemoveSmallIncreasedQOffer = false;
+        try
+        {
+            shouldRemoveSmallIncreasedQOffer = shouldRmSmallIncreasedQOffer();
+        }
+        catch (std::overflow_error const&)
+        {
+            if (view_.rules().enabled(featureMPTokensV2))
+            {
+                SOMETIMES(
+                    true,
+                    "OfferStream::step removed MPT offer with overflowing "
+                    "reduced quality");
+                permRmOffer(entry->key());
+                JLOG(j_.warn()) << "Removing offer with overflowing reduced quality "
+                                << entry->key();
+                offer_ = TOffer{};
+                continue;
+            }
+            // The strict reduction only overflows for a crafted MPT offer, and
+            // MPT offers require featureMPTokensV2 (enforced at OfferCreate
+            // preflight). So the amendment is always enabled here and this
+            // legacy re-throw is unreachable in practice.
+            // LCOV_EXCL_START
+            XRPL_ASSERT(
+                view_.rules().enabled(featureMPTokensV2),
+                "xrpl::TOfferStreamBase::step : overflow implies MPTokensV2");
+            throw;
+            // LCOV_EXCL_STOP
+        }
+
+        if (shouldRemoveSmallIncreasedQOffer)
         {
             auto const originalFunds = accountFundsHelper(
                 cancelView_,
diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp
index 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..857f759752 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{
@@ -516,7 +528,7 @@ CheckCash::doApply()
                                 return tecINSUFFICIENT_RESERVE;
 
                             if (auto const err =
-                                    checkCreateMPT(psb, mptID, accountID_, *sponsorSle, j_);
+                                    checkCreateMPT(psb, mptID, accountID_, *sponsorSle, 0, j_);
                                 !isTesSuccess(err))
                             {
                                 return err;
diff --git a/src/libxrpl/tx/transactors/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..e690cd7693 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -227,6 +227,7 @@ AMMClawback::applyGuts(Sandbox& sb)
                 sb,
                 *ammSle,
                 holder,
+                issuer,
                 ammAccount,
                 amountBalance,
                 amount2Balance,
@@ -256,7 +257,7 @@ AMMClawback::applyGuts(Sandbox& sb)
     }
 
     if (!isTesSuccess(result))
-        return result;  // LCOV_EXCL_LINE
+        return result;
 
     if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3))
     {
@@ -311,6 +312,14 @@ AMMClawback::equalWithdrawMatchingOneAmount(
     STAmount const& holdLPtokens,
     STAmount const& amount)
 {
+    // The clawback issuer signs for its own asset only. Threaded into the
+    // withdrawal so a recreated MPToken is auto-authorized only for the
+    // clawback issuer's asset, never for a paired asset from another issuer.
+    // preflight guarantees sfAccount is the clawed asset's issuer (it rejects
+    // the tx as temMALFORMED when sfAsset's issuer != sfAccount), so this is
+    // the issuer, not just any signer.
+    AccountID const issuer = ctx_.tx[sfAccount];
+
     auto frac = Number{amount} / amountBalance;
     auto amount2Withdraw = amount2Balance * frac;
 
@@ -324,6 +333,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             sb,
             ammSle,
             holder,
+            issuer,
             ammAccount,
             amountBalance,
             amount2Balance,
@@ -353,10 +363,18 @@ AMMClawback::equalWithdrawMatchingOneAmount(
 
         auto amountRounded = getRoundedAsset(rules, amountBalance, frac, IsDeposit::No);
 
+        // The requested clawback amount is likely too small and results in
+        // one-sided pool withdrawal due to round off. Fail so the issuer can
+        // clawback a larger amount.
+        if (rules.enabled(fixCleanup3_4_0) &&
+            (amountRounded == beast::kZero || amount2Rounded == beast::kZero))
+            return {tecAMM_FAILED, STAmount{}, STAmount{}, STAmount{}};
+
         return AMMWithdraw::withdraw(
             sb,
             ammSle,
             ammAccount,
+            issuer,
             holder,
             amountBalance,
             amountRounded,
@@ -377,6 +395,7 @@ AMMClawback::equalWithdrawMatchingOneAmount(
         sb,
         ammSle,
         ammAccount,
+        issuer,
         holder,
         amountBalance,
         amount,
diff --git a/src/libxrpl/tx/transactors/dex/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..edd2cc2037 100644
--- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
@@ -19,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -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,
@@ -513,6 +538,7 @@ AMMWithdraw::withdraw(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const& ammAccount,
+    std::optional const& clawbackIssuer,
     AccountID const& account,
     STAmount const& amountBalance,
     STAmount const& amountWithdraw,
@@ -680,14 +706,48 @@ AMMWithdraw::withdraw(
         if (mptokenKey && account != asset.getIssuer())
         {
             auto const& mptIssue = asset.get();
+            std::uint32_t createFlags = 0;
             if (auto const err = requireAuth(view, mptIssue, account, AuthType::WeakAuth);
                 !isTesSuccess(err))
-                return err;
+            {
+                if (authHandling != AuthHandling::IgnoreAuth || err != tecNO_AUTH)
+                {
+                    // Unreachable in practice. Normal withdraws (authHandling
+                    // != IgnoreAuth) are rejected for unauthorized holders in
+                    // preclaim, so they never get here. Under clawback
+                    // (IgnoreAuth) requireAuth returns a non-tecNO_AUTH error
+                    // (e.g. tecEXPIRED) only for a domain-authorized MPT, but no
+                    // such MPT can be in an AMM pool: a directly domain-gated
+                    // RequireAuth MPT fails AMMCreate/deposit with tecNO_AUTH,
+                    // and vault shares (whose recursive auth could yield
+                    // tecEXPIRED) are rejected by AMMCreate with tecWRONG_ASSET.
+                    return err;  // LCOV_EXCL_LINE
+                }
 
-            if (auto const err = checkCreateMPT(view, mptIssue, account, {}, journal);
+                // AMMClawback ignores authorization so the issuer can recover
+                // MPT locked in the pool even if the holder deleted their
+                // MPToken. Only auto-authorize the recreated MPToken for the
+                // clawback issuer's own asset: authorization is granted by an
+                // asset's issuer, and the clawback transaction is signed by
+                // that issuer only for its own asset. For a paired asset issued
+                // by a different account, recreate the MPToken *unauthorized* so
+                // the clawback does not grant authorization on behalf of that
+                // issuer (which would bypass its lsfMPTRequireAuth). The holder
+                // still receives the paired asset (accountSend only requires the
+                // MPToken to exist, not to be authorized); the balance remains
+                // gated by its issuer until that issuer authorizes it.
+                if (clawbackIssuer && asset.getIssuer() == *clawbackIssuer)
+                    createFlags = lsfMPTAuthorized;
+            }
+
+            if (auto const err = checkCreateMPT(view, mptIssue, account, {}, createFlags, journal);
                 !isTesSuccess(err))
             {
-                return err;
+                // checkCreateMPT only fails on tecDIR_FULL (its source line is
+                // itself LCOV-excluded) or a missing account, which cannot
+                // happen since `account` is the withdrawing LP. Defensive and
+                // unreachable in practice.
+                return err;  // LCOV_EXCL_LINE
             }
         }
         return tesSUCCESS;
@@ -781,6 +841,7 @@ AMMWithdraw::equalWithdrawTokens(
         view,
         ammSle,
         accountID_,
+        std::nullopt,
         ammAccount,
         amountBalance,
         amount2Balance,
@@ -833,6 +894,7 @@ AMMWithdraw::equalWithdrawTokens(
     Sandbox& view,
     SLE const& ammSle,
     AccountID const account,
+    std::optional const& clawbackIssuer,
     AccountID const& ammAccount,
     STAmount const& amountBalance,
     STAmount const& amount2Balance,
@@ -855,6 +917,7 @@ AMMWithdraw::equalWithdrawTokens(
                 view,
                 ammSle,
                 ammAccount,
+                clawbackIssuer,
                 account,
                 amountBalance,
                 amountBalance,
@@ -890,6 +953,7 @@ AMMWithdraw::equalWithdrawTokens(
             view,
             ammSle,
             ammAccount,
+            clawbackIssuer,
             account,
             amountBalance,
             amountWithdraw,
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..0492f9c062 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
@@ -34,6 +34,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -283,10 +284,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 +323,6 @@ OfferCreate::checkAcceptAsset(
                 }
             }
 
-            auto const trustLine = view.read(keylet::trustLine(id, issue.account, issue.currency));
-
             if (!trustLine)
             {
                 return tesSUCCESS;
@@ -623,7 +635,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 +649,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
@@ -922,7 +935,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);
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
index feed43d410..21e6bd2c30 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)
     {
diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
index 50f2e8b859..0fe27fb3ba 100644
--- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
+++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
@@ -304,11 +304,11 @@ escrowCreatePreclaimHelper(
         return ter;
 
     // If the issuer has frozen the account, return tecLOCKED
-    if (isFrozen(ctx.view, account, mptIssue))
+    if (isFrozen(ctx.view, account, *sleIssuance))
         return tecLOCKED;
 
     // If the issuer has frozen the destination, return tecLOCKED
-    if (isFrozen(ctx.view, dest, mptIssue))
+    if (isFrozen(ctx.view, dest, *sleIssuance))
         return tecLOCKED;
 
     // If the mpt cannot be transferred, return tecNO_AUTH
@@ -476,7 +476,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 +489,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..aa352d5e98 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 
@@ -110,7 +111,7 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx)
         }
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -185,7 +186,7 @@ escrowFinishPreclaimHelper(
         return ter;
 
     // If the issuer has frozen the destination, return tecLOCKED
-    if (isFrozen(ctx.view, dest, mptIssue))
+    if (isFrozen(ctx.view, dest, *sleIssuance))
         return tecLOCKED;
 
     return tesSUCCESS;
@@ -203,7 +204,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 +230,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)
     {
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
index 498f3c99eb..e914596599 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -25,7 +26,11 @@ namespace xrpl {
 bool
 LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx)
 {
-    return checkLendingProtocolDependencies(ctx.rules, ctx.tx);
+    if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx))
+        return false;
+
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) ||
+        (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0));
 }
 
 NotTEC
@@ -49,6 +54,9 @@ LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -109,6 +117,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct, waive))
         return ret;
 
+    // Validate credentials (if any) before canWithdraw, since canWithdraw may
+    // call credentials::authorizedDepositPreauth which assumes credentials
+    // already exist.
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err))
+        return err;
+
     // Withdrawal to a 3rd party destination account is essentially a transfer.
     // Enforce all the usual asset transfer checks.
     AuthType authType = AuthType::WeakAuth;
diff --git a/src/libxrpl/tx/transactors/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..d6cda9c326 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -34,7 +34,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];
@@ -218,7 +218,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 +253,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..a312dba3b3 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -127,23 +127,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 +141,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 +287,7 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
-    Number const lossUnrealized = owedToVault(loanSle);
+    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
@@ -353,7 +336,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
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index 54ee85b186..c5bfd8e9ee 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -9,6 +10,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +36,34 @@
 
 namespace xrpl {
 
+namespace {
+// Returns the account's true, unclamped balance in `asset`, for use only in
+// fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance)
+// cannot be used for this: for XRP it always defers to xrpLiquid, which
+// subtracts the account's reserve, so a payee sitting below its own reserve
+// would appear to receive nothing even though its raw ledger balance grew.
+// That mismatch is exactly what a conservation check must not see.
+STAmount
+conservationBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j)
+{
+    if (isXRP(asset))
+    {
+        auto const sle = view.read(keylet::account(id));
+        if (!sle)
+            return STAmount{asset};  // LCOV_EXCL_LINE
+        return view.balanceHookIOU(id, xrpAccount(), sle->getFieldAmount(sfBalance));
+    }
+    return accountHolds(
+        view,
+        id,
+        asset,
+        FreezeHandling::IgnoreFreeze,
+        AuthHandling::IgnoreAuth,
+        j,
+        SpendableHandling::FullBalance);
+}
+}  // namespace
+
 bool
 LoanPay::checkExtraFeatures(PreflightContext const& ctx)
 {
@@ -73,7 +104,7 @@ LoanPay::preflight(PreflightContext const& ctx)
 XRPAmount
 LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
 {
-    using namespace Lending;
+    using namespace lending;
 
     auto const normalCost = Transactor::calculateBaseFee(view, tx);
 
@@ -420,10 +451,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 +473,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 +482,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 +517,7 @@ LoanPay::doApply()
 #endif
 
     assetsAvailableProxy += totalPaidToVaultRounded;
-    assetsTotalProxy += paymentParts->valueChange;
+    assetsTotalProxy += assetsTotalDelta;
 
     XRPL_ASSERT_PARTS(
         *assetsAvailableProxy <= *assetsTotalProxy,
@@ -543,11 +570,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 +582,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,34 +612,13 @@ LoanPay::doApply()
     }
 
     // These three values are used to check that funds are conserved after the transfers
-    auto const accountBalanceBefore = accountHolds(
-        view,
-        accountID_,
-        asset,
-        FreezeHandling::IgnoreFreeze,
-        AuthHandling::IgnoreAuth,
-        j_,
-        SpendableHandling::FullBalance);
+    auto const accountBalanceBefore = conservationBalance(view, accountID_, asset, j_);
     auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              vaultPseudoAccount,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
+        : conservationBalance(view, vaultPseudoAccount, asset, j_);
     auto const brokerBalanceBefore = accountID_ == brokerPayee
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              brokerPayee,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
+        : conservationBalance(view, brokerPayee, asset, j_);
 
     if (totalPaidToVaultRounded != beast::kZero)
     {
@@ -667,33 +674,13 @@ LoanPay::doApply()
 #endif
 
     // Check that funds are conserved
-    auto const accountBalanceAfter = accountHolds(
-        view,
-        accountID_,
-        asset,
-        FreezeHandling::IgnoreFreeze,
-        AuthHandling::IgnoreAuth,
-        j_,
-        SpendableHandling::FullBalance);
+    auto const accountBalanceAfter = conservationBalance(view, accountID_, asset, j_);
     auto const vaultBalanceAfter = accountID_ == vaultPseudoAccount
         ? STAmount{asset, 0}
-        : accountHolds(
-              view,
-              vaultPseudoAccount,
-              asset,
-              FreezeHandling::IgnoreFreeze,
-              AuthHandling::IgnoreAuth,
-              j_,
-              SpendableHandling::FullBalance);
-    auto const brokerBalanceAfter = accountID_ == brokerPayee ? STAmount{asset, 0}
-                                                              : accountHolds(
-                                                                    view,
-                                                                    brokerPayee,
-                                                                    asset,
-                                                                    FreezeHandling::IgnoreFreeze,
-                                                                    AuthHandling::IgnoreAuth,
-                                                                    j_,
-                                                                    SpendableHandling::FullBalance);
+        : conservationBalance(view, vaultPseudoAccount, asset, j_);
+    auto const brokerBalanceAfter = accountID_ == brokerPayee
+        ? STAmount{asset, 0}
+        : conservationBalance(view, brokerPayee, asset, j_);
     auto const balanceScale = [&]() {
         // Find a reasonable scale to use for the balance comparisons.
         //
@@ -816,7 +803,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..2def3d2eb2 100644
--- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp
@@ -10,6 +10,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,6 +23,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -53,7 +55,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 +226,8 @@ TER
 LoanSet::preclaim(PreclaimContext const& ctx)
 {
     auto const& tx = ctx.tx;
+    auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval);
+    auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal);
 
     {
         // Check for numeric overflow of the schedule before we load any
@@ -237,9 +241,6 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         static_assert(kMaxTime == 4'294'967'295);
 
         auto const timeAvailable = kMaxTime - getStartDate(ctx.view);
-
-        auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval);
-        auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal);
         auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod);
 
         // The grace period can't be larger than the interval. Check it first,
@@ -309,6 +310,32 @@ LoanSet::preclaim(PreclaimContext const& ctx)
         return tefBAD_LEDGER;  // LCOV_EXCL_LINE
     }
 
+    if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        auto const phase = getVaultPhase(ctx.view, vault);
+        if (phase == VaultPhase::Subscription)
+        {
+            JLOG(ctx.j.warn()) << "Vault is still in the subscription phase.";
+            return tecTOO_SOON;
+        }
+        if (phase == VaultPhase::Redemption)
+        {
+            JLOG(ctx.j.warn()) << "Vault has entered the redemption phase.";
+            return tecEXPIRED;
+        }
+        if (phase == VaultPhase::Investment)
+        {
+            auto const finalPayment =
+                std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total);
+            if (finalPayment >= vault->at(sfRedemptionDate))
+            {
+                JLOG(ctx.j.warn()) << "Final loan payment date is on or after "
+                                      "the vault's redemption date.";
+                return tecNO_PERMISSION;
+            }
+        }
+    }
+
     if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum))
     {
         JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan.";
@@ -439,12 +466,12 @@ LoanSet::doApply()
         principalRequested,
         properties.loanState.managementFeeDue);
 
-    auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum);
     XRPL_ASSERT_PARTS(
-        vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy,
+        *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
         "xrpl::LoanSet::doApply",
         "Vault is below maximum limit");
-    if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy)
+
+    if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
     {
         JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault";
         return tecLIMIT_EXCEEDED;
@@ -490,8 +517,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 +621,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 +663,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 +671,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..c8b00f0193 100644
--- a/src/libxrpl/tx/transactors/payment/Payment.cpp
+++ b/src/libxrpl/tx/transactors/payment/Payment.cpp
@@ -281,7 +281,7 @@ Payment::preflight(PreflightContext const& ctx)
         }
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
index b8118bc49f..9143a675f6 100644
--- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
+++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp
@@ -87,7 +87,7 @@ PaymentChannelClaim::preflight(PreflightContext const& ctx)
             return temBAD_SIGNATURE;
     }
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/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..c3131714f8 100644
--- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
+++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -412,9 +413,24 @@ SponsorshipTransfer::doApply()
             if (!oldSponsorSle)
                 return tefINTERNAL;  // LCOV_EXCL_LINE
 
-            // The owner reclaims the reserve burden when the object is no longer sponsored.
-            // We do not check the sponsee's reserve here (via `checkReserve`) so that a sponsor can
-            // always end a sponsorship, even if the sponsee lacks sufficient reserve.
+            // The owner reclaims the reserve burden when the object is no longer
+            // sponsored, so it must be able to hold that reserve on its own once the
+            // sponsorship is removed. This mirrors the account-level End check below,
+            // keeping the behavior consistent across accounts and objects: a
+            // sponsorship can only be ended if the sponsee self-funds, another sponsor
+            // steps in (Reassign), or the object/account is deleted.
+            if (view().rules().enabled(fixCleanup3_4_0))
+            {
+                if (auto const ter = checkReserve(
+                        ctx_.getApplyViewContext(),
+                        sponseeSle,
+                        balanceBeforeFee(sponseeSle),
+                        SLE::pointer(),
+                        {.ownerCountDelta = ownerCountDelta},
+                        ctx_.journal);
+                    !isTesSuccess(ter))
+                    return ter;
+            }
 
             // Decrement sponsored count
             if (auto const ter = decrementSponsorCount(
diff --git a/src/libxrpl/tx/transactors/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..19ec99702a 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp
@@ -1,6 +1,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -70,7 +71,14 @@ ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx)
 
     // Sanity check: account must be the same as issuer
     if (sleIssuance->getAccountID(sfIssuer) != account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::preclaim : preflight already validated the "
+            "submitter is the issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Check if issuance has issuer ElGamal public key
     if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey))
@@ -127,7 +135,14 @@ ConfidentialMPTClawback::doApply()
     auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder));
 
     if (!sleIssuance || !sleHolderMPToken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : preclaim already validated these "
+            "objects exist");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const clawAmount = ctx_.tx[sfMPTAmount];
 
@@ -137,11 +152,25 @@ ConfidentialMPTClawback::doApply()
     // After clawback, the balance should be encrypted zero.
     auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID);
     if (!encZeroForHolder)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail "
+            "for an already-valid holder public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID);
     if (!encZeroForIssuer)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail "
+            "for an already-valid issuer public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Set holder's confidential balances to encrypted zero
     (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder;
@@ -154,14 +183,28 @@ ConfidentialMPTClawback::doApply()
         // Sanity check: the issuance must have an auditor public key if
         // auditing is enabled.
         if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey))
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTClawback::doApply : the holder's auditor balance implies "
+                "the issuance has an auditor public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey];
 
         auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID);
 
         if (!encZeroForAuditor)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot "
+                "fail for an already-valid auditor public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor);
     }
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
index 454eb39ead..5be3892151 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,7 +90,14 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on the
     // issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
     bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey);
@@ -207,11 +215,25 @@ ConfidentialMPTConvert::doApply()
 
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the MPToken "
+            "exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the issuance "
+            "exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const amtToConvert = ctx_.tx[sfMPTAmount];
     auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
@@ -273,7 +295,14 @@ ConfidentialMPTConvert::doApply()
         if (auditorEc)
         {
             if (!sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
-                return tecINTERNAL;  // LCOV_EXCL_LINE
+            {
+                // LCOV_EXCL_START
+                UNREACHABLE(
+                    "xrpl::ConfidentialMPTConvert::doApply : issuance-level auditing implies "
+                    "the MPToken already carries an auditor balance");
+                return tecINTERNAL;
+                // LCOV_EXCL_STOP
+            }
 
             auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]);
             if (!sum)
@@ -308,7 +337,14 @@ ConfidentialMPTConvert::doApply()
             (*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
 
         if (!zeroBalance)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::ConfidentialMPTConvert::doApply : canonical zero encryption cannot fail "
+                "for an already-valid holder public key");
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance);
     }
@@ -316,7 +352,12 @@ ConfidentialMPTConvert::doApply()
     {
         // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should
         // exist together
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvert::doApply : confidential balance fields must be all "
+            "present or all absent");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     view().update(sleIssuance);
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
index 87f9e476d6..1e3617ffbd 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -72,7 +73,14 @@ verifyProofs(
     std::shared_ptr const& mptoken)
 {
     if (!mptoken->isFieldPresent(sfHolderEncryptionKey))
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::verifyProofs : preclaim already validated the holder encryption key is "
+            "present");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const mptIssuanceID = tx[sfMPTokenIssuanceID];
     auto const account = tx[sfAccount];
@@ -169,7 +177,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on
     // the issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == account)
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
     if (!sleMptoken)
@@ -185,7 +200,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx)
     // Sanity check: holder's MPToken must have auditor balance field if auditing
     // is enabled
     if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance))
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::preclaim : issuance-level auditing implies the "
+            "MPToken already carries an auditor balance");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // if the total circulating confidential balance is smaller than what the
     // holder is trying to convert back, we know for sure this txn should
@@ -215,11 +237,25 @@ ConfidentialMPTConvertBack::doApply()
 
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the "
+            "MPToken exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID));
     if (!sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the "
+            "issuance exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const amtToConvertBack = ctx_.tx[sfMPTAmount];
     auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0);
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
index 0b98382a61..6485578cb4 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -49,7 +50,14 @@ ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx)
     // already checked in preflight, but should also check that issuer on the
     // issuance isn't the account either
     if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::preclaim : issuer derived from the MPT ID must "
+            "match the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const sleMptoken =
         ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount]));
@@ -82,14 +90,26 @@ ConfidentialMPTMergeInbox::doApply()
     auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID];
     auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_));
     if (!sleMptoken)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated the "
+            "MPToken exists");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // sanity check
     if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) ||
         !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) ||
         !sleMptoken->isFieldPresent(sfHolderEncryptionKey))
     {
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated these "
+            "fields are present");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     // Merge inbox into spending: spending = spending + inbox
@@ -114,7 +134,14 @@ ConfidentialMPTMergeInbox::doApply()
         encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID);
 
     if (!zeroEncryption)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTMergeInbox::doApply : canonical zero encryption cannot fail "
+            "for an already-valid holder public key");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption);
 
diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
index d121ec2634..e713ae5029 100644
--- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
+++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp
@@ -2,6 +2,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -82,7 +83,7 @@ ConfidentialMPTSend::preflight(PreflightContext const& ctx)
     if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount]))
         return temBAD_CIPHERTEXT;
 
-    if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err))
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
         return err;
 
     return tesSUCCESS;
@@ -105,7 +106,14 @@ verifySendProofs(
 {
     // Sanity check
     if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::detail::verifySendProofs : caller must pre-validate sender/destination/"
+            "issuance existence");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount);
 
@@ -204,7 +212,14 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
 
     // Sanity check: issuer isn't the sender
     if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount])
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::preclaim : issuer derived from the MPT ID must match "
+            "the ledger's stored issuer");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Check sender's MPToken existence
     auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account));
@@ -238,7 +253,12 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx)
         (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) ||
          !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance)))
     {
-        return tefINTERNAL;  // LCOV_EXCL_LINE
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::preclaim : issuance-level auditing implies both "
+            "MPTokens already carry an auditor balance");
+        return tefINTERNAL;
+        // LCOV_EXCL_STOP
     }
 
     // Check lock
@@ -283,7 +303,14 @@ ConfidentialMPTSend::doApply()
     auto const sleDestAcct = view().read(keylet::account(destination));
 
     if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct)
-        return tecINTERNAL;  // LCOV_EXCL_LINE
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE(
+            "xrpl::ConfidentialMPTSend::doApply : preclaim already validated these objects "
+            "exist");
+        return tecINTERNAL;
+        // LCOV_EXCL_STOP
+    }
 
     // Deposit preauth authorization was already verified in preclaim.
     // Remove any expired credentials.
@@ -353,7 +380,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedDestEc = rerandomizeCiphertext(
             destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge);
         if (!rerandomizedDestEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination inbox ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox];
         auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc);
@@ -374,7 +407,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedIssuerEc =
             rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge);
         if (!rerandomizedIssuerEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination issuer ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance];
         auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc);
@@ -396,7 +435,13 @@ ConfidentialMPTSend::doApply()
         auto rerandomizedAuditorEc = rerandomizeCiphertext(
             *auditorEc, (*sleIssuance)[sfAuditorEncryptionKey], sendChallenge);
         if (!rerandomizedAuditorEc)
-            return tecINTERNAL;  // LCOV_EXCL_LINE
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx_.journal.error())
+                << "ConfidentialMPTSend failed to rerandomize destination auditor ciphertext.";
+            return tecINTERNAL;
+            // LCOV_EXCL_STOP
+        }
 
         auto const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance];
         auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *rerandomizedAuditorEc);
diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
index 0aeb6f33d1..c19b8f64d7 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
@@ -37,6 +37,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 {
     auto const accountID = ctx.tx[sfAccount];
     auto const holderID = ctx.tx[~sfHolder];
+    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
 
     // if non-issuer account submits this tx, then they are trying either:
     // 1. Unauthorize/delete MPToken
@@ -51,9 +52,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
         // There is an edge case where all holders have zero balance, issuance
         // is legally destroyed, then outstanding MPT(s) are deleted afterwards.
-        // Thus, there is no need to check for the existence of the issuance if
-        // the MPT is being deleted with a zero balance. Check for unauthorize
-        // before fetching the MPTIssuance object.
+        // Thus, the unauthorize/delete path below does not require the issuance
+        // to exist when the MPT is being deleted with a zero balance.
 
         // if holder wants to delete/unauthorize a mpt
         if (ctx.tx.isFlag(tfMPTUnauthorize))
@@ -63,8 +63,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
             if ((*sleMpt)[sfMPTAmount] != 0)
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
@@ -73,21 +71,24 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
 
             if ((*sleMpt)[~sfLockedAmount].value_or(0) != 0)
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
                 if (!sleMptIssuance)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
 
                 return tecHAS_OBLIGATIONS;
             }
-            if (ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked))
+            if (ctx.view.rules().enabled(fixCleanup3_4_0))
+            {
+                if (sleMptIssuance && sleMpt->isFlag(lsfMPTLocked))
+                    return tecNO_PERMISSION;
+            }
+            else if (
+                ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked))
+            {
                 return tecNO_PERMISSION;
+            }
 
             if (ctx.view.rules().enabled(featureConfidentialTransfer))
             {
-                auto const sleMptIssuance =
-                    ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
-
                 // if there still existing encrypted balances of MPT in
                 // circulation
                 if (sleMptIssuance &&
@@ -106,9 +107,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
         }
 
         // Now test when the holder wants to hold/create/authorize a new MPT
-        auto const sleMptIssuance =
-            ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
-
         if (!sleMptIssuance)
             return tecOBJECT_NOT_FOUND;
 
@@ -126,7 +124,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     if (!sleHolder)
         return tecNO_DST;
 
-    auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID]));
     if (!sleMptIssuance)
         return tecOBJECT_NOT_FOUND;
 
diff --git a/src/libxrpl/tx/transactors/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..e8fd2e22b6 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,11 +108,12 @@ 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))
@@ -207,40 +181,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,7 +214,8 @@ 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;
     }
 
@@ -266,27 +233,29 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx)
         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)
+        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance)
     {
         return tecNO_PERMISSION;
     }
 
     if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) &&
-        !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount)
+        !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) &&
+         ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) &&
         (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
     {
         return tecNO_PERMISSION;  // LCOV_EXCL_LINE
@@ -327,23 +296,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:
diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index d77286b667..d0eeaed071 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -383,6 +383,20 @@ VaultClawback::doApply()
     if (sharesDestroyed == beast::kZero)
         return tecPRECISION_LOSS;
 
+    // A recovered amount can be genuinely non-zero yet still be dust relative to a
+    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's significant-digit
+    // precision: subtracting it below rounds the stored total right back to where it started.
+    // The shares still move, so ValidVault would fail after the fact with "clawback must
+    // decrease vault balance" instead of a clean upfront rejection.
+    if (view().rules().enabled(fixCleanup3_4_0) &&
+        (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) ||
+         debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered)))
+    {
+        JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault"
+                            " balance";
+        return tecPRECISION_LOSS;
+    }
+
     assetsTotal -= assetsRecovered;
     assetsAvailable -= assetsRecovered;
     view().update(vault);
diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp
index 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/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
index aa9cfc8537..5ee948bbba 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
@@ -2,17 +2,21 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +50,39 @@ roundToVaultScale(STAmount const& amount, SLE::const_ref vault)
     return roundToScale(amount, postScale, Number::RoundingMode::Downward);
 }
 
+// True if debiting `assets` would leave the depositor's balance where it started, so the deposit
+// would mint shares against a transfer that never happened. Asking the balance directly whether it
+// notices the debit avoids having to infer the rounding step: it has to be the stored balance that
+// answers, because that magnitude is what governs the rounding, and it is not the same as the
+// spendable amount, which also counts what the counterparty's limit allows.
+[[nodiscard]]
+static bool
+roundsToZeroForDepositor(
+    ReadView const& view,
+    AccountID const& account,
+    STAmount const& assets,
+    beast::Journal j)
+{
+    if (assets.integral())
+        return false;
+
+    auto const balance = accountHolds(
+        view,
+        account,
+        assets.asset(),
+        FreezeHandling::ZeroIfFrozen,
+        AuthHandling::ZeroIfUnauthorized,
+        j,
+        SpendableHandling::SimpleBalance);
+
+    if (balance - assets != balance)
+        return false;
+
+    JLOG(j.warn()) << "VaultDeposit: amount " << assets.getFullText()
+                   << " leaves the depositor's balance " << balance.getFullText() << " unchanged";
+    return true;
+}
+
 NotTEC
 VaultDeposit::preflight(PreflightContext const& ctx)
 {
@@ -71,6 +108,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);
@@ -196,6 +244,7 @@ TER
 VaultDeposit::doApply()
 {
     bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
+    bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     auto applyViewContext = ctx_.getApplyViewContext();
     if (!vault)
@@ -296,6 +345,12 @@ VaultDeposit::doApply()
             return tecINTERNAL;
             // LCOV_EXCL_STOP
         }
+        // What a deposit transfers is not the requested amount but that amount truncated to a
+        // whole number of shares and converted back, which can be smaller. Only here is that
+        // value known rather than recomputed, so this is where it can be checked against the
+        // depositor's balance before anything moves.
+        if (fix340Enabled && roundsToZeroForDepositor(view(), accountID_, *maybeAssets, j_))
+            return tecPRECISION_LOSS;
         assetsDeposited = *maybeAssets;
     }
     catch (std::overflow_error const&)
diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
index 353b72c30d..40689572a0 100644
--- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,6 +28,13 @@
 
 namespace xrpl {
 
+bool
+VaultWithdraw::checkExtraFeatures(PreflightContext const& ctx)
+{
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) ||
+        (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0));
+}
+
 static WaiveUnrealizedLoss
 shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
 {
@@ -59,6 +67,9 @@ VaultWithdraw::preflight(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -73,6 +84,16 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     if (!vault)
         return tecNO_ENTRY;
 
+    if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
+    {
+        if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment)
+        {
+            JLOG(ctx.j.debug())
+                << "VaultWithdraw: vault withdrawal is not allowed in the investment phase.";
+            return tecTOO_SOON;
+        }
+    }
+
     auto const amount = ctx.tx[sfAmount];
     auto const vaultAsset = vault->at(sfAsset);
     auto const vaultShare = vault->at(sfShareMPTID);
@@ -103,6 +124,12 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // Validate credentials (if any) before canWithdraw, since canWithdraw may
+    // call credentials::authorizedDepositPreauth which assumes credentials
+    // already exist.
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err))
+        return err;
+
     if (fix313Enabled && amount.asset() == vaultShare)
     {
         // Post-fixCleanup3_1_3: if the user specified shares, convert
@@ -134,7 +161,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
                     account,
                     dstAcct,
                     *maybeAssets,
-                    ctx.tx.isFieldPresent(sfDestinationTag)))
+                    ctx.tx.isFieldPresent(sfDestinationTag),
+                    ctx.tx[~sfCredentialIDs]))
                 return ret;
         }
         catch (std::overflow_error const&)
@@ -273,6 +301,44 @@ VaultWithdraw::doApply()
         return tecPATH_DRY;
     }
 
+    // The "final withdrawal" rule below handles its own zero-value case using
+    // sfAssetsAvailable directly, so it is exempt from the checks below.
+    bool const isFinalWithdrawal =
+        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
+
+    auto assetsAvailable = vault->at(sfAssetsAvailable);
+    auto assetsTotal = vault->at(sfAssetsTotal);
+    auto const lossUnrealized = vault->at(sfLossUnrealized);
+    XRPL_ASSERT(
+        lossUnrealized <= (assetsTotal - assetsAvailable),
+        "xrpl::VaultWithdraw::doApply : loss and assets do balance");
+
+    if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal)
+    {
+        // A withdrawal for a fixed share amount (variable assets) has no requested-asset
+        // amount to check for rounding, unlike the fixed-assets branch above: a small enough
+        // share amount can round down to an exact zero even though the vault still holds
+        // positive effective value backing outstanding shares.
+        if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
+            assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets";
+            return tecPRECISION_LOSS;
+        }
+
+        // assetsWithdrawn can also be genuinely non-zero and still too small to move
+        // sfAssetsTotal or sfAssetsAvailable once canonicalized to STAmount's precision. Either
+        // way the shares still move, so ValidVault would otherwise fail after the fact instead
+        // of a clean upfront rejection.
+        if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) ||
+            debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn))
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
+                                " vault balance";
+            return tecPRECISION_LOSS;
+        }
+    }
+
     // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
     // (checkWithdrawFreeze), so IgnoreFreeze avoids a redundant check that
     // would incorrectly return zero for vault pseudo-accounts whose shares
@@ -287,13 +353,6 @@ VaultWithdraw::doApply()
         return tecINSUFFICIENT_FUNDS;
     }
 
-    auto assetsAvailable = vault->at(sfAssetsAvailable);
-    auto assetsTotal = vault->at(sfAssetsTotal);
-    auto const lossUnrealized = vault->at(sfLossUnrealized);
-    XRPL_ASSERT(
-        lossUnrealized <= (assetsTotal - assetsAvailable),
-        "xrpl::VaultWithdraw::doApply : loss and assets do balance");
-
     // The vault must have enough assets on hand.
     if (*assetsAvailable < assetsWithdrawn)
     {
@@ -309,8 +368,6 @@ VaultWithdraw::doApply()
     // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
     // the helper result should already equal that value, and any mismatch is a rounding artifact
     // worth logging.
-    bool const isFinalWithdrawal =
-        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
     if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
     {
         // Unreachable: a final withdrawal with lossUnrealized > 0 has
diff --git a/src/test/app/AMMCalc_test.cpp b/src/test/app/AMMCalc_test.cpp
index 74080e669c..23f251d57a 100644
--- a/src/test/app/AMMCalc_test.cpp
+++ b/src/test/app/AMMCalc_test.cpp
@@ -20,6 +20,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -188,8 +189,7 @@ class AMMCalc_test : public beast::unit_test::Suite
     static std::string
     toString(STAmount const& a)
     {
-        return (boost::format("%s/%s") % a.getText() % ::xrpl::to_string(a.get().currency))
-            .str();
+        return std::format("{}/{}", a.getText(), ::xrpl::to_string(a.get().currency));
     }
 
     static STAmount
diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp
index 6facafde4a..1d75c4db22 100644
--- a/src/test/app/AMMClawbackMPT_test.cpp
+++ b/src/test/app/AMMClawbackMPT_test.cpp
@@ -16,6 +16,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -137,7 +139,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             AMM amm(env, gw, btc(100), usd(100));
             env.close();
             amm.deposit(alice, 1'000);
-            env.close();
 
             // can not clawback when tfMPTCanClawback is not enabled
             env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION));
@@ -503,6 +504,150 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testAMMClawbackAmountRoundsToZero(FeatureBitset features)
+    {
+        // Ensure a clawback that rounds down to zero MPT fails with
+        // tecAMM_FAILED instead of silently burning the holder's LP.
+        testcase("test AMMClawback amount that rounds down to zero");
+        using namespace jtx;
+
+        Env env(*this, features);
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        env.fund(XRP(10'000'000), gw, alice, bob);
+        env.close();
+
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        // The clawed asset (amountRounded) rounds to zero while its XRP
+        // counterpart is always large.
+        {
+            MPTTester const mptBtc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const btc = mptBtc;
+
+            AMM amm(env, alice, btc(3), XRP(333'000));
+            amm.deposit(bob, btc(3), XRP(333'000));
+
+            [[maybe_unused]] auto const [poolBtcBefore, poolXrpBefore, lptBefore] = amm.balances();
+            BEAST_EXPECT(poolBtcBefore == btc(6));
+
+            auto const issuerOABefore = mptBtc.getBalance(gw);
+            auto const aliceLpBefore = amm.getLPTokensBalance(alice.id());
+            auto const bobLpBefore = amm.getLPTokensBalance(bob.id());
+
+            // Attempt to clawback 1/6th of the BTC pool. When the zero-rounding
+            // guard is active (gated by fixCleanup3_4_0) the rounded amount
+            // drops to 0 and should trigger tecAMM_FAILED.
+            env(amm::ammClawback(gw, alice, btc, XRP, btc(1)),
+                Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS}));
+            env.close();
+
+            [[maybe_unused]] auto const [poolBtcAfter, poolXrpAfter, lptAfter] = amm.balances();
+            auto const issuerOAAfter = mptBtc.getBalance(gw);
+            auto const aliceLpAfter = amm.getLPTokensBalance(alice.id());
+            auto const bobLpAfter = amm.getLPTokensBalance(bob.id());
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: Clawback fails because the BTC balance
+                // would round to zero. All balances must remain untouched.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolXrpAfter == poolXrpBefore);
+                BEAST_EXPECT(issuerOAAfter == issuerOABefore);
+                BEAST_EXPECT(aliceLpAfter == aliceLpBefore);
+                BEAST_EXPECT(bobLpAfter == bobLpBefore);
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: BTC rounds to zero and the clawback
+                // silently burns alice's LP without clawing back any BTC.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolXrpAfter < poolXrpBefore);
+                BEAST_EXPECT(issuerOAAfter == issuerOABefore);
+                BEAST_EXPECT(aliceLpAfter < aliceLpBefore);
+                BEAST_EXPECT(bobLpAfter == bobLpBefore);
+            }
+        }
+
+        // The pool above only ever rounds the clawed asset (amountRounded) to
+        // zero; its XRP counterpart is always large. Exercise the other operand
+        // of the guard (amount2Rounded == 0) with an MPT/MPT pool where the
+        // *paired* asset is the tiny integer that floors to zero while the
+        // clawed asset still rounds non-zero.
+        {
+            Account const carol{"carol"};
+            Account const dan{"dan"};
+            env.fund(XRP(10'000'000), carol, dan);
+            env.close();
+
+            MPTTester const mptBtc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {carol, dan},
+                 .pay = 100'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const btc = mptBtc;
+
+            MPTTester const mptEth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {carol, dan},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+            MPT const eth = mptEth;
+
+            // btc pool dwarfs the eth pool, so a ~1/12th claw withdraws a
+            // non-zero btc amount while the eth counterpart rounds to zero.
+            AMM amm(env, carol, btc(3'000), eth(3));
+            amm.deposit(dan, btc(3'000), eth(3));
+
+            [[maybe_unused]] auto const [poolBtcBefore, poolEthBefore, lptBefore] = amm.balances();
+            BEAST_EXPECT(poolBtcBefore == btc(6'000));
+            BEAST_EXPECT(poolEthBefore == eth(6));
+
+            auto const carolLpBefore = amm.getLPTokensBalance(carol.id());
+            auto const danLpBefore = amm.getLPTokensBalance(dan.id());
+
+            env(amm::ammClawback(gw, carol, btc, eth, btc(500)),
+                Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS}));
+            env.close();
+
+            [[maybe_unused]] auto const [poolBtcAfter, poolEthAfter, lptAfter] = amm.balances();
+            auto const carolLpAfter = amm.getLPTokensBalance(carol.id());
+            auto const danLpAfter = amm.getLPTokensBalance(dan.id());
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: clawback fails because the ETH (Asset2)
+                // balance would round to zero (guard fires via
+                // amount2Rounded == 0). All balances must remain untouched.
+                BEAST_EXPECT(poolBtcAfter == poolBtcBefore);
+                BEAST_EXPECT(poolEthAfter == poolEthBefore);
+                BEAST_EXPECT(carolLpAfter == carolLpBefore);
+                BEAST_EXPECT(danLpAfter == danLpBefore);
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: the asymmetric round-off goes through.
+                // btc is clawed (non-zero) but eth rounds to zero, so the eth
+                // pool is untouched while carol's LP is burned. This asymmetry
+                // proves amount2Rounded == 0 is the trigger.
+                BEAST_EXPECT(poolBtcAfter < poolBtcBefore);
+                BEAST_EXPECT(poolEthAfter == poolEthBefore);
+                BEAST_EXPECT(carolLpAfter < carolLpBefore);
+                BEAST_EXPECT(danLpAfter == danLpBefore);
+            }
+        }
+    }
+
     void
     testAMMClawbackAll(FeatureBitset features)
     {
@@ -543,7 +688,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
 
             // gw clawback all BTC from alice
             amm.deposit(bob, btc(1'000'000000), usd(2000));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(3000), IOUAmount(3000000)));
 
             auto aliceBTC = env.balance(alice, btc);
@@ -921,7 +1065,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             BEAST_EXPECT(amm.expectBalances(btc(2'000'000000), usd(8'000), IOUAmount(4'000'000)));
 
             amm.deposit(bob, btc(1'000'000000), usd(4'000));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(12'000), IOUAmount(6'000'000)));
 
             auto aliceBTC = env.balance(alice, btc);
@@ -1335,6 +1478,60 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testClawbackCreatesMissingMPToken(FeatureBitset features)
+    {
+        testcase("test AMMClawback creates missing MPToken");
+        using namespace jtx;
+
+        auto test = [&](std::optional const clawAmount) {
+            Env env{*this, features};
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(1'000'000), gw, alice);
+            env.close();
+
+            MPTTester token(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 1'000,
+                 .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+                 .authHolder = true});
+
+            AMM ammAlice(env, alice, token(1'000), XRP(1'000));
+            env.close();
+            BEAST_EXPECT(env.balance(alice, token) == token(0));
+
+            // The holder can delete the zero-balance MPToken while still
+            // holding LP tokens. A regular AMMWithdraw remains subject to
+            // RequireAuth and cannot recreate the missing token.
+            token.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id())));
+            ammAlice.withdrawAll(alice, std::nullopt, Ter(tecNO_AUTH));
+            env.close();
+            BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id())));
+
+            // AMMClawback ignores authorization and must be able to recreate
+            // the holder MPToken so the issuer can recover MPT from the pool.
+            std::optional amount;
+            if (clawAmount)
+                amount = token(*clawAmount);
+            env(amm::ammClawback(gw, alice, token, XRP, amount));
+            env.close();
+
+            auto const sleMpt = env.le(keylet::mptoken(token.issuanceID(), alice.id()));
+            BEAST_EXPECT(sleMpt && sleMpt->isFlag(lsfMPTAuthorized));
+            env.require(Balance(alice, token(0)));
+
+            BEAST_EXPECT(clawAmount ? ammAlice.ammExists() : !ammAlice.ammExists());
+        };
+
+        test(std::nullopt);
+        test(400);
+    }
+
     void
     testSingleDepositAndClawback(FeatureBitset features)
     {
@@ -1361,7 +1558,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(XRP(100), btc(400), IOUAmount(200000)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(XRP(100), btc(800), IOUAmount{282842'712474619, -9}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1407,7 +1603,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1462,7 +1657,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200)));
             amm.deposit(alice, btc(400));
-            env.close();
             BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12}));
 
             auto aliceBTC = env.balance(alice, MPT(btc));
@@ -1669,7 +1863,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
             env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION));
 
             // Although USD is clawable with asfAllowTrustLineClawback.
-            // When tfClawTwoAssets is set, we will claw Asser2 as well.
+            // When tfClawTwoAssets is set, we will claw Asset2 as well.
             // But Asset2 is not clawable. tfMPTCanClawback was not set for BTC.
             env(amm::ammClawback(gw, alice, usd, btc, std::nullopt),
                 Txflags(tfClawTwoAssets),
@@ -1811,6 +2005,199 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         }
     }
 
+    // Test that AMMClawback succeeds when the LP has previously deleted both
+    // zero-balance MPToken objects in an MPT/MPT pool.  The fix changes the
+    // ValidMPTIssuance invariant threshold from > 1 to > 2 so that the two
+    // MPToken creations triggered by the internal AMMWithdraw are permitted.
+    void
+    testClawbackAfterDeletingMPTokens(FeatureBitset features)
+    {
+        testcase("test AMMClawback after holder deletes zero-balance MPTokens");
+        using namespace jtx;
+
+        // Partial clawback (one asset): verify both MPTokens are recreated and
+        // the non-claw asset is returned to alice.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(100'000), gw, alice);
+            env.close();
+
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            // Alice deposits everything into the MPT/MPT pool; her MPT
+            // balances drop to zero.
+            AMM const amm(env, alice, btc(10'000), eth(10'000));
+            env.close();
+            BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000}));
+
+            auto aliceBTC = env.balance(alice, btc);
+            auto aliceETH = env.balance(alice, eth);
+            BEAST_EXPECT(aliceBTC == btc(0));
+            BEAST_EXPECT(aliceETH == eth(0));
+
+            // Alice deletes both zero-balance MPTokens to reclaim reserves.
+            btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // gw claws back some BTC from alice's share in the pool.
+            // AMMWithdraw internally creates both missing MPTokens
+            // (mptokensCreated_ == 2); the invariant (> 2) allows this.
+            env(amm::ammClawback(gw, alice, btc, eth, btc(1'000)));
+            env.close();
+
+            // Both MPToken objects must have been recreated.
+            BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // The non-claw asset (eth) was returned to alice.
+            BEAST_EXPECT(env.balance(alice, eth) > aliceETH);
+            // The claw asset (btc) was burned; alice's btc balance stays 0.
+            env.require(Balance(alice, aliceBTC));
+            BEAST_EXPECT(amm.ammExists());
+        }
+
+        // Full clawback (two assets, tfClawTwoAssets): verify both MPTokens
+        // are recreated and the AMM is deleted when fully drained.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const alice{"alice"};
+            env.fund(XRP(100'000), gw, alice);
+            env.close();
+
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice},
+                 .pay = 10'000,
+                 .flags = tfMPTCanClawback | kMptDexFlags});
+
+            AMM const amm(env, alice, btc(10'000), eth(10'000));
+            env.close();
+
+            auto aliceBTC = env.balance(alice, btc);
+            auto aliceETH = env.balance(alice, eth);
+
+            btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+            // Full two-asset clawback: both assets are clawed and alice
+            // receives nothing back.  The AMM should be empty and deleted.
+            env(amm::ammClawback(gw, alice, btc, eth, std::nullopt), Txflags(tfClawTwoAssets));
+            env.close();
+
+            BEAST_EXPECT(!amm.ammExists());
+            // Both assets were clawed; alice's balances remain at zero.
+            env.require(Balance(alice, aliceBTC));
+            env.require(Balance(alice, aliceETH));
+        }
+    }
+
+    void
+    testClawbackCrossIssuerPairedAssetAuth(FeatureBitset features)
+    {
+        testcase("test AMMClawback recreates paired-issuer MPToken unauthorized");
+        using namespace jtx;
+
+        // Cross-issuer MPT/MPT pool: btc is issued by gw, eth by gw2, and both
+        // require authorization. Alice deposits her entire balance of both and
+        // deletes the resulting zero-balance MPTokens. When gw claws back its
+        // own asset (btc), the two-asset withdrawal must recreate both of
+        // Alice's MPTokens so the pool can pay her the paired asset. The
+        // recreated MPToken may only be auto-authorized for the clawback
+        // issuer's own asset (btc); the paired asset's issuer (gw2) never
+        // consented, so eth must be recreated *unauthorized*, leaving gw2 in
+        // control of its own token and preserving its RequireAuth guarantee.
+        Env env(*this, features);
+        Account const gw{"gateway"};
+        Account const gw2{"gateway2"};
+        Account const alice{"alice"};
+        env.fund(XRP(100'000), gw, gw2, alice);
+        env.close();
+
+        MPTTester btc(
+            {.env = env,
+             .issuer = gw,
+             .holders = {alice},
+             .pay = 10'000,
+             .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true});
+
+        MPTTester eth(
+            {.env = env,
+             .issuer = gw2,
+             .holders = {alice},
+             .pay = 10'000,
+             .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true});
+
+        // Alice deposits everything into the pool; her MPT balances drop to 0.
+        AMM const amm(env, alice, btc(10'000), eth(10'000));
+        env.close();
+        BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000}));
+        BEAST_EXPECT(env.balance(alice, btc) == btc(0));
+        BEAST_EXPECT(env.balance(alice, eth) == eth(0));
+
+        // Alice deletes both zero-balance MPTokens to reclaim reserves.
+        btc.authorize({.account = alice, .flags = tfMPTUnauthorize});
+        eth.authorize({.account = alice, .flags = tfMPTUnauthorize});
+        BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id())));
+        BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id())));
+
+        // gw (issuer of btc) claws back part of Alice's btc. This is a
+        // cross-issuer pool, so tfClawTwoAssets is not permitted: only btc is
+        // clawed back, while the paired eth is returned to Alice.
+        env(amm::ammClawback(gw, alice, btc, eth, btc(1'000)));
+        env.close();
+
+        // Both MPTokens were recreated so the withdrawal could pay Alice.
+        auto const sleBtc = env.le(keylet::mptoken(btc.issuanceID(), alice.id()));
+        auto const sleEth = env.le(keylet::mptoken(eth.issuanceID(), alice.id()));
+        BEAST_EXPECT(sleBtc);
+        BEAST_EXPECT(sleEth);
+
+        // The clawback issuer's own asset (btc) may be recreated authorized:
+        // gw has authority over its own token.
+        BEAST_EXPECT(sleBtc && sleBtc->isFlag(lsfMPTAuthorized));
+
+        // The paired asset (eth) is issued by gw2, who did not sign this
+        // transaction. It must be recreated *unauthorized* so gw2's RequireAuth
+        // is not bypassed. This is the core assertion for the cross-issuer fix.
+        BEAST_EXPECT(sleEth && !sleEth->isFlag(lsfMPTAuthorized));
+
+        // The clawback still completed: btc was clawed back (Alice keeps a zero
+        // btc balance) and the paired eth was delivered into Alice's now
+        // unauthorized, gw2-gated MPToken (non-zero raw balance).
+        BEAST_EXPECT(sleBtc && sleBtc->getFieldU64(sfMPTAmount) == 0);
+        BEAST_EXPECT(sleEth && sleEth->getFieldU64(sfMPTAmount) > 0);
+        BEAST_EXPECT(amm.ammExists());
+    }
+
     void
     run() override
     {
@@ -1819,11 +2206,17 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
         testInvalidRequest(all);
         testFeatureDisabled(all);
         testAMMClawbackAmount(all);
+        testAMMClawbackAmount(all - fixCleanup3_4_0);
+        testAMMClawbackAmountRoundsToZero(all);
+        testAMMClawbackAmountRoundsToZero(all - fixCleanup3_4_0);
         testAMMClawbackAll(all);
         testAMMClawbackAmountSameIssuer(all);
         testAMMClawbackAllSameIssuer(all);
         testAMMClawbackIssuesEachOther(all);
         testAssetFrozenOrLocked(all);
+        testClawbackCreatesMissingMPToken(all);
+        testClawbackAfterDeletingMPTokens(all);
+        testClawbackCrossIssuerPairedAssetAuth(all);
         testSingleDepositAndClawback(all);
         testLastHolderLPTokenBalance(all);
         testLastHolderLPTokenBalance(all - fixAMMv1_3 - fixAMMClawbackRounding);
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index ba416d8192..90bface1fb 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -2155,6 +2155,209 @@ class AMMClawback_test : public beast::unit_test::Suite
             }
             BEAST_EXPECT(env.balance(carol, eur) == eur(7750));
         }
+
+        // gw (USD issuer) individually freezes the AMM-USD trust line.
+        // AMMClawback must still succeed because the freeze invariant
+        // short-circuits before reaching the AMM line check (no receivers in
+        // the USD issuer's change set). Behavior is identical with or without
+        // fixCleanup3_4_0.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw individually freezes the AMM-USD trust line (AMM pseudo-account
+            // <-> gw), not alice's trust line.
+            env(trust(gw, STAmount{Issue{usd.currency, amm.ammAccount()}, 0}, tfSetFreeze));
+            env.close();
+
+            env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+            env.close();
+
+            env.require(Balance(alice, usd(1000)));
+            env.require(Balance(alice, eur(2500)));
+            BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+            BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+        }
+
+        // gw2 (EUR issuer) individually freezes the AMM-EUR trust line.
+        // The EUR flow (AMM → alice) is a genuine P2P transfer checked by the
+        // freeze invariant. Pre-fixCleanup3_4_0 the isAMMNode guard incorrectly
+        // blocked AMMClawback's overrideFreeze privilege on that trust line.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw2 individually freezes the AMM-EUR trust line.
+            env(trust(gw2, STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, tfSetFreeze));
+            env.close();
+
+            if (features[fixCleanup3_4_0])
+            {
+                // Post-fixCleanup3_4_0: overrideFreeze privilege applies to
+                // all freeze types on AMM trust lines.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+                env.close();
+
+                env.require(Balance(alice, usd(1000)));
+                env.require(Balance(alice, eur(2500)));
+                BEAST_EXPECT(
+                    amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+                BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: the isAMMNode guard prevents the
+                // overrideFreeze privilege from applying to individually-frozen
+                // AMM trust lines, so the invariant blocks the clawback.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED));
+            }
+        }
+
+        // gw2 (EUR issuer) globally freezes its issued assets. AMMClawback
+        // must still be able to return EUR from the AMM to alice.
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            env(fset(gw2, asfGlobalFreeze));
+            env.close();
+
+            env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+            env.close();
+
+            env.require(Balance(alice, usd(1000)));
+            env.require(Balance(alice, eur(2500)));
+            BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+            BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+        }
+
+        // Same as above but gw2 deep-freezes the AMM-EUR trust line.
+        if (features[featureDeepFreeze])
+        {
+            Env env(*this, features);
+            Account const gw{"gateway"};
+            Account const gw2{"gateway2"};
+            Account const alice{"alice"};
+            env.fund(XRP(1000000), gw, gw2, alice);
+            env.close();
+
+            env(fset(gw, asfAllowTrustLineClawback));
+            env.close();
+            env.require(Flags(gw, asfAllowTrustLineClawback));
+
+            auto const usd = gw["USD"];
+            env.trust(usd(100000), alice);
+            env(pay(gw, alice, usd(3000)));
+            env.close();
+
+            auto const eur = gw2["EUR"];
+            env.trust(eur(100000), alice);
+            env(pay(gw2, alice, eur(3000)));
+            env.close();
+
+            AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS));
+            env.close();
+
+            BEAST_EXPECT(
+                amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12}));
+
+            // gw2 deep-freezes the AMM-EUR trust line.
+            env(trust(
+                gw2,
+                STAmount{Issue{eur.currency, amm.ammAccount()}, 0},
+                tfSetFreeze | tfSetDeepFreeze));
+            env.close();
+
+            if (features[fixCleanup3_4_0])
+            {
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS));
+                env.close();
+
+                env.require(Balance(alice, usd(1000)));
+                env.require(Balance(alice, eur(2500)));
+                BEAST_EXPECT(
+                    amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13}));
+                BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13}));
+            }
+            else
+            {
+                // Pre-fixCleanup3_4_0: same isAMMNode guard issue blocks the
+                // clawback on deep-frozen AMM trust lines.
+                env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED));
+            }
+        }
     }
 
     void
@@ -2530,6 +2733,7 @@ class AMMClawback_test : public beast::unit_test::Suite
               // precision loss caught in transaction layer -> tecPRECISION_LOSS
               all - fixAMMClawbackRounding - featureMPTokensV2,
               all - featureMPTokensV2,
+              all - fixCleanup3_4_0,
               all})
         {
             testAMMClawbackSpecificAmount(features);
diff --git a/src/test/app/AMMExtendedMPT_test.cpp b/src/test/app/AMMExtendedMPT_test.cpp
index f04ea39f2b..5059128d4b 100644
--- a/src/test/app/AMMExtendedMPT_test.cpp
+++ b/src/test/app/AMMExtendedMPT_test.cpp
@@ -188,20 +188,28 @@ private:
             {features});
 
         // tfPassive -- place the offer without crossing it.
-        testAMM(
-            [&](AMM& ammAlice, Env& env) {
-                // Carol creates a passive offer that could cross AMM.
-                // Carol's offer should stay in the ledger.
-                auto const& btc = MPT(ammAlice[1]);
-                env(offer(carol_, XRP(100), btc(100), tfPassive));
-                env.close();
-                BEAST_EXPECT(ammAlice.expectBalances(XRP(10'100), btc(10'000), ammAlice.tokens()));
-                BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), btc(100)}}}));
-            },
-            {{XRP(10'100), gAmmmpt(10'000)}},
-            0,
-            std::nullopt,
-            {features});
+        {
+            Env env{*this, features};
+            fund(env, gw_, {alice_, carol_}, XRP(30'000'000));
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, carol_},
+                 .pay = 30'000'000,
+                 .flags = kMptDexFlags});
+
+            AMM const ammAlice(env, alice_, XRP(10'100'000), btc(10'000'000));
+
+            // Scale the exact-quality fixture up so the visual relationship
+            // stays clear: the passive CLOB offer has the same 1:1 quality as
+            // the generated AMM offer, so it should not cross.
+            env(offer(carol_, XRP(100'000), btc(100'000), tfPassive));
+            env.close();
+            BEAST_EXPECT(
+                ammAlice.expectBalances(XRP(10'100'000), btc(10'000'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), btc(100'000)}}}));
+        }
 
         // tfPassive -- cross only offers of better quality.
         testAMM(
@@ -1084,9 +1092,9 @@ private:
 
         // AMM is consumed up to the first cam Offer quality
         BEAST_EXPECT(ammCarol.expectBalances(
-            aBux(3'093'541'659'651'604), bBux(3'200'215'509'984'418), ammCarol.tokens()));
+            aBux(3'093'541'659'651'603), bBux(3'200'215'509'984'419), ammCarol.tokens()));
         BEAST_EXPECT(expectOffers(
-            env, cam, 1, {{Amounts{bBux(200'215'509'984'418), aBux(200'215'509'984'419)}}}));
+            env, cam, 1, {{Amounts{bBux(200'215'509'984'419), aBux(200'215'509'984'419)}}}));
     }
 
     void
@@ -1241,7 +1249,7 @@ private:
         BEAST_EXPECT(sa == XRP(100'000'000));
         // Bob gets ~99.99e12ETH. This is the amount Bob
         // can get out of AMM for 100,000,000XRP.
-        BEAST_EXPECT(equal(da, eth(99'999'900'000'100)));
+        BEAST_EXPECT(equal(da, eth(99'999'900'000'099)));
     }
 
     // carol holds ETH, sells ETH for XRP
@@ -1505,6 +1513,96 @@ private:
         }
     }
 
+    void
+    pathFindMPTAMMExecutableSourceAmount()
+    {
+        testcase("Path Find: MPT AMM source amount is executable");
+        using namespace jtx;
+
+        auto const checkQuote = [&](std::int64_t usdPool,
+                                    std::int64_t eurPool,
+                                    std::int64_t deliverAmount,
+                                    std::int64_t expectedSourceAmount) {
+            Env env = pathTestEnv();
+            env.fund(XRP(30'000), gw_, alice_, bob_, carol_);
+            env.close();
+
+            MPTTester const usd(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, bob_, carol_},
+                 .pay = usdPool,
+                 .flags = kMptDexFlags});
+
+            MPTTester const eur(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_, bob_, carol_},
+                 .pay = eurPool,
+                 .flags = kMptDexFlags});
+
+            AMM const ammCarol(env, carol_, usd(usdPool), eur(eurPool));
+            env.close();
+
+            STPathSet st;
+            STAmount sa, da;
+            auto const deliver = eur(deliverAmount);
+            std::tie(st, sa, da) = findPaths(
+                env,
+                alice_,
+                bob_,
+                deliver,
+                std::nullopt,
+                usd.issuanceID(),
+                std::nullopt,
+                std::nullopt);
+
+            // Each quote must execute when used as an exact-output SendMax.
+            BEAST_EXPECT(equal(da, deliver));
+            BEAST_EXPECT(equal(sa, usd(expectedSourceAmount)));
+            BEAST_EXPECT(!st.empty());
+
+            auto const before = eur.getBalance(bob_);
+            env(pay(alice_, bob_, deliver),
+                Json(jss::Paths, st.getJson(JsonOptions::Values::None)),
+                Sendmax(sa),
+                Txflags(tfNoRippleDirect));
+            BEAST_EXPECT(eur.getBalance(bob_) == before + deliverAmount);
+        };
+
+        struct TestCase
+        {
+            std::int64_t usdPool;
+            std::int64_t eurPool;
+            std::int64_t deliverAmount;
+            std::int64_t expectedSourceAmount;
+        };
+
+        // Cover the original 2:1 pool and the same pool scaled down by 1000.
+        // clang-format off
+        TestCase const testCases[] = {
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1,     .expectedSourceAmount = 3},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 2,     .expectedSourceAmount = 5},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 10,    .expectedSourceAmount = 21},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 100,   .expectedSourceAmount = 201},
+            {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1'000, .expectedSourceAmount = 2'003},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 1,     .expectedSourceAmount = 3},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 2,     .expectedSourceAmount = 5},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 10,    .expectedSourceAmount = 21},
+            {.usdPool = 2'000,     .eurPool = 1'000,     .deliverAmount = 100,   .expectedSourceAmount = 223},
+        };
+        // clang-format on
+
+        for (auto const& testCase : testCases)
+        {
+            checkQuote(
+                testCase.usdPool,
+                testCase.eurPool,
+                testCase.deliverAmount,
+                testCase.expectedSourceAmount);
+        }
+    }
+
     void
     testFalseDry(FeatureBitset features)
     {
@@ -3583,6 +3681,7 @@ private:
         pathFind01();
         pathFind02();
         pathFind06();
+        pathFindMPTAMMExecutableSourceAmount();
     }
 
     void
diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp
index bb532b361a..83c848b7c4 100644
--- a/src/test/app/AMMExtended_test.cpp
+++ b/src/test/app/AMMExtended_test.cpp
@@ -267,20 +267,39 @@ private:
             {features});
 
         // tfPassive -- place the offer without crossing it.
-        testAMM(
-            [&](AMM& ammAlice, Env& env) {
-                // Carol creates a passive offer that could cross AMM.
-                // Carol's offer should stay in the ledger.
-                env(offer(carol_, XRP(100), USD(100), tfPassive));
-                env.close();
-                BEAST_EXPECT(
-                    ammAlice.expectBalances(XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens()));
-                BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}}));
-            },
-            {{XRP(10'100), USD(10'000)}},
-            0,
-            std::nullopt,
-            {features});
+        if (features[featureMPTokensV2])
+        {
+            Env env{*this, features};
+            fund(env, gw_, {alice_, carol_}, XRP(30'000'000), {USD(30'000'000)});
+
+            AMM const ammAlice(env, alice_, XRP(10'100'000), USD(10'000'000));
+
+            // Scale the exact-quality fixture up so the visual relationship
+            // stays clear: the passive CLOB offer has the same 1:1 quality as
+            // the generated AMM offer, so it should not cross.
+            env(offer(carol_, XRP(100'000), USD(100'000), tfPassive));
+            env.close();
+            BEAST_EXPECT(
+                ammAlice.expectBalances(XRP(10'100'000), USD(10'000'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), USD(100'000)}}}));
+        }
+        else
+        {
+            testAMM(
+                [&](AMM& ammAlice, Env& env) {
+                    // Carol creates a passive offer that could cross AMM.
+                    // Carol's offer should stay in the ledger.
+                    env(offer(carol_, XRP(100), USD(100), tfPassive));
+                    env.close();
+                    BEAST_EXPECT(ammAlice.expectBalances(
+                        XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens()));
+                    BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}}));
+                },
+                {{XRP(10'100), USD(10'000)}},
+                0,
+                std::nullopt,
+                {features});
+        }
 
         // tfPassive -- cross only offers of better quality.
         testAMM(
@@ -1359,6 +1378,7 @@ private:
         testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3);
         testEnforceNoRipple(all_);
         testFillModes(all_);
+        testFillModes(all_ - featureMPTokensV2);
         testOfferCrossWithXRP(all_);
         testOfferCrossWithLimitOverride(all_);
         testCurrencyConversionEntire(all_);
diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp
index bf0bc5c7d7..ac9728ede1 100644
--- a/src/test/app/AMMMPT_test.cpp
+++ b/src/test/app/AMMMPT_test.cpp
@@ -27,19 +27,24 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -3269,6 +3274,48 @@ private:
                     ammAlice.expectBalances(MPT(ammAlice[1])(1), XRP(10'000), IOUAmount{100000}));
             },
             {{XRP(10'000), gAmmmpt(10'000)}});
+
+        // MPT/MPT equal withdrawal after LP deletes both zero-balance MPTokens.
+        // AMMWithdraw must recreate both missing MPTokens; the invariant allows
+        // up to two MPToken creations per AMMWithdraw/AMMClawback (threshold > 2).
+        {
+            Env env{*this};
+            env.fund(XRP(30'000), gw_, alice_);
+            env.close();
+            MPTTester btc(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 10'000,
+                 .flags = kMptDexFlags});
+            MPTTester eth(
+                {.env = env,
+                 .issuer = gw_,
+                 .holders = {alice_},
+                 .pay = 10'000,
+                 .flags = kMptDexFlags});
+
+            // Alice deposits everything into the MPT/MPT pool; her MPT
+            // balances drop to zero.
+            AMM ammAlice(env, alice_, btc(10'000), eth(10'000));
+            BEAST_EXPECT(expectMPT(env, alice_, btc(0)));
+            BEAST_EXPECT(expectMPT(env, alice_, eth(0)));
+
+            // Alice deletes both zero-balance MPTokens to reclaim reserve.
+            btc.authorize({.account = alice_, .flags = tfMPTUnauthorize});
+            eth.authorize({.account = alice_, .flags = tfMPTUnauthorize});
+            BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice_.id())));
+            BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice_.id())));
+
+            // Equal withdrawal succeeds: both missing MPTokens are recreated
+            // (mptokensCreated_ == 2, which satisfies the > 2 invariant check).
+            ammAlice.withdrawAll(alice_);
+            BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice_.id())));
+            BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice_.id())));
+            BEAST_EXPECT(expectMPT(env, alice_, btc(10'000)));
+            BEAST_EXPECT(expectMPT(env, alice_, eth(10'000)));
+            BEAST_EXPECT(!ammAlice.ammExists());
+        }
     }
 
     void
@@ -3945,24 +3992,30 @@ private:
             [&](AMM& ammAlice, Env& env) {
                 // Bid a tiny amount
                 auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset};
+                auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0);
+                auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)};
+                auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny};
                 env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}}));
-                // Auction slot purchase price is equal to the tiny amount
-                // since the minSlotPrice is 0 with no trading fee.
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny}));
-                // The purchase price is too small to affect the total tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}}
+                               : ammAlice.tokens()));
                 // Bid the tiny amount
                 env(ammAlice.bid({
                     .account = alice_,
                     .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset},
                 }));
                 // Pay slightly higher price
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}}));
-                // The purchase price is still too small to affect the total
-                // tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(
+                    0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}}));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340
+                        ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}}
+                        : ammAlice.tokens()));
             },
             {{gAmmmpt(10'000'000'000), USD(10'000)}});
 
@@ -4041,9 +4094,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 +4106,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 +4954,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 +5063,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 +5118,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 +5725,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 +7276,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
     {
@@ -7157,6 +7495,7 @@ private:
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testClawback();
         testClawbackFromAMMAccount(all);
         testClawbackFromAMMAccount(all - featureSingleAssetVault);
@@ -7165,6 +7504,7 @@ private:
         testAMMTokens();
         testAmendment();
         testAMMAndCLOB(all);
+        testAMMOfferGenerationPolicy(all);
         testTradingFee(all);
         testTradingFee(all - fixAMMv1_3);
         testAdjustedTokens(all);
@@ -7178,6 +7518,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..15668d4d71 100644
--- a/src/test/app/AccountDelete_test.cpp
+++ b/src/test/app/AccountDelete_test.cpp
@@ -23,6 +23,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -31,10 +32,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -214,8 +217,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 +233,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 +330,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 +393,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 +425,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 +512,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 +549,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 +677,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 +697,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 +715,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 +728,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 +757,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 +1107,7 @@ public:
     void
     run() override
     {
+        auto const all{jtx::testableAmendments()};
         testBasics();
         testDirectories();
         testOwnedTypes();
@@ -1083,7 +1115,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 5085ad6172..c332b26a5b 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
@@ -498,7 +499,7 @@ class Batch_test : public beast::unit_test::Suite
             auto const batchFee = batch::calcBatchFee(env, 0, 2);
             auto tx1 = batch::Inner(pay(alice, bob, XRP(1)), seq + 1);
             tx1[jss::Fee] = "1.5";
-            env.setParseFailureExpected(true);
+            auto const g = env.getParseFailureGuard(true);
             try
             {
                 env(batch::outer(alice, seq, batchFee, tfAllOrNothing),
@@ -510,7 +511,6 @@ class Batch_test : public beast::unit_test::Suite
             {
                 BEAST_EXPECT(true);
             }
-            env.setParseFailureExpected(false);
         }
 
         // temSEQ_AND_TICKET: Batch: inner txn cannot have both Sequence
@@ -649,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);
@@ -3177,10 +3177,11 @@ 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));
+        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),
@@ -3199,7 +3200,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/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..6450ceeb61 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),
             });
@@ -880,11 +880,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,7 +893,7 @@ 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,
             });
@@ -965,15 +965,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 +982,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 +999,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,
@@ -5087,7 +5077,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 +5087,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 +5102,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 +5118,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 +5138,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             mptAlice.create({
                 .ownerCount = 1,
                 .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance,
-                .mutableFlags = tmfMPTCanEnableCanLock,
+                .immutableFlags = tifMPTCanLock,
             });
 
             mptAlice.authorize({
@@ -5200,14 +5190,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 +5210,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 +5233,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
             // confidential outstanding balance
             mptAlice.set({
                 .account = alice,
-                .mutableFlags = tmfMPTSetCanHoldConfidentialBalance,
+                .flags = tfMPTSetCanHoldConfidentialBalance,
                 .err = tecNO_PERMISSION,
             });
         }
@@ -5469,6 +5459,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)
     {
@@ -8143,6 +8556,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase
         testConvertBackWithAuditor(features);
         testConvertBackPedersenProof(features);
         testConvertBackBulletproof(features);
+        testConvertBackOverdraftBulletproof(features);
 
         // Homomorphic operation tests
         testSendHomomorphicOverflow(features);
@@ -8177,6 +8591,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..3ff90c2a8f 100644
--- a/src/test/app/Delegate_test.cpp
+++ b/src/test/app/Delegate_test.cpp
@@ -47,6 +47,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -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
@@ -2167,11 +2168,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 +2719,24 @@ class Delegate_test : public beast::unit_test::Suite
 
         std::size_t delegableCount = 0;
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, txDelegable, ...) \
-    if (txDelegable == xrpl::Delegable)                 \
-    {                                                   \
-        delegableCount++;                               \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                 \
+    if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \
+    {                                                                                \
+        delegableCount++;                                                            \
     }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
         // ====================================================================
         // IMPORTANT NOTICE:
@@ -2749,7 +2756,7 @@ class Delegate_test : public beast::unit_test::Suite
         // DO NOT modify expectedDelegableCount unless all scenarios, including
         // edge cases, have been fully tested and verified.
         // ====================================================================
-        std::size_t const expectedDelegableCount = 57;
+        std::size_t const expectedDelegableCount = 56;
 
         BEAST_EXPECTS(
             delegableCount == expectedDelegableCount,
@@ -2823,15 +2830,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));
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..72db63bd3f 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,7 +944,7 @@ 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));
             }
@@ -1072,7 +1073,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 +1097,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 +1119,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 +1145,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 +1189,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 +1230,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 +1264,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 +1321,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 +1344,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 +2703,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 +2931,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 +3283,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 +3307,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 +3321,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 +3341,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 +3382,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 +3414,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 +3439,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 +3683,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 +4227,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 +4248,8 @@ public:
             testMPTWithFeats(feats);
             testMPTWithFeats(feats - fixTokenEscrowV1);
         }
+        testMPTSplitEscrowTransferFee(all - fixCleanup3_4_0);
+        testMPTSplitEscrowTransferFee(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..49e3f9be94 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -26,11 +26,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -408,7 +410,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));
 
@@ -741,6 +743,164 @@ struct FlowMPT_test : public beast::unit_test::Suite
         return result;
     }
 
+    void
+    testOfferOwnerMPTCreation(FeatureBitset features)
+    {
+        using namespace jtx;
+        Account const alice("alice");
+        Account const bob("bob");
+        Account const carol("carol");
+        Account const gw("gw");
+
+        {
+            testcase("Reserve-edge offer owner cannot create another object");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const xrpOffer = ownerIncrement - drops(1);
+            auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
+
+            env.fund(XRP(10'000), alice, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10});
+
+            env(offer(bob, usd(1), xrpOffer));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
+
+            // This mirrors the full-crossing setup below. Bob has enough XRP
+            // for the resting offer, but not enough to pay a fee and add
+            // another owner-count object while the offer remains on ledger.
+            env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE));
+            env.close();
+
+            env.require(Owners(bob, 1));
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+        }
+
+        {
+            testcase("Reserve-edge offer owner creates MPToken during consume");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const xrpOffer = ownerIncrement - drops(1);
+            auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(bob, usd(1), xrpOffer));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // Bob has enough XRP for the resting offer but is close to
+            // reserve. The payment should not create Bob's USD MPToken until
+            // the offer is actually consumed, otherwise the temporary owner
+            // count increase can make the offer look underfunded during path
+            // execution.
+            env(pay(alice, carol, xrpOffer),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(carol, carolXRP + xrpOffer));
+            env.require(Balance(bob, usd(1)));
+            env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1));
+            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            BEAST_EXPECT(offersOnAccount(env, bob).empty());
+        }
+
+        {
+            testcase("Partial offer owner creates MPToken during consume");
+
+            Env env(*this, features);
+
+            auto const baseFee = env.current()->fees().base;
+            auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
+            auto const bobStart = reserve(env, 3) + baseFee;
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.fund(bobStart, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(bob, usd(2), drops(2 * ownerIncrement)));
+            env.close();
+
+            env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // Partial consumption leaves Bob's offer on the ledger, so he ends
+            // up owning both the remaining offer and a newly created MPToken.
+            // The MPToken is created regardless of reserve; this setup simply
+            // funds Bob enough that he still meets reserve(2) afterward (the
+            // under-reserved case is covered in OfferMPT_test's no-reserve-check
+            // testcase).
+            env(pay(alice, carol, drops(ownerIncrement)),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(carol, carolXRP + drops(ownerIncrement)));
+            env.require(Balance(bob, usd(1)));
+            env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2));
+            BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+            BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement)));
+        }
+
+        {
+            testcase("Issuer-owned offer does not create issuer MPToken");
+
+            Env env(*this, features);
+
+            env.fund(XRP(10'000), alice, carol, gw);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
+
+            env(pay(gw, alice, usd(1)));
+            env(offer(gw, usd(1), drops(1'000)));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
+            auto const carolXRP = env.balance(carol);
+
+            // The issuer can own an offer that receives its own MPT without an
+            // MPToken. Consuming that offer should keep the issuer side
+            // tokenless.
+            env(pay(alice, carol, drops(1'000)),
+                Path(~XRP),
+                Sendmax(usd(1)),
+                Txflags(tfNoRippleDirect));
+            env.close();
+
+            env.require(Balance(alice, usd(0)));
+            env.require(Balance(carol, carolXRP + drops(1'000)));
+            BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
+            BEAST_EXPECT(offersOnAccount(env, gw).empty());
+        }
+    }
+
     void
     testSelfPayment1(FeatureBitset features)
     {
@@ -2120,6 +2280,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testFalseDry(features);
         testDirectStep(features);
         testBookStep(features);
+        testOfferOwnerMPTCreation(features);
         testTransferRate(features);
         testSelfPayment1(features);
         testSelfPayment2(features);
diff --git a/src/test/app/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
index eaf1f2704c..dcd22ffda6 100644
--- a/src/test/app/Invariants_test.cpp
+++ b/src/test/app/Invariants_test.cpp
@@ -16,11 +16,13 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -42,6 +44,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -54,6 +57,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 
 #include 
@@ -64,6 +69,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -134,7 +141,12 @@ class Invariants_test : public beast::unit_test::Suite
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
         std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
         Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        // Result fed to the invariant checker on the first pass. Set it to a
+        // tec to exercise result-dependent invariants; the harness runs no
+        // transactor, so one never arises on its own.
+        TER initialResult = tesSUCCESS)
     {
         doInvariantCheck(
             makeEnv(defaultAmendments()),
@@ -144,7 +156,9 @@ class Invariants_test : public beast::unit_test::Suite
             tx,
             ters,
             preclose,
-            setTxAccount);
+            setTxAccount,
+            loc,
+            initialResult);
     }
 
     void
@@ -156,7 +170,9 @@ class Invariants_test : public beast::unit_test::Suite
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
         std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
         Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None)
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS)
     {
         using namespace test::jtx;
 
@@ -170,7 +186,8 @@ class Invariants_test : public beast::unit_test::Suite
         if (setTxAccount != TxAccount::None)
             tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
 
-        doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters);
+        doInvariantCheck(
+            std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult);
     }
 
     void
@@ -183,7 +200,9 @@ class Invariants_test : public beast::unit_test::Suite
         Precheck const& precheck,
         XRPAmount fee = XRPAmount{},
         STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED})
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS)
     {
         using namespace test::jtx;
 
@@ -202,31 +221,44 @@ class Invariants_test : public beast::unit_test::Suite
         if (!BEAST_EXPECT(transactor))
             return;
 
-        // invoke check twice to cover tec and tef cases
+        // Invoke the check twice to cover the tec and tef cases. Both passes run
+        // against the same view -- production would discard it in between -- so
+        // the second sees the same violation and escalates tec -> tef. A
+        // {tec, tef} pair therefore means "enforced whatever the incoming
+        // result", not that the transaction ends in tef on ledger.
         if (!BEAST_EXPECT(ters.size() == 2))
             return;
 
-        TER terActual = tesSUCCESS;
+        TER terActual = initialResult;
         for (TER const& terExpect : ters)
         {
-            terActual = transactor->checkInvariants(terActual, fee);
-            BEAST_EXPECTS(
+            TER const terInput = terActual;
+            terActual =
+                transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
+            expect(
                 terExpect == terActual,
-                "expected: " + transToken(terExpect) + " got: " + transToken(terActual));
+                "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
+                loc.file_name(),
+                loc.line());
             auto const messages = sink.messages().str();
 
-            if (!isTesSuccess(terActual))
+            // 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)
             {
-                BEAST_EXPECTS(
+                expect(
                     messages.starts_with("Invariant failed:") ||
                         messages.starts_with("Transaction caused an exception"),
-                    messages);
+                    messages,
+                    loc.file_name(),
+                    loc.line());
             }
 
             // std::cerr << messages << '\n';
             for (auto const& m : expectLogs)
             {
-                BEAST_EXPECTS(messages.contains(m), m);
+                expect(messages.contains(m), m, loc.file_name(), loc.line());
             }
         }
     }
@@ -436,16 +468,10 @@ class Invariants_test : public beast::unit_test::Suite
             XRPAmount{},
             STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
 
-        for (auto const& keyletInfo : kDirectAccountKeylets)
+        for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets)
         {
-            // TODO: Use structured binding once LLVM 16 is the minimum
-            // supported version. See also:
-            // https://github.com/llvm/llvm-project/issues/48582
-            // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c
-            if (!keyletInfo.includeInTests)
+            if (!includeInTests)
                 continue;
-            auto const& keyletfunc = keyletInfo.function;
-            auto const& type = keyletInfo.expectedLEName;
 
             using namespace std::string_literals;
 
@@ -633,8 +659,8 @@ class Invariants_test : public beast::unit_test::Suite
                 // 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));
+                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.
@@ -921,7 +947,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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]));
+                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));
@@ -935,7 +962,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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]));
+                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));
@@ -950,7 +978,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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]));
+                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));
@@ -974,7 +1003,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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));
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
                 sleNew->setFieldAmount(sfAmount, XRP(-1));
                 ac.view().insert(sleNew);
                 return true;
@@ -988,7 +1018,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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));
+                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));
@@ -1004,7 +1035,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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));
+                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);
@@ -1021,7 +1053,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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));
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
 
                 Issue const bad{badCurrency(), AccountID(0x4985601)};
                 STAmount const amt(bad, 1);
@@ -1038,7 +1071,8 @@ class Invariants_test : public beast::unit_test::Suite
                 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));
+                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);
@@ -1463,7 +1497,7 @@ class Invariants_test : public beast::unit_test::Suite
         std::uint32_t numCreds = 2,
         std::uint32_t seq = 10)
     {
-        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), seq);
+        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq));
         auto sle = std::make_shared(pdKeylet);
 
         sle->setAccountID(sfOwner, a1);
@@ -2005,7 +2039,7 @@ class Invariants_test : public beast::unit_test::Suite
             makeEnv(features),
             {{"domain doesn't exist"}},
             [](Account const& a1, Account const&, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a1.id(), 10);
+                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));
@@ -2032,7 +2066,7 @@ class Invariants_test : public beast::unit_test::Suite
             makeEnv(features),
             {{"hybrid offer is malformed"}},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a2.id(), 10);
+                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));
@@ -2067,7 +2101,7 @@ class Invariants_test : public beast::unit_test::Suite
                 a2,
                 {{"hybrid offer is malformed"}},
                 [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), 10);
+                    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));
@@ -2106,7 +2140,7 @@ class Invariants_test : public beast::unit_test::Suite
                 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);
+                    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));
@@ -2143,7 +2177,7 @@ class Invariants_test : public beast::unit_test::Suite
                 a2,
                 {{"hybrid offer is malformed"}},
                 [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), 10);
+                    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));
@@ -2176,7 +2210,7 @@ class Invariants_test : public beast::unit_test::Suite
                 a2,
                 {{"transaction consumed wrong domains"}},
                 [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), 10);
+                    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));
@@ -2213,7 +2247,7 @@ class Invariants_test : public beast::unit_test::Suite
                 a2,
                 {{"domain transaction affected regular offers"}},
                 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), 10);
+                    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));
@@ -2234,6 +2268,90 @@ class Invariants_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testPermissionedDEXDeletedOfferFallback()
+    {
+        using namespace test::jtx;
+
+        testcase << "PermissionedDEX null after";
+
+        // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that
+        // domain lands in the set finalize consults. after == null is never
+        // tracked (pre-340: after-only; post-340: early return) — same result,
+        // both sides are coverage/regression that we do not fall back to before.
+        auto const check = [this](
+                               FeatureBitset features,
+                               bool const afterIsNull,
+                               bool const isDelete,
+                               bool const expectInvariantFailure) {
+            Env env(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2);
+            env.close();
+
+            auto sleOffer =
+                std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10)));
+            sleOffer->setAccountID(sfAccount, a2);
+            sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+            sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+            sleOffer->setFieldH256(sfDomainID, pd1);
+
+            CurrentTransactionRulesGuard const rulesGuard(env.current()->rules());
+
+            ValidPermissionedDEX invariant;
+            if (afterIsNull)
+            {
+                // Defensive path: after is null. Must not fall back to before.
+                invariant.visitEntry(isDelete, sleOffer, nullptr);
+            }
+            else
+            {
+                // Normal / real-erase path: after is the offer on pd1.
+                invariant.visitEntry(isDelete, nullptr, sleOffer);
+            }
+
+            STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) {
+                              tx.setFieldH256(sfDomainID, pd2);
+                              tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                              tx.setFieldAmount(sfTakerGets, XRP(1));
+                          }};
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            bool const passed =
+                invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog);
+            BEAST_EXPECT(passed != expectInvariantFailure);
+            if (expectInvariantFailure)
+            {
+                BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains"));
+            }
+            else
+            {
+                BEAST_EXPECT(sink.messages().str().empty());
+            }
+        };
+
+        auto const pre = defaultAmendments() - fixCleanup3_4_0;
+        auto const post = defaultAmendments() | fixCleanup3_4_0;
+
+        // after == null: not tracked
+        check(pre, true, true, false);
+        check(post, true, true, false);
+
+        // after == offer on pd1
+        // pre-340: domainsOld_ (delete still inserted) → fail
+        check(pre, false, true, true);
+        // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail
+        check(post, false, true, false);
+        check(post, false, false, true);
+    }
+
     void
     testBookDirectoryExchangeRate()
     {
@@ -2412,9 +2530,9 @@ class Invariants_test : public beast::unit_test::Suite
         vaultID = vKeylet.key;
 
         // Create Loan Broker
-        using namespace loanBroker;
+        using namespace loan_broker;
 
-        auto const loanBrokerKeylet = keylet::loanBroker(a.id(), env.seq(a));
+        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));
 
@@ -2472,6 +2590,54 @@ class Invariants_test : public beast::unit_test::Suite
 
         // TODO: Loan Object
 
+        // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
+        // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
+        Keylet closedEndedVaultKeylet = keylet::amendments();
+        Preclose const createClosedEndedVault = [&, this](
+                                                    Account const& a, Account const&, Env& env) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create(
+                {.owner = a,
+                 .asset = xrpIssue(),
+                 .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            closedEndedVaultKeylet = keylet;
+            return BEAST_EXPECT(env.le(closedEndedVaultKeylet));
+        };
+
+        {
+            // Each mutation must keep the vault otherwise valid so that only the immutability check
+            // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind
+            // stays within the recognised range.
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(closedEndedVaultKeylet);
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createClosedEndedVault);
+            }
+        }
+
         {
             auto const mods = std::to_array>({
                 [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
@@ -2721,7 +2887,7 @@ class Invariants_test : public beast::unit_test::Suite
                         brokerKeylet = this->createLoanBroker(alice, env, asset);
                         if (!BEAST_EXPECT(env.le(brokerKeylet)))
                             return false;
-                        env(loanBroker::coverDeposit(alice, brokerKeylet.key, asset(10)));
+                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
                         env.close();
                         return BEAST_EXPECT(env.le(brokerKeylet));
                     };
@@ -2918,7 +3084,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -2939,7 +3105,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -2959,7 +3125,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -2980,7 +3146,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 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()));
@@ -2997,7 +3163,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3019,14 +3185,16 @@ class Invariants_test : public beast::unit_test::Suite
              "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 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(), ac.view().seq());
+                    auto const keylet =
+                        keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq()));
                     auto sleVault = ac.view().peek(keylet);
                     if (!sleVault)
                         return false;
@@ -3055,7 +3223,7 @@ class Invariants_test : public beast::unit_test::Suite
             [&](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 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()));
@@ -3075,7 +3243,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3097,7 +3265,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3122,7 +3290,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3209,7 +3377,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3236,7 +3404,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3260,11 +3428,11 @@ class Invariants_test : public beast::unit_test::Suite
              "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 negative",
              "assets available must not be greater than assets outstanding",
-             "assets outstanding must be positive"},
+             "assets outstanding must not be negative"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3289,14 +3457,14 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3312,7 +3480,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3328,7 +3496,7 @@ class Invariants_test : public beast::unit_test::Suite
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3345,7 +3513,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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());
+                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;
@@ -3362,7 +3530,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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());
+                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;
                                }));
@@ -3370,14 +3538,49 @@ class Invariants_test : public beast::unit_test::Suite
             XRPAmount{},
             STTx{
                 ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is
+        // allowed to change loss unrealized, so it isolates this check from the
+        // "must not change loss unrealized" invariant. Gated behind
+        // fixCleanup3_4_0 (see below).
+        doInvariantCheck(
+            {"loss unrealized must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
             {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
+        // Without fixCleanup3_4_0 the same state must NOT trip the invariant,
+        // preserving pre-amendment behavior (no fork risk).
+        doInvariantCheck(
+            makeEnv(defaultAmendments() - fixCleanup3_4_0),
+            {},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
+            {tesSUCCESS, tesSUCCESS},
+            precloseXrp,
+            TxAccount::A2);
+
         doInvariantCheck(
             {"set assets outstanding must not exceed assets maximum"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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;
                                }));
@@ -3389,9 +3592,9 @@ class Invariants_test : public beast::unit_test::Suite
             TxAccount::A2);
 
         doInvariantCheck(
-            {"assets maximum must be positive"},
+            {"assets maximum must not be negative"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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;
                                }));
@@ -3407,7 +3610,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3421,14 +3624,14 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
         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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3442,14 +3645,14 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {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(), ac.view().seq());
+                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);
@@ -3476,7 +3679,7 @@ class Invariants_test : public beast::unit_test::Suite
                 "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3502,7 +3705,7 @@ class Invariants_test : public beast::unit_test::Suite
                 "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3529,7 +3732,7 @@ class Invariants_test : public beast::unit_test::Suite
                 "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3551,9 +3754,10 @@ class Invariants_test : public beast::unit_test::Suite
             {
                 "created vault must be empty",
                 "create operation must not have updated a vault",
+                "invalid OutstandingAmount balance 0 9 0",
             },
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3567,7 +3771,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             [&](Account const& a1, Account const& a2, Env& env) {
                 Vault const vault{env};
                 auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
@@ -3577,11 +3781,11 @@ class Invariants_test : public beast::unit_test::Suite
 
         doInvariantCheck(
             {
-                "assets maximum must be positive",
+                "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(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3605,7 +3809,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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 const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 auto sleVault = ac.view().peek(keylet);
                 if (!sleVault)
                     return false;
@@ -3634,7 +3838,7 @@ class Invariants_test : public beast::unit_test::Suite
                 // 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 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()));
@@ -3690,7 +3894,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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 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()));
@@ -3749,7 +3953,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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 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()));
@@ -3790,7 +3994,7 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {"deposit must change vault balance"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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();
                                }));
@@ -3803,7 +4007,7 @@ class Invariants_test : public beast::unit_test::Suite
         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());
+                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;
                                }));
@@ -3811,7 +4015,7 @@ class Invariants_test : public beast::unit_test::Suite
             XRPAmount{},
             STTx{
                 ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -3822,7 +4026,7 @@ class Invariants_test : public beast::unit_test::Suite
         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());
+                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()));
@@ -3842,7 +4046,7 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfFee] = XRPAmount(100);
                     tx[sfAccount] = a3.id();
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp);
 
         doInvariantCheck(
@@ -3852,7 +4056,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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());
+                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()));
@@ -3868,14 +4072,14 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
         doInvariantCheck(
             {"deposit must change depositor balance"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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()));
@@ -3890,28 +4094,28 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
         doInvariantCheck(
             {"deposit must change depositor shares"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
                                    sample.accountShares.reset();
                                }));
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {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(), ac.view().seq());
+                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;
@@ -3919,7 +4123,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -3929,7 +4133,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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());
+                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;
@@ -3937,7 +4141,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -3948,7 +4152,7 @@ class Invariants_test : public beast::unit_test::Suite
                 (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
                 ac.view().update(sleA3);
 
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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;
                                }));
@@ -3961,7 +4165,7 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfDelegate] = a3.id();
                     tx[sfFee] = XRPAmount(2000);
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -3969,7 +4173,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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());
+                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;
@@ -3977,7 +4181,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -3985,7 +4189,7 @@ class Invariants_test : public beast::unit_test::Suite
         doInvariantCheck(
             {"withdrawal must change vault balance"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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();
                                }));
@@ -4002,7 +4206,7 @@ class Invariants_test : public beast::unit_test::Suite
         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());
+                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()));
@@ -4024,7 +4228,7 @@ class Invariants_test : public beast::unit_test::Suite
                     // This commented out line causes the invariant violation.
                     // tx[sfDestination] = A4.id();
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp);
 
         doInvariantCheck(
@@ -4036,7 +4240,7 @@ class Invariants_test : public beast::unit_test::Suite
                 "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());
+                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()));
@@ -4052,14 +4256,14 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {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(), ac.view().seq());
+                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;
                              })))
@@ -4073,35 +4277,35 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
         doInvariantCheck(
             {"withdrawal must change depositor shares"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
                                    sample.accountShares.reset();
                                }));
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {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(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) {
                                    sample.sharesTotal = 0;
                                }));
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -4110,7 +4314,7 @@ class Invariants_test : public beast::unit_test::Suite
              "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());
+                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;
@@ -4118,7 +4322,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -4126,7 +4330,7 @@ class Invariants_test : public beast::unit_test::Suite
             {"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());
+                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;
@@ -4134,7 +4338,7 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -4145,7 +4349,7 @@ class Invariants_test : public beast::unit_test::Suite
                 (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
                 ac.view().update(sleA3);
 
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq());
+                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;
                                }));
@@ -4158,7 +4362,7 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfDelegate] = a3.id();
                     tx[sfFee] = XRPAmount(2000);
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseXrp,
             TxAccount::A2);
 
@@ -4213,14 +4417,15 @@ class Invariants_test : public beast::unit_test::Suite
              "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);
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
                                    sample.accountShares->amount = 5;
                                }));
             },
             XRPAmount{},
             STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseMpt,
             TxAccount::A2);
 
@@ -4228,21 +4433,22 @@ class Invariants_test : public beast::unit_test::Suite
         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);
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) {
                                    sample.vaultAssets.reset();
                                }));
             },
             XRPAmount{},
             STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {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(), ac.view().seq());
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
                 return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
             },
             XRPAmount{},
@@ -4254,7 +4460,8 @@ class Invariants_test : public beast::unit_test::Suite
         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);
+                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{},
@@ -4267,7 +4474,8 @@ class Invariants_test : public beast::unit_test::Suite
              "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);
+                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;
                                }));
@@ -4279,13 +4487,14 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfAccount] = a3.id();
                     tx[sfHolder] = a4.id();
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseMpt);
 
         doInvariantCheck(
             {"clawback must change holder shares"},
             [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2);
+                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();
                                }));
@@ -4297,7 +4506,7 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfAccount] = a3.id();
                     tx[sfHolder] = a4.id();
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseMpt);
 
         doInvariantCheck(
@@ -4305,7 +4514,8 @@ class Invariants_test : public beast::unit_test::Suite
              "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);
+                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;
@@ -4319,8 +4529,288 @@ class Invariants_test : public beast::unit_test::Suite
                     tx[sfAccount] = a3.id();
                     tx[sfHolder] = a4.id();
                 }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseMpt);
+
+        // ─────────────────────────────────────────────────────────────
+        // Closed-ended vault invariants added in ValidVault::finalize (create must supply both
+        // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase,
+        // withdraw not in Investment, loan origination only in Investment.
+
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+
+        // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it
+        // from ac.view().seq(), which depends on how many env.close() calls preclose issued.
+        Keylet closedEndedKeylet = keylet::amendments();
+
+        // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with
+        // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and
+        // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub
+        // leaves the vault in Subscription.
+        auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) {
+            return [&, advanceBySub, doDeposit](
+                       Account const& a1, Account const& a2, Env& env) -> bool {
+                env.fund(XRP(1000), a3, a4);
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create(
+                    {.owner = a1,
+                     .asset = xrpIssue(),
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = red});
+                env(tx);
+                closedEndedKeylet = keylet;
+                if (doDeposit)
+                {
+                    env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+                }
+                if (advanceBySub >= 0)
+                    env.close(tp{d{sub + advanceBySub}});
+                return true;
+            };
+        };
+
+        // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance)
+        // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE
+        // states no legitimate transactor would produce.
+        auto const insertBareClosedEndedVault =
+            [closedEnded](
+                ApplyContext& ac,
+                Account const& owner,
+                std::optional subscriptionDate,
+                std::optional redemptionDate) -> bool {
+            auto const sequence = ac.view().seq();
+            auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence));
+            auto sleVault = std::make_shared(vaultKeylet);
+            auto const vaultPage = ac.view().dirInsert(
+                keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id()));
+            if (!vaultPage)
+                return false;
+            sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+            auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+            auto sleAccount = std::make_shared(keylet::account(pseudoId));
+            sleAccount->setAccountID(sfAccount, pseudoId);
+            sleAccount->setFieldAmount(sfBalance, STAmount{});
+            sleAccount->setFieldU32(sfSequence, 0);
+            sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+            sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+            ac.view().insert(sleAccount);
+
+            auto const sharesMptId = makeMptID(sequence, pseudoId);
+            auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+            auto sleShares = std::make_shared(sharesKeylet);
+            auto const sharesPage = ac.view().dirInsert(
+                keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+            if (!sharesPage)
+                return false;
+            sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+            sleShares->at(sfFlags) = 0;
+            sleShares->at(sfIssuer) = pseudoId;
+            sleShares->at(sfOutstandingAmount) = 0;
+            sleShares->at(sfSequence) = sequence;
+
+            sleVault->at(sfAccount) = pseudoId;
+            sleVault->at(sfFlags) = 0;
+            sleVault->at(sfSequence) = sequence;
+            sleVault->at(sfOwner) = owner.id();
+            sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}});
+            sleVault->at(sfAssetsTotal) = Number(0);
+            sleVault->at(sfAssetsAvailable) = Number(0);
+            sleVault->at(sfLossUnrealized) = Number(0);
+            sleVault->at(sfShareMPTID) = sharesMptId;
+            sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+            sleVault->at(sfVaultKind) = closedEnded;
+            if (subscriptionDate)
+                sleVault->at(sfSubscriptionDate) = *subscriptionDate;
+            if (redemptionDate)
+                sleVault->at(sfRedemptionDate) = *redemptionDate;
+
+            ac.view().insert(sleVault);
+            ac.view().insert(sleShares);
+            return true;
+        };
+
+        testcase << "Vault create closed-ended";
+
+        // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate.
+        doInvariantCheck(
+            {"closed-ended vault must have SubscriptionDate and RedemptionDate"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate;
+        // exercises the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMinInvestmentPeriod - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and
+        // is caught by the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right).
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMaxInvestmentPeriod;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        testcase << "Vault deposit closed-ended";
+
+        // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs
+        // simulates an otherwise valid deposit shape so only the phase invariant fires.
+        doInvariantCheck(
+            {"deposit only allowed in Subscription or NoPhase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault withdrawal closed-ended";
+
+        // A withdrawal from a closed-ended vault in the Investment phase.
+        doInvariantCheck(
+            {"withdrawal not allowed during Investment phase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault loan set";
+
+        // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires
+        // on any vault mutation; touching the vault SLE with no field change is sufficient.
+        doInvariantCheck(
+            {"loan origination only allowed in Investment phase"},
+            [&](Account const&, Account const&, ApplyContext& ac) {
+                auto sleVault = ac.view().peek(closedEndedKeylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false));
+
+        testcase << "Vault loan set - closed-ended final payment past "
+                    "RedemptionDate";
+
+        // A newly-created loan against a closed-ended vault must satisfy StartDate +
+        // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same
+        // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant
+        // catches it even when preclaim is bypassed.
+        Keylet closedEndedBrokerKeylet = keylet::amendments();
+        std::uint32_t closedEndedRed = 0;
+        doInvariantCheck(
+            {"closed-ended loan final payment must precede RedemptionDate"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                // Touch the vault so ValidVault::finalizeLoanSet sees an
+                // entry in afterVault_; the vault is in Investment, so
+                // finalizeLoanSet itself passes.
+                auto sleVault = ac.view().peek(closedEndedKeylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+
+                // Read the broker's next loan sequence to build the loan
+                // keylet the same way LoanSet::doApply would.
+                auto sleBroker = ac.view().peek(closedEndedBrokerKeylet);
+                if (!sleBroker)
+                    return false;
+                std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence);
+
+                // Synthesize a Loan whose final scheduled payment lands
+                // exactly at RedemptionDate: StartDate = red, interval = 60,
+                // remaining = 1 => red + 60 >= red.
+                auto sleLoan = std::make_shared(
+                    keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
+                sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
+                sleLoan->at(sfLoanSequence) = loanSeq;
+                sleLoan->at(sfBorrower) = a1.id();
+                sleLoan->at(sfStartDate) = closedEndedRed;
+                sleLoan->at(sfPaymentInterval) = 60;
+                sleLoan->at(sfPaymentRemaining) = 1;
+                sleLoan->at(sfTotalValueOutstanding) = Number(100);
+                sleLoan->at(sfPeriodicPayment) = Number(1);
+                ac.view().insert(sleLoan);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const&, Env& env) -> bool {
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+                closedEndedRed = red;
+
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create(
+                    {.owner = a1,
+                     .asset = xrpIssue(),
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = red});
+                env(tx);
+                closedEndedKeylet = keylet;
+
+                // Create the loan broker; LoanBrokerSet has no phase gate.
+                closedEndedBrokerKeylet =
+                    keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1)));
+                env(loan_broker::set(a1, keylet.key));
+
+                // Advance parent close time into Investment so
+                // ValidVault::finalizeLoanSet is satisfied.
+                env.close(tp{d{sub + 1}});
+                return true;
+            });
     }
 
     void
@@ -4363,7 +4853,8 @@ class Invariants_test : public beast::unit_test::Suite
                 if (!sle)
                     return false;
 
-                auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence]));
+                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));
@@ -4378,7 +4869,8 @@ class Invariants_test : public beast::unit_test::Suite
                 if (!sle)
                     return false;
 
-                auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence]));
+                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));
@@ -4435,7 +4927,7 @@ class Invariants_test : public beast::unit_test::Suite
                 },
                 XRPAmount{},
                 STTx{ttPAYMENT, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
                 [&](Account const& a1, Account const& a2, Env& env) {
                     Account const gw("gw");
                     env.fund(XRP(1'000), gw);
@@ -4465,6 +4957,199 @@ class Invariants_test : public beast::unit_test::Suite
                 return true;
             });
 
+        // The on-failure MPT checks (OutstandingAmount balance / transfer) apply
+        // to every non-tesSUCCESS result, with no per-result exemption: on a tec
+        // the transactor discards the view and re-applies only offer, trust
+        // line, NFT offer and credential deletions, so an MPT change reaching
+        // the invariant is a bug whatever the code. Seeded via initialResult.
+        {
+            MPTID id;
+            // preclose: gw issues an MPT held by A1 and A2.
+            auto const setup = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt(
+                    {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000});
+                id = mpt.issuanceID();
+                return true;
+            };
+
+            // Consistent mint: OutstandingAmount and A1's balance both grow by
+            // 10, so conservation holds and only the on-failure check fires.
+            Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleIss || !sleTok)
+                    return false;
+                (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10;
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleTok);
+                return true;
+            };
+
+            // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is
+            // unchanged, and CanTransfer keeps the ordinary transfer check
+            // quiet, so only the on-failure check fires.
+            Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleA = ac.view().peek(keylet::mptoken(id, a1.id()));
+                auto sleB = ac.view().peek(keylet::mptoken(id, a2.id()));
+                if (!sleIss || !sleA || !sleB)
+                    return false;
+                (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer;
+                (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10;
+                (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleA);
+                ac.view().update(sleB);
+                return true;
+            };
+
+            STTx const payment{ttPAYMENT, [](STObject&) {}};
+
+            // Negative controls: nothing fires on tesSUCCESS. Without these, the
+            // cases below would still pass if the result guard were dropped.
+            doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+
+            // tecKILLED and tecINCOMPLETE are not special: an MPT change paired
+            // with either fires, as with any other failure.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            // The same change under a third failure result: the check keys off
+            // "not tesSUCCESS", nothing finer.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A lock moves value within one holder, so it is not a two-sided
+            // transfer and the `senders || receivers` form is what catches it.
+            // OutstandingAmount and the holder total are unchanged, so the
+            // balance check stays quiet.
+            Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] < 10)
+                    return false;
+                // A fresh MPToken has no locked amount, so set it directly.
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10;
+                sleTok->setFieldU64(sfLockedAmount, 10);
+                ac.view().update(sleTok);
+                return true;
+            };
+            // Negative control: a lock is legitimate on tesSUCCESS.
+            doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            // The lock is caught under any failure result.
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A deleted MPToken has no amtAfter, so the sender/receiver counts
+            // skip it and only the deletedAuthorized_ term can catch it. That
+            // needs holders authorized but never paid, so the MPToken can be
+            // erased with a zero balance and OutstandingAmount untouched --
+            // otherwise the holder would register as a sender instead.
+            MPTID emptyId;
+            auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100});
+                emptyId = mpt.issuanceID();
+                return true;
+            };
+            Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                    return false;
+                ac.view().erase(sleTok);
+                return true;
+            };
+            // ValidMPTIssuance also reports the deletion, so assert on
+            // ValidMPTTransfer's message, which only the new check can produce.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                eraseToken,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupEmpty,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
         // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
         {
             Env env(*this, defaultAmendments());
@@ -5160,7 +5845,13 @@ class Invariants_test : public beast::unit_test::Suite
             std::make_pair(ttAMM_WITHDRAW, false),
             std::make_pair(ttPAYMENT, false),
             std::make_pair(ttPAYMENT, true)};
-        for (auto const enabled : {true, false})
+        // The two amendments that gate enforcement, in all four combinations.
+        FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0};
+        for (auto const gates :
+             {gatesEnabled,
+              gatesEnabled - featureMPTokensV2,
+              gatesEnabled - fixCleanup3_4_0,
+              FeatureBitset{}})
         {
             for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
             {
@@ -5171,7 +5862,7 @@ class Invariants_test : public beast::unit_test::Suite
                       0u})
                 {
                     MPTID id{};
-                    auto const isSuccess = !enabled || flag == 0 ||
+                    auto const isSuccess = !gates.any() || flag == 0 ||
                         (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
                         (tx == ttAMM_WITHDRAW &&
                          (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
@@ -5222,16 +5913,83 @@ class Invariants_test : public beast::unit_test::Suite
                             MPTTester const usd(
                                 {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100});
                             id = usd.issuanceID();
-                            if (!enabled)
-                            {
+                            // Either gate enforces, so both must be off to stay
+                            // advisory. Disable after setting up the MPT; the
+                            // next env.close() is what makes it take effect.
+                            if (!gates[featureMPTokensV2])
                                 env.disableFeature(featureMPTokensV2);
-                            }
+                            if (!gates[fixCleanup3_4_0])
+                                env.disableFeature(fixCleanup3_4_0);
                             return true;
                         });
                 }
             }
         }
 
+        // An orphan has a zero balance, so only deletion is legitimate (see
+        // "Skipping Deleted MPTs" in testConfidentialMPTTransfer).
+        {
+            MPTID orphanID;
+            auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) {
+                MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+                mpt.create({.flags = tfMPTCanTransfer});
+                orphanID = mpt.issuanceID();
+                // A2 is authorized but never paid, so its balance is zero and
+                // the issuance can be destroyed while its MPToken lives on.
+                mpt.authorize({.account = a2});
+                mpt.destroy();
+                return true;
+            };
+            // ValidMPTBalanceChanges also reports this, so assert on the
+            // orphan message, which only the missing-issuance branch produces.
+            doInvariantCheck(
+                {{"orphaned MPToken balance changed"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                        return false;
+                    (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                    ac.view().update(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan);
+            // Negative control: erasing the orphan is how it gets cleaned up.
+            doInvariantCheck(
+                {},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tesSUCCESS, tesSUCCESS},
+                setupOrphan);
+            // The same erase on a failure. The orphan branch continues, so only
+            // the pre-loop deletion check can report this one.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
         // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
         // through sfReferenceHolding to test the vault's underlying asset for
         // each changed holder.
@@ -5406,7 +6164,9 @@ class Invariants_test : public beast::unit_test::Suite
 
         for (bool const isMPT : {false, true})
         {
-            auto const error = isMPT ? TER(tecINVARIANT_FAILED) : TER(tefINVARIANT_FAILED);
+            // Under fixCleanup3_4_0 the MPT balance invariants also fire on the
+            // second pass, so both IOU and MPT pools now escalate to tef.
+            auto const error = TER(tefINVARIANT_FAILED);
             for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW})
             {
                 test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED);
@@ -5762,7 +6522,7 @@ class Invariants_test : public beast::unit_test::Suite
                 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;
+                    checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key;
                     env(check::create(a1, a2, XRP(1)));
                     return true;
                 });
@@ -5888,7 +6648,7 @@ class Invariants_test : public beast::unit_test::Suite
 
             OpenView ov{*env.current()};
 
-            auto const vaultKeylet = keylet::vault(a1.id(), ov.seq());
+            auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq()));
             auto sleVault = std::make_shared(vaultKeylet);
             sleVault->makeFieldAbsent(sfAccount);
             ov.rawInsert(sleVault);
@@ -5908,12 +6668,137 @@ class Invariants_test : public beast::unit_test::Suite
             auto transactor = makeTransactor(ac);
             if (!BEAST_EXPECT(transactor))
                 return;
-            TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{});
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
             BEAST_EXPECT(result == tecINVARIANT_FAILED);
             BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
         }
     }
 
+    void
+    testTxCheckException()
+    {
+        testcase << "txCheck exception";
+        using namespace jtx;
+
+        // A TxInvariantCheck that throws from the requested hook, so we can
+        // exercise checkInvariantsHelper's catch block via the
+        // transaction-specific layer (as opposed to the protocol layer,
+        // which testObjectHasPseudoAccount's last case already covers via a
+        // real Transactor's finalizeInvariants).
+        enum class ThrowFrom { VisitEntry, Finalize };
+
+        struct ThrowingTxInvariantCheck : TxInvariantCheck
+        {
+            ThrowFrom const throwFrom;
+
+            explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom)
+            {
+            }
+
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+                if (throwFrom == ThrowFrom::VisitEntry)
+                    throw std::runtime_error("test-injected visitEntry exception");
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                if (throwFrom == ThrowFrom::Finalize)
+                    throw std::runtime_error("test-injected finalize exception");
+                return true;
+            }
+        };
+
+        for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize})
+        {
+            Env env{*this};
+            Account const alice{"alice"};
+            env.fund(XRP(1000), alice);
+            env.close();
+
+            OpenView ov{*env.current()};
+            STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            // visitEntry only runs for entries the transaction touched, so
+            // make a modification for the traversal to report.
+            auto sle = ac.view().peek(keylet::account(alice.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            sle->at(sfSequence) = sle->at(sfSequence) + 1;
+            ac.view().update(sle);
+
+            ThrowingTxInvariantCheck throwing{throwFrom};
+            TER terActual = tesSUCCESS;
+            for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+            {
+                terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing);
+                BEAST_EXPECT(terExpect == terActual);
+                BEAST_EXPECT(sink.messages().str().contains(
+                    "Transaction caused an exception during invariant checks"));
+            }
+        }
+    }
+
+    void
+    testTxCheckFinalizeFalse()
+    {
+        testcase << "txCheck finalize returns false";
+        using namespace jtx;
+
+        // A TxInvariantCheck whose finalize returns false, so we can exercise
+        // the "Transaction has failed one or more transaction invariants"
+        // log path in checkInvariantsHelper independently of any real
+        // transactor. This is the transaction-layer analogue of the
+        // protocol-layer coverage in testObjectHasPseudoAccount / others.
+        struct FailingTxInvariantCheck : TxInvariantCheck
+        {
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                return false;
+            }
+        };
+
+        Env env{*this};
+        Account const alice{"alice"};
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        OpenView ov{*env.current()};
+        STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+        test::StreamSink sink{beast::Severity::Warning};
+        beast::Journal const jlog{sink};
+        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+        FailingTxInvariantCheck failing;
+        TER terActual = tesSUCCESS;
+        for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+        {
+            terActual = checkInvariants(ac, terActual, XRPAmount{}, failing);
+            BEAST_EXPECT(terExpect == terActual);
+            BEAST_EXPECT(sink.messages().str().contains(
+                "Transaction has failed one or more transaction invariants"));
+            // The protocol-layer log must not appear: only the tx-layer
+            // finalize failed here.
+            BEAST_EXPECT(!sink.messages().str().contains(
+                "Transaction has failed one or more global invariants"));
+        }
+    }
+
     void
     testConfidentialMPTTransfer()
     {
@@ -6100,7 +6985,9 @@ class Invariants_test : public beast::unit_test::Suite
             },
             XRPAmount{},
             STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            // Second pass is tef: the bumped MPTAmount also trips
+            // ValidMPTTransfer's on-failure check, which escalates the tec.
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
             precloseConfidential);
 
         // badVersion
@@ -6185,6 +7072,7 @@ public:
         testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
         testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
+        testPermissionedDEXDeletedOfferFallback();
         testBookDirectoryExchangeRate();
         testNoModifiedUnmodifiableFields();
         testValidPseudoAccounts();
@@ -6198,6 +7086,8 @@ public:
         testAMM();
         testObjectHasPseudoAccount();
         testSponsorship();
+        testTxCheckException();
+        testTxCheckFinalizeFalse();
     }
 };
 
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/LedgerNodeHelpers_test.cpp b/src/test/app/LedgerNodeHelpers_test.cpp
new file mode 100644
index 0000000000..a9e4e3ebfc
--- /dev/null
+++ b/src/test/app/LedgerNodeHelpers_test.cpp
@@ -0,0 +1,260 @@
+#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..0853affab7 100644
--- a/src/test/app/LedgerReplay_test.cpp
+++ b/src/test/app/LedgerReplay_test.cpp
@@ -28,7 +28,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -53,11 +52,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -280,13 +282,13 @@ public:
     send(std::shared_ptr const& m) override
     {
     }
-    [[nodiscard]] beast::IP::Endpoint
+    [[nodiscard]] beast::ip::Endpoint
     getRemoteAddress() const override
     {
         return {};
     }
     void
-    charge(Resource::Charge const& fee, std::string const& context = {}) override
+    charge(resource::Charge const& fee, std::string const& context = {}) override
     {
     }
     [[nodiscard]] id_t
@@ -333,7 +335,7 @@ public:
     setPublisherListSequence(PublicKey const&, std::size_t const) override
     {
     }
-    [[nodiscard]] uint256 const&
+    [[nodiscard]] uint256
     getClosedLedgerHash() const override
     {
         static uint256 const kHash{};
@@ -400,7 +402,7 @@ public:
 
 enum class PeerSetBehavior {
     Good,
-    Drop50,
+    DropAlternate,
     DropAll,
     DropSkipListReply,
     DropLedgerDeltaReply,
@@ -443,17 +445,13 @@ struct TestPeerSet : public PeerSet
         protocol::MessageType type,
         std::shared_ptr const& peer) override
     {
-        int dropRate = 0;
-        if (behavior == PeerSetBehavior::Drop50)
-        {
-            dropRate = 50;
-        }
-        else if (behavior == PeerSetBehavior::DropAll)
-        {
-            dropRate = 100;
-        }
+        if (behavior == PeerSetBehavior::DropAll)
+            return;
 
-        if (randInt(1, 100) <= dropRate)
+        // Drop every other message deterministically. Alternating drops
+        // still exercise the timeout/retry path while guaranteeing every
+        // subtask eventually gets a reply.
+        if (behavior == PeerSetBehavior::DropAlternate && sendCount++ % 2 == 0)
             return;
 
         switch (type)
@@ -498,6 +496,7 @@ struct TestPeerSet : public PeerSet
     LedgerReplayMsgHandler& remote;
     std::shared_ptr dummyPeer;
     PeerSetBehavior behavior;
+    std::atomic sendCount{0};
 };
 
 /**
@@ -958,7 +957,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 +982,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 +990,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 +1042,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 +1071,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 +1090,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 +1099,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 +1127,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 +1292,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 +1394,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
             case PeerSetBehavior::Good:
                 testcase("good network");
                 break;
-            case PeerSetBehavior::Drop50:
+            case PeerSetBehavior::DropAlternate:
                 testcase("network drops 50% messages");
                 break;
             case PeerSetBehavior::Repeat:
@@ -1514,6 +1600,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
     {
         testProofPath();
         testReplayDelta();
+        testTruncatedHeader();
         testTaskParameter();
         testConfig();
         testHandshake();
@@ -1523,7 +1610,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite
         testAllInboundLedgers(4);
         testPeerSetBehavior(PeerSetBehavior::Good, 1);
         testPeerSetBehavior(PeerSetBehavior::Good);
-        testPeerSetBehavior(PeerSetBehavior::Drop50);
+        testPeerSetBehavior(PeerSetBehavior::DropAlternate);
         testPeerSetBehavior(PeerSetBehavior::Repeat);
         testStop();
         testSkipListBadReply();
diff --git a/src/test/app/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..ae1d557bb9 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();
         }
@@ -237,7 +240,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));
@@ -791,7 +795,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 TER terActual = tesSUCCESS;
                 for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                 {
-                    terActual = ac.checkInvariants(terActual, XRPAmount{});
+                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                     BEAST_EXPECT(terExpect == terActual);
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
@@ -827,7 +831,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 TER terActual = tesSUCCESS;
                 for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                 {
-                    terActual = ac.checkInvariants(terActual, XRPAmount{});
+                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                     BEAST_EXPECT(terExpect == terActual);
                     BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                     // uncomment to log the invariant failure message
@@ -871,7 +875,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 +1051,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();
 
@@ -1117,7 +1123,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 +1158,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..08c12e94d1 100644
--- a/src/test/app/NFToken_test.cpp
+++ b/src/test/app/NFToken_test.cpp
@@ -33,8 +33,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -143,7 +145,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 +864,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 +908,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 +949,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 +1011,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 +1050,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 +1119,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 +1182,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 +1216,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 +1256,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 +1284,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 +1310,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 +1339,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 +1393,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 +1497,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 +1608,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 +1617,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 +1664,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 +1684,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 +1704,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 +1722,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 +1770,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 +1799,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 +1856,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 +1892,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 +1908,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 +1948,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 +1967,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 +1983,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 +2066,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 +2079,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 +2090,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 +2103,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 +2131,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 +2144,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 +2156,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 +2170,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 +2216,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 +2229,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 +2241,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 +2255,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 +2297,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 +2312,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 +2327,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 +2373,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 +2386,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 +2405,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 +2636,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 +2697,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 +2726,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 +2754,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 +2777,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 +2816,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 +2875,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 +2952,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 +2966,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 +2986,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 +3074,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 +3154,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 +3227,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 +3247,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 +3318,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 +3338,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 +3417,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 +3437,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 +3503,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 +3523,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 +3584,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 +3623,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 +3646,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 +3665,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 +3685,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 +3744,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 +3780,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 +3892,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 +3936,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 +3987,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 +4031,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 +4106,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 +4132,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 +4149,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 +4187,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 +4212,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 +4228,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 +4269,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 +4308,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 +4365,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 +4457,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 +4473,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 +4551,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 +4791,87 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         checkOffers("nft_buy_offers", 501, 2, __LINE__);
     }
 
+    void
+    testNftXxxOffersMarkerWrongSide(FeatureBitset features)
+    {
+        // A pagination marker passed to nft_buy_offers / nft_sell_offers must
+        // reference an offer on the same side (buy vs. sell) as the directory
+        // being enumerated.  A wrong-side marker is rejected with invalidParams.
+        //
+        // Note: the pre-fix code also returned invalidParams for a wrong-side
+        // marker, but only after scanning the entire target directory (an
+        // O(directory size) walk usable to burn CPU).  The fix short-circuits
+        // that scan.  The scan-avoidance is not observable from the RPC
+        // response, so this test locks the rejection contract (wrong-side ->
+        // error, same-side -> success) rather than the performance property.
+        testcase("nft_buy_offers and nft_sell_offers wrong-side marker");
+
+        using namespace test::jtx;
+
+        Env env{*this, features};
+
+        Account const issuer{"issuer"};
+        Account const buyer{"buyer"};
+
+        env.fund(XRP(10000), issuer, buyer);
+        env.close();
+
+        // Mint a transferable NFT.
+        uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)};
+        env(token::mint(issuer, 0), Txflags(tfTransferable));
+        env.close();
+
+        // Create one sell offer (from the issuer, who owns the NFT) and one
+        // buy offer (from the buyer) for the same NFT.
+        env(token::createOffer(issuer, nftID, XRP(100)), Txflags(tfSellNFToken));
+        env(token::createOffer(buyer, nftID, XRP(50)), token::Owner(issuer));
+        env.close();
+
+        // Grab the index of the single offer on each side from the RPC
+        // response so we can use it as a marker.
+        auto firstOfferIndex = [this, &env, &nftID](char const* request) {
+            json::Value params;
+            params[jss::nft_id] = to_string(nftID);
+            json::Value const result = env.rpc("json", request, to_string(params))[jss::result];
+            BEAST_EXPECT(result.isMember(jss::offers) && result[jss::offers].size() == 1);
+            return result[jss::offers][0u][jss::nft_offer_index].asString();
+        };
+
+        std::string const sellOfferIndex = firstOfferIndex("nft_sell_offers");
+        std::string const buyOfferIndex = firstOfferIndex("nft_buy_offers");
+
+        auto queryWithMarker = [&env, &nftID](char const* request, std::string const& marker) {
+            json::Value params;
+            params[jss::nft_id] = to_string(nftID);
+            params[jss::marker] = marker;
+            return env.rpc("json", request, to_string(params))[jss::result];
+        };
+
+        // A marker referencing an offer on the wrong side is rejected with
+        // invalidParams.
+        {
+            // Sell-side marker passed to nft_buy_offers.
+            json::Value const result = queryWithMarker("nft_buy_offers", sellOfferIndex);
+            BEAST_EXPECT(result[jss::error].asString() == "invalidParams");
+        }
+        {
+            // Buy-side marker passed to nft_sell_offers.
+            json::Value const result = queryWithMarker("nft_sell_offers", buyOfferIndex);
+            BEAST_EXPECT(result[jss::error].asString() == "invalidParams");
+        }
+
+        // A same-side marker is still accepted.  With a single offer on each
+        // side, resuming after it simply yields no further offers.
+        {
+            json::Value const result = queryWithMarker("nft_buy_offers", buyOfferIndex);
+            BEAST_EXPECT(!result.isMember(jss::error));
+        }
+        {
+            json::Value const result = queryWithMarker("nft_sell_offers", sellOfferIndex);
+            BEAST_EXPECT(!result.isMember(jss::error));
+        }
+    }
+
     void
     testNFTokenNegOffer(FeatureBitset features)
     {
@@ -4706,25 +4911,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 +5096,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 +5110,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 +5624,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 +5643,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 +6261,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 +6271,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();
 
@@ -6207,12 +6423,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 +6444,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 +6466,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 +6475,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 +6497,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 +6519,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 +6542,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 +6712,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 +6757,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 +6860,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 +6876,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 +6895,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 +7019,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 +7052,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 +7070,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 +7308,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 +7356,127 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testCreateOfferInvalidAmount(FeatureBitset features)
+    {
+        testcase("Invalid NFT offer create amount");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, a fake-XRP offer amount (an IOU using the
+        // "XRP" currency code) is not rejected in preflight. With the amendment
+        // enabled, preflight rejects it with temBAD_CURRENCY.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const alice{"alice"};
+            Account const gw{"gw"};
+
+            env.fund(XRP(1000), alice, gw);
+            env.close();
+
+            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
+            env(token::mint(alice, 0u), Txflags(tfTransferable));
+            env.close();
+
+            // Fake XRP (an IOU using the "XRP" currency code) sell offer
+            // amount.
+            auto const bad = IOU(gw, badCurrency());
+            env(token::createOffer(alice, nftID, bad(1)),
+                Txflags(tfSellNFToken),
+                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tesSUCCESS}));
+            env.close();
+        }
+    }
+
+    void
+    testAcceptOfferInvalidBrokerFee(FeatureBitset features)
+    {
+        testcase("Invalid NFT offer accept broker fee");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, a fake-XRP broker fee (an IOU using the "XRP"
+        // currency code) is not rejected in preflight and reaches later offer
+        // validation instead. With the amendment enabled, preflight rejects it
+        // with temBAD_CURRENCY.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const alice{"alice"};
+            Account const buyer{"buyer"};
+            Account const broker{"broker"};
+            Account const gw{"gw"};
+
+            env.fund(XRP(1000), alice, buyer, broker, gw);
+            env.close();
+
+            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
+            env(token::mint(alice, 0u), Txflags(tfTransferable));
+            env.close();
+
+            uint256 const sellOfferIndex =
+                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
+            env(token::createOffer(alice, nftID, XRP(10)), Txflags(tfSellNFToken));
+            env.close();
+
+            uint256 const buyOfferIndex =
+                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
+            env(token::createOffer(buyer, nftID, XRP(40)), token::Owner(alice));
+            env.close();
+
+            // Fake XRP (an IOU using the "XRP" currency code) broker fee.
+            auto const bad = IOU(gw, badCurrency());
+            env(token::brokerOffers(broker, buyOfferIndex, sellOfferIndex),
+                token::BrokerFee(bad(1)),
+                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tecNFTOKEN_BUY_SELL_MISMATCH}));
+            env.close();
+        }
+    }
+
+    void
+    testCreateOfferIouIssuerGlobalFreeze(FeatureBitset features)
+    {
+        testcase("Create NFT offer by IOU issuer under global freeze");
+
+        using namespace test::jtx;
+
+        // Before fixCleanup3_4_0, an IOU issuer that has set a global freeze on
+        // their own currency cannot create an NFToken offer denominated in that
+        // currency; the offer is rejected with tecFROZEN.  With the amendment
+        // enabled, the issuer is not subject to their own global freeze when the
+        // offer is denominated in their own IOU (e.g. to receive their own
+        // transfer fees), so the offer succeeds.
+        for (bool const withFix : {false, true})
+        {
+            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
+
+            Account const issuer{"issuer"};
+            IOU const isISU(issuer["ISU"]);
+
+            env.fund(XRP(1000), issuer);
+            env.close();
+
+            // issuer mints a transferable NFToken.
+            uint256 const nftID = token::getNextID(env, issuer, 0, tfTransferable);
+            env(token::mint(issuer, 0u), Txflags(tfTransferable));
+            env.close();
+
+            // issuer sets a global freeze on their own IOU.
+            env(fset(issuer, asfGlobalFreeze));
+            env.close();
+
+            // issuer creates a sell offer for the NFToken denominated in their
+            // own (globally frozen) IOU.
+            env(token::createOffer(issuer, nftID, isISU(100)),
+                Txflags(tfSellNFToken),
+                Ter(withFix ? TER{tesSUCCESS} : TER{tecFROZEN}));
+            env.close();
+        }
+    }
+
 protected:
     FeatureBitset const allFeatures_{test::jtx::testableAmendments()};
 
@@ -7155,6 +7508,7 @@ protected:
         testNFTokenWithTickets(features);
         testNFTokenDeleteAccount(features);
         testNftXxxOffers(features);
+        testNftXxxOffersMarkerWrongSide(features);
         testNFTokenNegOffer(features);
         testIOUWithTransferFee(features);
         testBrokeredSaleToSelf(features);
@@ -7165,6 +7519,9 @@ protected:
         testUnaskedForAutoTrustline(features);
         testNFTIssuerIsIOUIssuer(features);
         testNFTokenModify(features);
+        testCreateOfferInvalidAmount(features);
+        testAcceptOfferInvalidBrokerFee(features);
+        testCreateOfferIouIssuerGlobalFreeze(features);
     }
 
 public:
diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp
index d03b1b8e93..e262954fdf 100644
--- a/src/test/app/OfferMPT_test.cpp
+++ b/src/test/app/OfferMPT_test.cpp
@@ -1,3 +1,5 @@
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -5,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -22,6 +25,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -35,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -46,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -609,6 +615,267 @@ public:
         testHelper2TokensMix(test);
     }
 
+    void
+    testMPTIssuerOfferUsesRemainingCapacity(FeatureBitset features)
+    {
+        testcase("MPT issuer offer dust removal uses remaining issuance capacity");
+
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const carol{"carol"};
+        Account const bob{"bob"};
+
+        Env env{*this, features};
+        env.fund(XRP(10'000), issuer, carol, bob);
+        env.close();
+
+        MPTTester const musd(
+            {.env = env, .issuer = issuer, .holders = {carol, bob}, .maxAmt = 101});
+
+        // The issuer offer is fully fundable when placed. Later issuance leaves
+        // only one MPT of remaining capacity, so this issuer-owned MPT offer
+        // must be clipped by owner funds just like a holder-funded offer.
+        auto const issuerOfferSeq = env.seq(issuer);
+        env(offer(issuer, drops(1), musd(100)));
+        env.close();
+
+        env(pay(issuer, carol, musd(100)));
+        env.close();
+        BEAST_EXPECT(env.balance(issuer, musd) == musd(-100));
+        BEAST_EXPECT(env.balance(carol, musd) == musd(100));
+
+        // Carol's same-quality offer provides the legitimately funded side of
+        // the crossing. Without the issuer-cap dust-removal check, Bob would
+        // receive Carol's 100 MPT plus one free self-issued MPT from issuer's
+        // stale offer while paying only Carol's one drop.
+        auto const carolOfferSeq = env.seq(carol);
+        env(offer(carol, drops(1), musd(100)));
+        env.close();
+
+        auto const issuerOffer = keylet::offer(issuer.id(), SeqProxy::rawSequence(issuerOfferSeq));
+        auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq));
+        BEAST_EXPECT(env.le(issuerOffer) != nullptr);
+        BEAST_EXPECT(env.le(carolOffer) != nullptr);
+
+        env(offer(bob, musd(101), drops(2), tfImmediateOrCancel));
+        env.close();
+
+        BEAST_EXPECT(env.le(issuerOffer) == nullptr);
+        BEAST_EXPECT(env.le(carolOffer) == nullptr);
+        env.require(offers(issuer, 0), offers(carol, 0), offers(bob, 0));
+        BEAST_EXPECT(env.balance(issuer, musd) == musd(-100));
+        BEAST_EXPECT(env.balance(carol, musd) == musd(0));
+        BEAST_EXPECT(env.balance(bob, musd) == musd(100));
+    }
+
+    void
+    testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features)
+    {
+        using namespace jtx;
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+
+        {
+            testcase("Partially funded MPT/XRP input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const gw = Account{"gw"};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}});
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), drops(1'000'000)));
+            env.close();
+
+            auto const targetBalance = reserve(env, 2) + drops(999'999);
+            auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() -
+                env.current()->fees().base;
+            env(pay(alice, gw, drops(drain)));
+            env.close();
+
+            auto const aliceXRPBefore = env.balance(alice);
+            auto const bobXRPBefore = env.balance(bob);
+
+            env(pay(gw, bob, drops(1'000'000)),
+                Sendmax(usd(1)),
+                Path(~XRP),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // alice's offer sells 1,000,000 drops for usd(1) but she can fund
+            // only 999,999. Filling the clipped remainder would require a
+            // fractional usd (MPT) input that rounds down to zero, so without
+            // the fix the taker could take the funded drops for free.
+            // shouldRmSmallIncreasedQOffer() now treats the MPT input as
+            // integral (like XRP) and removes the degraded offer, so the
+            // payment goes dry. The removal happens only inside the crossing:
+            // tecPATH_DRY discards everything but the fee, so the offer itself
+            // stays in the ledger, unconsumed.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice) == aliceXRPBefore);
+            BEAST_EXPECT(env.balance(bob) == bobXRPBefore);
+        }
+
+        {
+            testcase("Partially funded MPT/IOU input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const mptIssuer = Account{"mptIssuer"};
+            auto const iouIssuer = Account{"iouIssuer"};
+
+            env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob);
+            env.close();
+
+            auto const eur = iouIssuer["EUR"];
+            env.trust(eur(100), alice, bob);
+            env(pay(iouIssuer, alice, eur(0.5)));
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}});
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), eur(1)));
+            env.close();
+
+            auto const aliceEURBefore = env.balance(alice, eur);
+            auto const bobEURBefore = env.balance(bob, eur);
+
+            env(pay(mptIssuer, bob, eur(1)),
+                Sendmax(usd(1)),
+                Path(~eur),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // Same zero-input regression as the MPT/XRP case above, but with
+            // an IOU (eur) output leg: the fractional usd (MPT) input rounds
+            // to zero. The degraded offer is removed during crossing, the
+            // payment goes dry, and tecPATH_DRY leaves the offer in the ledger.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore);
+            BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore);
+        }
+
+        {
+            testcase("Partially funded MPT/MPT input offer cannot be consumed for free");
+
+            Env env{*this, features};
+            auto const issuerA = Account{"issuerA"};
+            auto const issuerB = Account{"issuerB"};
+
+            env.fund(XRP(10'000), issuerA, issuerB, alice, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}});
+            MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}});
+
+            env(pay(issuerB, alice, eur(999'999)));
+            env.close();
+
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), eur(1'000'000)));
+            env.close();
+
+            auto const aliceEURBefore = eur.getBalance(alice);
+            auto const bobEURBefore = eur.getBalance(bob);
+
+            env(pay(issuerA, bob, eur(1'000'000)),
+                Sendmax(usd(1)),
+                Path(~eur),
+                Txflags(tfNoRippleDirect | tfPartialPayment),
+                Ter(tecPATH_DRY));
+            env.close();
+
+            // Same zero-input regression as above, but with both legs MPT: the
+            // fractional usd (MPT) input rounds to zero. The degraded offer is
+            // removed during crossing, the payment goes dry, and tecPATH_DRY
+            // leaves the offer in the ledger.
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore));
+            BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore));
+        }
+
+        {
+            // The dry cases above never observe the degraded offer actually
+            // being removed, because tecPATH_DRY rolls the removal back. Here a
+            // second, fully funded offer lets the crossing succeed, so the
+            // removal persists: alice's degraded offer is deleted from the
+            // book (not taken for free) while carol's good offer fills.
+            testcase(
+                "Partially funded MPT input offer is removed, not consumed, "
+                "when a funded offer crosses");
+
+            Env env{*this, features};
+            auto const gw = Account{"gw"};
+            auto const carol = Account{"carol"};
+
+            env.fund(XRP(10'000), gw, alice, carol, bob);
+            env.close();
+
+            MPTTester const usd({.env = env, .issuer = gw, .holders = {alice, carol, bob}});
+
+            // alice's offer sells 1,000,000 drops for usd(1) but, as in the
+            // dry cases above, she can fund only 999,999 drops, so filling the
+            // clipped remainder would require a fractional usd (MPT) input that
+            // rounds down to zero.
+            auto const aliceOfferSeq = env.seq(alice);
+            env(offer(alice, usd(1), drops(1'000'000)));
+            env.close();
+
+            auto const targetBalance = reserve(env, 2) + drops(999'999);
+            auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() -
+                env.current()->fees().base;
+            env(pay(alice, gw, drops(drain)));
+            env.close();
+
+            // carol's same-quality offer is fully funded and provides the
+            // legitimate side of the crossing.
+            auto const carolOfferSeq = env.seq(carol);
+            env(offer(carol, usd(1), drops(1'000'000)));
+            env.close();
+
+            // bob needs usd to buy drops.
+            env(pay(gw, bob, usd(2)));
+            env.close();
+
+            auto const aliceOffer = keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq));
+            auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq));
+            BEAST_EXPECT(env.le(aliceOffer) != nullptr);
+            BEAST_EXPECT(env.le(carolOffer) != nullptr);
+
+            auto const aliceXRPBefore = env.balance(alice);
+            auto const bobXRPBefore = env.balance(bob);
+
+            // bob buys drops with usd, wanting more than carol alone supplies so
+            // the crossing also reaches alice's offer. carol's offer fills;
+            // alice's degraded offer is removed rather than taken for free, so
+            // bob receives only carol's 1,000,000 drops and pays only usd(1).
+            env(offer(bob, drops(2'000'000), usd(2), tfImmediateOrCancel));
+            env.close();
+
+            BEAST_EXPECT(env.le(aliceOffer) == nullptr);
+            BEAST_EXPECT(env.le(carolOffer) == nullptr);
+            env.require(offers(alice, 0), offers(carol, 0), offers(bob, 0));
+
+            // alice's offer was removed, not consumed: her balances are
+            // unchanged and none of her funded 999'999 drops leaked to bob.
+            BEAST_EXPECT(env.balance(alice) == aliceXRPBefore);
+            BEAST_EXPECT(env.balance(alice, usd) == usd(0));
+            BEAST_EXPECT(env.balance(carol, usd) == usd(1));
+            BEAST_EXPECT(env.balance(bob, usd) == usd(1));
+            BEAST_EXPECT(
+                env.balance(bob) == bobXRPBefore + drops(1'000'000) - env.current()->fees().base);
+        }
+    }
+
     void
     testInsufficientReserve(FeatureBitset features)
     {
@@ -947,6 +1214,161 @@ public:
         }
     }
 
+    void
+    testMPTAMMLimitQualityRounding(FeatureBitset features)
+    {
+        testcase("MPT AMM limitQuality checks rounded integral output");
+
+        using namespace jtx;
+
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+
+        // IOC used to reject the AMM strand with tecKILLED.  The continuous
+        // limitQuality target is about 32.88 MPT; rounding to nearest requested
+        // 33 MPT and made the realized AMM quality miss Bob's limit.  The
+        // discrete fallback takes the largest satisfying integer output: 32.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 100'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, XRP(100), btc(1'000));
+
+            auto const bobBTCBefore = btc.getBalance(bob);
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, btc(100), drops(10'340'000)), Txflags(tfImmediateOrCancel));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32);
+            BEAST_EXPECT(xrpAfter > xrpBefore);
+            BEAST_EXPECT(btcAfter < btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 0));
+        }
+
+        // A standard OfferCreate at the same limit used to bypass the AMM and
+        // rest unchanged on the book.  It should now take the largest
+        // satisfying 32-MPT AMM fill first, then leave only the remainder on
+        // the book.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 100'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, XRP(100), btc(1'000));
+
+            auto const bobBTCBefore = btc.getBalance(bob);
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, btc(100), drops(10'340'000)));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32);
+            BEAST_EXPECT(xrpAfter > xrpBefore);
+            BEAST_EXPECT(btcAfter < btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 1));
+
+            auto const bobOffers = offersOnAccount(env, bob);
+            if (BEAST_EXPECT(bobOffers.size() == 1))
+            {
+                BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != btc(100));
+                BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != drops(10'340'000));
+            }
+        }
+
+        // Mirror the IOC case with the integral output flipped from MPT units
+        // to XRP drops.  The same continuous target (~32.88) used to round up
+        // to 33 drops and miss limitQuality; the discrete fallback allows the
+        // largest satisfying 32-drop AMM fill.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 200'000'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, drops(1'000), btc(100'000'000));
+
+            auto const bobXRPBefore = env.balance(bob, XRP);
+            auto const baseFee = env.current()->fees().base;
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, drops(100), btc(10'340'000)), Txflags(tfImmediateOrCancel));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee));
+            BEAST_EXPECT(xrpAfter < xrpBefore);
+            BEAST_EXPECT(btcAfter > btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 0));
+        }
+
+        // Mirror the standard OfferCreate case as well.  It should consume the
+        // largest satisfying 32-drop AMM fill before leaving only the remainder
+        // on the book.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPTTester const btc(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, bob},
+                 .pay = 200'000'000,
+                 .flags = kMptDexFlags});
+            AMM const amm(env, alice, drops(1'000), btc(100'000'000));
+
+            auto const bobXRPBefore = env.balance(bob, XRP);
+            auto const baseFee = env.current()->fees().base;
+            auto const [xrpBefore, btcBefore, lpBefore] = amm.balances();
+
+            env(offer(bob, drops(100), btc(10'340'000)));
+            env.close();
+
+            auto const [xrpAfter, btcAfter, lpAfter] = amm.balances();
+            env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee));
+            BEAST_EXPECT(xrpAfter < xrpBefore);
+            BEAST_EXPECT(btcAfter > btcBefore);
+            BEAST_EXPECT(lpAfter == lpBefore);
+            BEAST_EXPECT(expectOffers(env, bob, 1));
+
+            auto const bobOffers = offersOnAccount(env, bob);
+            if (BEAST_EXPECT(bobOffers.size() == 1))
+            {
+                BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != drops(100));
+                BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != btc(10'340'000));
+            }
+        }
+    }
+
     void
     testMalformed(FeatureBitset features)
     {
@@ -2727,6 +3149,50 @@ public:
         using namespace jtx;
         auto const gw1 = Account("gateway1");
 
+        {
+            auto const issuer = Account("issuer");
+            auto const sender = Account("sender");
+            auto const receiver = Account("receiver");
+            auto const seller = Account("seller");
+            auto const buyer = Account("buyer");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, sender, receiver, seller, buyer);
+            env.close();
+
+            MPTTester mpt{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {sender, receiver, seller, buyer},
+                 .transferFee = 100}};
+            MPT const token = mpt;
+
+            mpt.pay(issuer, sender, 2'000);
+            mpt.pay(issuer, seller, 2'000);
+
+            // A direct holder-to-holder payment of 999 MPT at a 0.1% fee
+            // requires 1000 from the sender and burns one MPT.
+            env(pay(sender, receiver, token(999)), Ter(tecPATH_PARTIAL));
+            env.close();
+            env(pay(sender, receiver, token(999)), Sendmax(token(1'000)));
+            env.close();
+
+            BEAST_EXPECT(mpt.getBalance(sender) == 1'000);
+            BEAST_EXPECT(mpt.getBalance(receiver) == 999);
+            BEAST_EXPECT(mpt.getBalance(issuer) == 3'999);
+
+            // CLOB crossing should apply the same fee quantum.  The offer
+            // owner pays ceil(999 * 1.001) = 1000, not floor(...) = 999.
+            env(offer(seller, XRP(999), token(999)));
+            env.close();
+            env(offer(buyer, token(999), XRP(999)));
+            env.close();
+
+            BEAST_EXPECT(mpt.getBalance(seller) == 1'000);
+            BEAST_EXPECT(mpt.getBalance(buyer) == 999);
+            BEAST_EXPECT(mpt.getBalance(issuer) == 3'998);
+        }
+
         auto test = [&](auto&& issue1, auto&& issue2) {
             Env env{*this, features};
 
@@ -3102,6 +3568,247 @@ public:
         }
     }
 
+    void
+    testTransferRateOverflowOffer(FeatureBitset features)
+    {
+        testcase("Transfer Rate Overflow Offer");
+
+        using namespace jtx;
+
+        auto const issuer = Account("issuer");
+        auto const taker = Account("taker");
+
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            auto constexpr takerFunds = 2'000'000'000'000'000'000LL;
+            MPTTester const token{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {taker},
+                 .transferFee = 50'000,
+                 .pay = takerFunds,
+                 .maxAmt = kMaxMpTokenAmount}};
+
+            // Covers OfferCreate::flowCross() sendMax calculation. A large
+            // non-issuer MPT offer with a transfer fee used to overflow in
+            // multiplyRound() before the offer could be placed.
+            auto constexpr offerAmount = 1'230'000'000'000'000'000LL;
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, XRP(1), token(offerAmount)));
+            env.close();
+
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(taker, token) == token(takerFunds));
+        }
+
+        // Each scenario below targets a BookStep/OfferStream overflow path.
+        // The expected behavior is the same in all cases: remove the unusable
+        // book tip offer and let the taker's crossing offer remain rather than
+        // returning tecINTERNAL with the poison offer still on-ledger.
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}};
+
+            // Covers BookStep::forEachOffer() offer preparation, where
+            // ownerGives = mulRatio(ofrAmt.out, transferRateOut) overflowed
+            // for an oversized MPT output with a transfer fee.
+            std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL;
+            auto const poisonSeq = env.seq(issuer);
+            env(offer(issuer, XRP(1), token(poisonAmount)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, token(100), XRP(100)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+        }
+
+        {
+            auto const gwA = Account("gatewayA");
+            auto const gwB = Account("gatewayB");
+            auto const alice = Account("alice");
+            auto const mallory = Account("mallory");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), gwA, gwB, alice, mallory);
+            env.close();
+
+            MPTTester const tokenA{
+                {.env = env, .issuer = gwA, .holders = {alice, mallory}, .transferFee = 50'000}};
+
+            MPTTester const tokenB{{.env = env, .issuer = gwB, .holders = {alice, mallory}}};
+
+            env(pay(gwA, alice, tokenA(1'000)));
+
+            // Covers BookStep::forEachOffer() offer preparation, where
+            // stpAmt.in = mulRatio(ofrAmt.in, transferRateIn) overflowed.
+            // The MPT/MPT amounts keep the offer quality reachable while
+            // applying tokenA's transfer rate overflows the input side.
+            std::int64_t const poisonPays = 6'148'914'691'236'517'205LL;
+            std::int64_t const poisonGets = 34'000'000'000'000'000LL;
+            env(pay(gwB, mallory, tokenB(poisonGets)));
+
+            auto const poisonSeq = env.seq(mallory);
+            env(offer(mallory, tokenA(poisonPays), tokenB(poisonGets)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const aliceSeq = env.seq(alice);
+            env(offer(alice, tokenB(1), tokenA(100)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceSeq))) != nullptr);
+        }
+
+        {
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}};
+
+            // Give the taker exactly one MPT. If the old rounding overflow
+            // collapsed the required input to the minimum positive amount, the
+            // taker could afford the bad fill and the balance checks below
+            // would catch the economic gain.
+            env(pay(issuer, taker, token(1)));
+            env.close();
+
+            // Covers BookStep::revImp() output reduction. The issuer's offer
+            // is fully funded and has no transfer fee, so offer preparation
+            // succeeds. The taker asks for slightly less output, forcing
+            // limitStepOut() to reduce the offer; that strict reduction used
+            // to overflow and leave the poison offer on the book.
+            auto const funded = 1'844'674'407'370'955'162LL;
+            auto const offerOut = funded + 1;
+
+            auto const poisonSeq = env.seq(issuer);
+            env(offer(issuer, XRP(1), token(offerOut)));
+            env.close();
+
+            auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const issuerXRPBefore = env.balance(issuer, XRP);
+            auto const takerXRPBefore = env.balance(taker, XRP);
+            auto const takerMPTBefore = env.balance(taker, token);
+            auto const fee = env.current()->fees().base;
+
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, token(funded), XRP(1)));
+            env.close();
+
+            // The former overflow point must not turn into a near-free fill:
+            // the unusable offer is removed, the taker's offer remains, and no
+            // value changes hands beyond the taker's transaction fee.
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore);
+            BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee);
+            BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore);
+        }
+
+        {
+            auto const poisonMaker = Account("poisonMaker");
+
+            Env env{*this, features};
+            env.fund(XRP(10'000), issuer, poisonMaker, taker);
+            env.close();
+
+            MPTTester const token{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {poisonMaker, taker},
+                 .maxAmt = kMaxMpTokenAmount}};
+
+            // Covers OfferStream::step() filtering. The offer is mostly
+            // funded, but reducing it to the actual owner funds inside
+            // shouldRmSmallIncreasedQOffer() used to overflow before BookStep
+            // saw the offer.
+            auto const funded = 1'844'674'407'370'955'162LL;
+            auto const offerOut = funded + 1;
+            env(pay(issuer, poisonMaker, token(funded)));
+
+            auto const poisonSeq = env.seq(poisonMaker);
+            env(offer(poisonMaker, XRP(1), token(offerOut)));
+            env.close();
+
+            auto const poisonKeylet =
+                keylet::offer(poisonMaker.id(), SeqProxy::rawSequence(poisonSeq));
+            BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+            auto const takerSeq = env.seq(taker);
+            env(offer(taker, token(1), XRP(1)));
+            env.close();
+
+            BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+            BEAST_EXPECT(
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            BEAST_EXPECT(env.balance(poisonMaker, token) == token(funded));
+            BEAST_EXPECT(env.balance(taker, token) == token(0));
+        }
+
+        {
+            // Same overflow scenario as the ownerGives case above, but run with
+            // trace-level logging so BookStep::forEachOffer's removeOffer()
+            // emits its "Removing offer with overflowing amount calculation"
+            // trace line. This exercises the JLOG body inside removeOffer,
+            // which is skipped when logging is above trace severity.
+            std::string logs;
+            {
+                Env env{
+                    *this,
+                    envconfig(),
+                    features,
+                    std::make_unique(&logs),
+                    beast::Severity::Trace};
+                env.fund(XRP(10'000), issuer, taker);
+                env.close();
+
+                MPTTester const token{
+                    {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}};
+
+                std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL;
+                auto const poisonSeq = env.seq(issuer);
+                env(offer(issuer, XRP(1), token(poisonAmount)));
+                env.close();
+
+                auto const poisonKeylet =
+                    keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq));
+                BEAST_EXPECT(env.le(poisonKeylet) != nullptr);
+
+                auto const takerSeq = env.seq(taker);
+                env(offer(taker, token(100), XRP(100)));
+                env.close();
+
+                BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
+                BEAST_EXPECT(
+                    env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+            }
+            BEAST_EXPECT(logs.contains("Removing offer with overflowing amount calculation"));
+        }
+    }
+
     void
     testSelfCrossOffer1(FeatureBitset features)
     {
@@ -4920,6 +5627,7 @@ public:
         testSellOffer(features);
         testSellWithFillOrKill(features);
         testTransferRateOffer(features);
+        testTransferRateOverflowOffer(features);
         testSelfCrossOffer(features);
         testSelfIssueOffer(features);
         testDirectToDirectPath(features);
@@ -4934,8 +5642,11 @@ public:
         testDeletedOfferIssuer(features);
         testTicketOffer(features);
         testTicketCancelOffer(features);
+        testMPTAMMLimitQualityRounding(features);
         testRmSmallIncreasedQOffersXRP(features);
         testRmSmallIncreasedQOffersMPT(features);
+        testMPTIssuerOfferUsesRemainingCapacity(features);
+        testPartiallyFundedMPTInputOfferZeroInput(features);
         testFillOrKill(features);
         testTickSize(features);
         testAutoCreateReserve(features);
diff --git a/src/test/app/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..ff4a024cb8 100644
--- a/src/test/app/PathMPT_test.cpp
+++ b/src/test/app/PathMPT_test.cpp
@@ -112,10 +112,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 +125,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 +165,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));
diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp
index 8f19a419a0..29b4a5b048 100644
--- a/src/test/app/Path_test.cpp
+++ b/src/test/app/Path_test.cpp
@@ -53,6 +53,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -151,10 +152,10 @@ public:
         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 +165,7 @@ public:
              .role = Role::USER,
              .coro = {},
              .infoSub = {},
-             .apiVersion = RPC::kApiVersionIfUnspecified},
+             .apiVersion = rpc::kApiVersionIfUnspecified},
             {},
             {}};
 
@@ -190,7 +191,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();
         });
 
@@ -262,10 +263,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,49 +276,49 @@ 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));
@@ -1866,6 +1867,103 @@ 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{};
+
+        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
     run() override
     {
@@ -1878,6 +1976,7 @@ public:
         issuesPathNegativeRippleClientIssue23Smaller();
         issuesPathNegativeRippleClientIssue23Larger();
         qualityPathsQualitySetAndTest();
+        testAssembleAddDeduplication();
         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 998b7b1c7f..ddb56a1480 100644
--- a/src/test/app/PermissionedDEX_test.cpp
+++ b/src/test/app/PermissionedDEX_test.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -59,7 +60,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 +86,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 +149,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 {};
@@ -1244,7 +1246,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 +1280,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 =
@@ -1570,7 +1574,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);
@@ -1666,7 +1671,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 +1740,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 +1821,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 +1834,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 +1852,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 +1866,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 +1878,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 +1891,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 +1913,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 +1939,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 +1954,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 +2008,198 @@ 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)
+    {
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        testcase << "Replace domain offer via OfferCreate"
+                 << (fixEnabled ? " (fixCleanup3_4_0 enabled)" : " (fixCleanup3_4_0 disabled)");
+
+        Env env(*this, features);
+        auto const& [gw, domainOwner, alice, bob, carol, USD, domainA, credType] =
+            PermissionedDEX(env);
+
+        Account const domainOwnerB("permdex-domainOwnerB");
+        auto const domainB =
+            setupDomain(env, {alice, bob, carol, gw}, domainOwnerB, "permdex-other-domain");
+        BEAST_EXPECT(domainA != domainB);
+
+        auto const oldSeq = env.seq(alice);
+        env(offer(alice, USD(100), XRP(1)), Domain(domainA));
+        env.close();
+
+        BEAST_EXPECT(checkOffer(env, alice, oldSeq, USD(100), XRP(1), 0, true));
+        auto const oldOffer = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(oldSeq)));
+        if (!BEAST_EXPECT(oldOffer))
+            return;
+        BEAST_EXPECT(oldOffer->getFieldH256(sfDomainID) == domainA);
+
+        auto const newSeq = env.seq(alice);
+        // The invariant should reject mixing active Permissioned DEX domains,
+        // not a domain that is only touched because its offer is being deleted.
+        if (fixEnabled)
+        {
+            env(offer(alice, USD(100), XRP(2)), Domain(domainB), Json(jss::OfferSequence, oldSeq));
+            env.close();
+
+            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(), SeqProxy::rawSequence(newSeq)));
+            if (!BEAST_EXPECT(newOffer))
+                return;
+            BEAST_EXPECT(newOffer->getFieldH256(sfDomainID) == domainB);
+        }
+        else
+        {
+            env(offer(alice, USD(100), XRP(2)),
+                Domain(domainB),
+                Json(jss::OfferSequence, oldSeq),
+                Ter(tecINVARIANT_FAILED));
+            env.close();
+
+            BEAST_EXPECT(checkOffer(env, alice, oldSeq, USD(100), XRP(1), 0, true));
+            BEAST_EXPECT(!offerExists(env, alice, newSeq));
+        }
+    }
+
 public:
     void
     run() override
@@ -2038,6 +2237,16 @@ 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/RCLValidations_test.cpp b/src/test/app/RCLValidations_test.cpp
index aaf84225a7..9ace2e21af 100644
--- a/src/test/app/RCLValidations_test.cpp
+++ b/src/test/app/RCLValidations_test.cpp
@@ -2,12 +2,12 @@
 #include 
 
 #include 
-#include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp
index c219bd8737..82019affba 100644
--- a/src/test/app/SHAMapStore_test.cpp
+++ b/src/test/app/SHAMapStore_test.cpp
@@ -23,10 +23,9 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -60,7 +59,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 +98,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;
     }
 
@@ -347,11 +346,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;
@@ -370,7 +369,7 @@ public:
 
         // 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();
@@ -423,7 +422,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());
 
@@ -457,7 +456,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)
@@ -489,18 +488,18 @@ public:
         lastRotated = ledgerSeq - 1;
     }
 
-    std::unique_ptr
+    std::unique_ptr
     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 {};
         newPath = path;
         section.set(Keys::kPath, newPath.string());
 
-        auto backend{NodeStore::Manager::instance().makeBackend(
+        auto backend{node_store::Manager::instance().makeBackend(
             section,
             megabytes(env.app().config().getValueFor(SizedItem::BurstSize, std::nullopt)),
             scheduler,
@@ -549,7 +548,7 @@ public:
         auto archiveBackend = makeBackendRotating(env, scheduler, archiveDb);
 
         static constexpr int kReadThreads = 4;
-        auto dbr = std::make_unique(
+        auto dbr = std::make_unique(
             scheduler,
             kReadThreads,
             std::move(writableBackend),
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index f20aac68f9..a1a9f80a11 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,17 +361,17 @@ 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();
     }
 
@@ -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");
@@ -956,7 +1167,7 @@ public:
 
         {
             // Invalid SponsorshipEnd permission (sponsor object/sponsor account)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const charlie("charlie");
@@ -1001,7 +1212,7 @@ public:
 
         {
             // sponsor account
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1132,7 +1343,7 @@ public:
         }
         {
             // dissolve account sponsorship from sponsor
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1156,7 +1367,7 @@ public:
 
         {
             // sponsor object (co-signing)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1171,7 +1382,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 +1395,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 +1476,20 @@ public:
             BEAST_EXPECT(sle2->isFieldPresent(sfSponsor));
             BEAST_EXPECT(sle2->getAccountID(sfSponsor) == sponsor2.id());
 
-            // dissolve sponsor: ending an object sponsorship succeeds even
-            // when the sponsee lacks sufficient reserve to reclaim the object.
+            // dissolve sponsor: ending an object sponsorship now (fixCleanup3_4_0) requires the
+            // sponsee to be able to self-fund the object's reserve.
             adjustAccountXRPBalance(env, alice, reserve(env, 1) - drops(1));
 
+            if (features[fixCleanup3_4_0])
+            {
+                // Under-funded: End is rejected until alice can self-fund.
+                env(sponsor::transfer(alice, tfSponsorshipEnd, checkId),
+                    Ter(tecINSUFFICIENT_RESERVE));
+                env.close();
+
+                adjustAccountXRPBalance(env, alice, reserve(env, 1));
+            }
+
             env(sponsor::transfer(alice, tfSponsorshipEnd, checkId));
             env.close();
 
@@ -1291,7 +1513,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 +1522,7 @@ public:
         }
         {
             // sponsor object (pre-funded + no ltSponsorship entry)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1312,7 +1534,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 +1556,7 @@ public:
         }
         {
             // sponsor object (pre-funded)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor1("sponsor1");
@@ -1346,7 +1568,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 +1659,7 @@ public:
 
         {
             // Dissolve object sponsorship from sponsor(no-ltSponsorship)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1448,7 +1670,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 +1699,7 @@ public:
 
         {
             // Dissolve object sponsorship from sponsor (with ltSponsorship)
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1488,7 +1710,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 +1757,7 @@ public:
 
             for (bool const isIssuerHigh : {false, true})
             {
-                Env env{*this, testableAmendments()};
+                Env env{*this, features};
                 env.fund(XRP(10000), alice, bob, sponsor);
                 env.close();
 
@@ -1579,7 +1801,7 @@ public:
 
         {
             // invalid transfer
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const bob("bob");
             Account const sponsor("sponsor");
@@ -1616,7 +1838,7 @@ public:
         {
             // existing owner objects that are outside the v1 SponsorshipTransfer
             // object allow-list
-            Env env{*this, testableAmendments()};
+            Env env{*this, features};
             Account const alice("alice");
             Account const sponsor("sponsor");
             env.fund(XRP(10000), alice, sponsor);
@@ -1633,7 +1855,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);
 
@@ -1663,15 +1885,16 @@ 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}));
+            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 +2242,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 +2266,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 +2313,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 +2545,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 +2820,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 +2884,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 +2924,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 +2990,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 +3399,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 +3418,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 +3432,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 +3487,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 +3550,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 +3652,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 +3755,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 +3838,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 +4782,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 +5173,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);
@@ -5285,14 +5519,14 @@ 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
             }
@@ -5392,7 +5626,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);
@@ -5443,11 +5678,14 @@ protected:
         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 617820c89c..0000000000
--- a/src/test/app/Vault_test.cpp
+++ /dev/null
@@ -1,8347 +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
-            env.setParseFailureExpected(true);
-            try
-            {
-                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);
-            }
-            env.setParseFailureExpected(false);
-        }
-    }
-
-    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/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
similarity index 79%
rename from src/test/app/LendingHelpers_test.cpp
rename to src/test/app/lending/LendingHelpers_test.cpp
index ac8e0764fc..32c49feb02 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,326 @@ class LendingHelpers_test : public beast::unit_test::Suite
              Number{-18304, -5}));
     }
 
+    void
+    testAccrualLoanOriginationDeltas()
+    {
+        using namespace xrpl::accrual;
+
+        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("accrual::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
+    testAccrualLoanOriginationExceedsVaultMaximum()
+    {
+        using namespace xrpl::accrual;
+
+        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("accrual::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
+    testAccrualLoanVaultExposure()
+    {
+        testcase("accrual::loanVaultExposure");
+
+        auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+        BEAST_EXPECT(xrpl::accrual::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 Accrual's formula is meaningfully exercised.
+        LoanPaymentParts const parts{
+            .principalPaid = Number{100},
+            .interestPaid = Number{20},
+            .valueChange = Number{5},
+            .feePaid = Number{3}};
+
+        {
+            testcase("accrual::loanPaymentDeltas: nonzero valueChange");
+            auto const deltas = xrpl::accrual::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 "
+                "Accrual");
+            Env const env{*this};
+            auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue);
+            auto const expected =
+                xrpl::accrual::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 Accrual'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 Accrual");
+            Env const env{*this};
+            BEAST_EXPECT(
+                loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) ==
+                xrpl::accrual::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 Accrual");
+            Env const env{*this};
+            auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
+            BEAST_EXPECT(
+                loanVaultExposure(legacyVault, sle) == xrpl::accrual::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 Accrual");
+            Env const env{*this};
+            auto const deltas = loanPaymentDeltas(legacyVault, parts);
+            auto const expected = xrpl::accrual::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 +1880,93 @@ public:
         }
     }
 
+    // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real
+    // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls
+    // the function directly against hand-picked, unsubmitted transactions
+    // (via env.jt(), which never touches the ledger) to exercise every early
+    // return and the success path precisely.
+    void
+    testLoanDefaultFreezeExemptAccounts()
+    {
+        using namespace jtx;
+        using namespace loan;
+
+        testcase("getLoanDefaultFreezeExemptAccounts");
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        Env env{*this};
+        Vault const vault{env};
+        env.fund(XRP(10'000), lender, borrower);
+        env.close();
+
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        env(vaultTx);
+        env.close();
+        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
+        env(loan_broker::set(lender, vaultKeylet.key));
+        env.close();
+
+        env(set(borrower, brokerKeylet.key, Number{200'000}),
+            Sig(sfCounterpartySignature, lender),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+
+        // Not a LoanManage transaction at all.
+        {
+            auto const jt = env.jt(jtx::pay(lender, borrower, XRP(1)));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // LoanManage, but not the tfLoanDefault flag.
+        {
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanImpair));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // tfLoanDefault, but fixCleanup3_4_0 is disabled.
+        {
+            env.disableFeature(fixCleanup3_4_0);
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+            env.enableFeature(fixCleanup3_4_0);
+        }
+
+        // tfLoanDefault, amendment enabled, but the referenced Loan doesn't
+        // exist (reusing the broker's own ID as a bogus LoanID, same trick
+        // testInvalidLoanManage-style tests use elsewhere in this suite).
+        {
+            auto const jt = env.jt(manage(lender, brokerKeylet.key, tfLoanDefault));
+            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
+        }
+
+        // tfLoanDefault, amendment enabled, Loan/LoanBroker/Vault all exist:
+        // resolves the issuer, broker, vault accounts, and the vault's asset.
+        {
+            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
+            auto const result = getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx);
+            auto const brokerSle = env.le(brokerKeylet);
+            auto const vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(result);
+            BEAST_EXPECT(brokerSle);
+            BEAST_EXPECT(vaultSle);
+            if (result && brokerSle && vaultSle)
+            {
+                BEAST_EXPECT(result->issuer == vaultSle->at(sfAsset).getIssuer());
+                BEAST_EXPECT(result->broker == brokerSle->at(sfAccount));
+                BEAST_EXPECT(result->vault == vaultSle->at(sfAccount));
+                BEAST_EXPECT(result->asset == vaultSle->at(sfAsset));
+            }
+        }
+    }
+
     void
     run() override
     {
@@ -1573,6 +1991,19 @@ public:
         testComputeOverpaymentComponents();
         testComputeInterestAndFeeParts();
         testCanApplyToBrokerCover();
+
+        testAccrualLoanOriginationDeltas();
+        testCashBasisLoanOriginationDeltas();
+        testAccrualLoanOriginationExceedsVaultMaximum();
+        testAccrualLoanVaultExposure();
+        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 91%
rename from src/test/app/LoanBroker_test.cpp
rename to src/test/app/lending/LoanBroker_test.cpp
index f6f85a0cca..321ed5168f 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 
@@ -93,10 +96,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 +172,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 +186,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 +200,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 +295,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 +542,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 +650,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 +746,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,7 +868,7 @@ 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);
@@ -897,7 +903,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 +1047,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,7 +1100,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase("Invalid LoanBrokerCoverClawback");
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         // preflight
         {
@@ -1109,7 +1116,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);
@@ -1217,10 +1224,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,7 +1264,7 @@ 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"};
@@ -1323,7 +1331,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,7 +1373,7 @@ 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);
@@ -1401,7 +1410,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();
 
@@ -1548,12 +1558,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();
         };
 
@@ -1621,7 +1632,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 +1640,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 +1673,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 +1761,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 +1791,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();
 
@@ -1833,7 +1846,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     testCoverDepositFreezes()
     {
         using namespace jtx;
-        using namespace loanBroker;
+        using namespace loan_broker;
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
@@ -1856,7 +1869,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();
 
@@ -1926,7 +1940,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,7 +1999,7 @@ 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"};
@@ -2006,7 +2021,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 +2062,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"};
@@ -2069,7 +2085,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();
 
@@ -2184,7 +2201,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();
 
@@ -2351,7 +2369,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 +2377,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 +2398,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();
@@ -2472,7 +2491,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 +2506,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 +2534,132 @@ class LoanBroker_test : public beast::unit_test::Suite
         testRIPD4274MPT();
     }
 
+    void
+    testCoverWithdrawCredentialDepositPreauth(FeatureBitset features)
+    {
+        testcase(
+            std::string{"CoverWithdraw with credential-based deposit preauth "} +
+            (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"));
+        using namespace jtx;
+        using namespace std::chrono_literals;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        Env env(*this, features);
+
+        Account const broker{"broker"};
+        Account const dest{"dest"};
+        Account const credIssuer{"credIssuer"};
+        char const credType[] = "abcde";
+
+        env.fund(XRP(10'000), broker, dest, credIssuer);
+        env(fset(dest, asfDepositAuth));
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1'000'000};
+
+        Vault const vault(env);
+        auto const [vaultTx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit({.depositor = broker, .id = vaultKeylet.key, .amount = asset(1'000)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
+        env(loan_broker::set(broker, vaultKeylet.key));
+        env.close();
+
+        env(loan_broker::coverDeposit(broker, brokerKeylet.key, asset(500)));
+        env.close();
+
+        auto coverWithdrawToDest = [&]() {
+            return loan_broker::coverWithdraw(broker, brokerKeylet.key, asset(10));
+        };
+
+        // Without any preauth, coverWithdraw to dest fails
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Issue and accept a credential for the broker (with expiration)
+        auto jv = credentials::create(broker, credIssuer, credType);
+        std::uint32_t const expiration =
+            env.current()->header().parentCloseTime.time_since_epoch().count() + 100;
+        jv[sfExpiration.jsonName] = expiration;
+        env(jv);
+        env(credentials::accept(broker, credIssuer, credType));
+        env.close();
+
+        auto const credKeylet = credentials::keylet(broker, credIssuer, credType);
+        auto const credIdx =
+            credentials::ledgerEntry(env, broker, credIssuer, credType)[jss::result][jss::index]
+                .asString();
+
+        // dest authorizes deposits from holders of credentials issued by credIssuer
+        env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}}));
+        env.close();
+
+        // Without supplying credentials, still fails
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION});
+        env.close();
+
+        if (!fixEnabled)
+        {
+            // Pre-fix: sfCredentialIDs in LoanBrokerCoverWithdraw is disabled
+            env(coverWithdrawToDest(),
+                loan_broker::kDestination(dest),
+                credentials::Ids({credIdx}),
+                Ter{temDISABLED});
+            env.close();
+            return;
+        }
+
+        // With credentials, succeeds
+        env(coverWithdrawToDest(), loan_broker::kDestination(dest), credentials::Ids({credIdx}));
+        env.close();
+
+        // Bad credential id is rejected
+        std::string const invalidIdx =
+            "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034";
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({invalidIdx}),
+            Ter{tecBAD_CREDENTIALS});
+        env.close();
+
+        // Malformed credential array (duplicates) is rejected by checkFields
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx, credIdx}),
+            Ter{temMALFORMED});
+        env.close();
+
+        // Valid credential not authorized by dest hits authorizedDepositPreauth error path
+        char const credType2[] = "fghij";
+        env(credentials::create(broker, credIssuer, credType2));
+        env(credentials::accept(broker, credIssuer, credType2));
+        env.close();
+        auto const credIdx2 =
+            credentials::ledgerEntry(env, broker, credIssuer, credType2)[jss::result][jss::index]
+                .asString();
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx2}),
+            Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Advance time past expiration: credentials yield tecEXPIRED and are deleted
+        env.close(150s);
+        BEAST_EXPECT(env.le(credKeylet));
+        env(coverWithdrawToDest(),
+            loan_broker::kDestination(dest),
+            credentials::Ids({credIdx}),
+            Ter{tecEXPIRED});
+        env.close();
+        BEAST_EXPECT(!env.le(credKeylet));
+    }
+
     // Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw,
     // or clawback whose amount rounds to zero at sfCoverAvailable's precision
     // scale must be rejected with tecPRECISION_LOSS once the amendment is on,
@@ -2522,7 +2668,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 +2697,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 +2845,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,6 +2898,9 @@ public:
 
         testRIPD4274();
 
+        testCoverWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0);
+        testCoverWithdrawCredentialDepositPreauth(all_);
+
         testLoanBrokerDeleteLockedMPT(all_);
         testLoanBrokerDeleteLockedMPT(all_ - fixCleanup3_2_0);
 
diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp
new file mode 100644
index 0000000000..11053b6fd0
--- /dev/null
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -0,0 +1,1017 @@
+#include 
+#include 
+#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 whole-life-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 (which always check against
+    // principal + interestDue, regardless of the amendment).
+    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,
+                "whole-life origination must add interestDue to AssetsTotal; delta=" +
+                    to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue));
+            BEAST_EXPECTS(
+                debtTotalDelta == principalOutstanding + interestDue,
+                "whole-life origination must add principal+interest to DebtTotal; delta=" +
+                    to_string(debtTotalDelta));
+        }
+
+        // AssetsMaximum guard checks interestDue headroom only under
+        // whole-life accounting; 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("whole-life: 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);
+        }
+
+        // DebtMaximum guard: cash-basis projects principal-only DebtTotal;
+        // whole-life projects principal + interestDue.
+        for (auto const cashBasis : {true, false})
+        {
+            testcase(
+                std::string("LoanSet DebtMaximum guard (") +
+                (cashBasis ? "cash-basis)" : "whole-life)"));
+            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 whole-life
+    // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own
+    // TotalValueOutstanding delta exactly, since whole-life 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 (whole-life) 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,
+                "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; "
+                "debtTotalDelta=" +
+                    to_string(off.debtTotalDelta) +
+                    " totalValueDelta=" + to_string(off.totalValueDelta));
+
+            // Derive interestPaid from the whole-life 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 whole-life run.
+            BEAST_EXPECTS(
+                off.assetsTotalDelta == beast::kZero,
+                "regular on-time payment must not change AssetsTotal under whole-life; 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);
+        }
+    }
+
+    // 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);
+
+            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;
+
+            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 whole-life (accrual) accounting
+    // forever, even after the amendment is later enabled -- the switch is
+    // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment
+    // flag.
+    void
+    testLegacyVaultKeepsAccrualAfterAmendmentEnabled()
+    {
+        testcase("LEVersion: legacy vault keeps accrual 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 accrual-basis, 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: whole-life 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: whole-life 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 whole-life
+        // 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 -- whole-life 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;
+
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet);
+        env.close(
+            stateAtImpair.startDate + std::chrono::seconds(paymentInterval) +
+            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 whole-life 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 whole-life accounting 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();
+        testCashBasisLoanManage();
+        testLegacyVaultKeepsAccrualAfterAmendmentEnabled();
+        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..b0c43190c5
--- /dev/null
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -0,0 +1,909 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#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();
+
+        // 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.
+        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..264dbdcd24
--- /dev/null
+++ b/src/test/app/lending/LoanInvariants_test.cpp
@@ -0,0 +1,876 @@
+#include 
+#include 
+#include 
+#include 
+#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));
+    }
+
+    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();
+            });
+    }
+
+    // Tests run under each entry in amendmentCombinations().
+    void
+    runAmendmentSensitive(FeatureBitset features)
+    {
+        testLoanPayComputePeriodicPaymentInvariants(features);
+        testLoanPayDebtDecreaseInvariant(features);
+        testAccountSendMptMinAmountInvariant(features);
+        testMinimumBrokerCoverConsistency(features);
+    }
+
+public:
+    void
+    run() override
+    {
+        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..6cced5c97a
--- /dev/null
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -0,0 +1,692 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#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 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
+    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);
+
+            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(), 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..c5a7d54311
--- /dev/null
+++ b/src/test/app/lending/LoanMisc_test.cpp
@@ -0,0 +1,568 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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();
+
+            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
+    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..93d1671feb
--- /dev/null
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -0,0 +1,869 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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);
+        }
+    }
+
+    void
+    testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features)
+    {
+        // Regression test: LoanPay::doApply's fund-conservation check used to
+        // read XRP balances via accountHolds(..., SpendableHandling::
+        // FullBalance), which for XRP always defers to xrpLiquid (balance
+        // minus reserve, clamped at zero). When the broker fee landed on a
+        // payee sitting below its own reserve, that payee's clamped balance
+        // stayed zero and the fee vanished from the conservation sum,
+        // tripping "funds are conserved (with rounding)".
+        testcase("LoanPay funds conserved: broker fee payee below reserve");
+
+        using namespace jtx;
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        // Broker defaults match the fuzz workload: ManagementFeeRate = 100
+        // tenth-bips. The service fee guarantees feePaid > 0 on the first
+        // regular payment.
+        BrokerParameters const brokerParams;
+        Number const serviceFeeValue{2};
+        LoanParameters const loanParams{
+            .account = borrower,
+            .counter = lender,
+            .principalRequest = 1000,
+            .serviceFee = serviceFeeValue,
+            .interest = TenthBips32{percentageToTenthBips(12)},
+            .payTotal = 12,
+            .payInterval = 3600};
+
+        auto const loanOpt =
+            createLoan(env, AssetType::XRP, brokerParams, loanParams, issuer, lender, borrower);
+        if (BEAST_EXPECT(loanOpt); !loanOpt.has_value())
+            return;
+        auto const& [broker, loanKeylet, brokerPseudo] = *loanOpt;
+
+        auto const vaultPseudo = [&]() {
+            auto const vaultSle = env.le(keylet::vault(broker.vaultID));
+            if (!BEAST_EXPECT(vaultSle))
+                return AccountID{};
+            return vaultSle->at(sfAccount);
+        }();
+
+        // Raw AccountRoot balance, matching LoanPay::doApply's conservation
+        // check (not the reserve-clamped accountHolds()/xrpLiquid() value).
+        auto rawBalance = [&](AccountID const& id) -> STAmount {
+            auto const sle = env.le(keylet::account(id));
+            if (!BEAST_EXPECT(sle))
+                return STAmount{};
+            return sle->getFieldAmount(sfBalance);
+        };
+        auto lenderReserve = [&] {
+            return env.current()->fees().accountReserve(ownerCount(env, lender), 1);
+        };
+
+        STAmount const baseFee{env.current()->fees().base};
+
+        // Park the lender (broker owner, fee payee) exactly at its reserve,
+        // then burn part of the reserve with an oversized transaction fee.
+        // Fees are exempt from the reserve check, so the balance ends up
+        // below the reserve.
+        env(pay(lender, issuer, rawBalance(lender.id()) - lenderReserve() - baseFee));
+        env(noop(lender), Fee(XRP(100)));
+        env.close();
+        BEAST_EXPECT(env.balance(lender) < lenderReserve());
+
+        // First regular payment, exactly the amount due.
+        auto const state = getCurrentState(env, broker, loanKeylet);
+        STAmount const serviceFee = broker.asset(serviceFeeValue);
+        STAmount const roundedPeriodicPayment{
+            broker.asset,
+            roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
+        STAmount const totalDue = roundToScale(
+            roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward);
+
+        auto const borrowerBefore = rawBalance(borrower.id());
+        auto const vaultBefore = rawBalance(vaultPseudo);
+        auto const lenderBefore = rawBalance(lender.id());
+
+        // Before the fix, this aborted inside LoanPay::doApply on
+        // XRPL_ASSERT_PARTS(goodRounding, "xrpl::LoanPay::doApply", "funds
+        // are conserved (with rounding)").
+        env(loan::pay(borrower, loanKeylet.key, totalDue));
+        env.close();
+
+        auto const borrowerAfter = rawBalance(borrower.id());
+        auto const vaultAfter = rawBalance(vaultPseudo);
+        auto const lenderAfter = rawBalance(lender.id());
+
+        // The broker fee reached the lender's AccountRoot, even though the
+        // lender's balance remains below its reserve.
+        BEAST_EXPECT(lenderAfter > lenderBefore);
+        BEAST_EXPECT(lenderAfter < lenderReserve());
+
+        // Total funds conserved across the payer, vault, and fee payee.
+        BEAST_EXPECT(
+            borrowerBefore - baseFee + vaultBefore + lenderBefore ==
+            borrowerAfter + vaultAfter + lenderAfter);
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testLoanSetNearZeroInterestRateSucceeds();
+    }
+
+    // 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..b666281fee
--- /dev/null
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -0,0 +1,1254 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#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).
+    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(), SeqProxy::rawSequence(env.seq(lender)));
+        env(loan_broker::set(lender, vaultKeylet.key),
+            loan_broker::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, SeqProxy::rawSequence(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());
+    }
+
+#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.
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultSle))
+            return;
+        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) > beast::kZero);
+
+        // (AssetsTotal 4M - LossUnrealized 2M) * 1 share / 4M shares = 0.5,
+        // rounds down to zero drops.
+        auto const shareAsset = vaultSle->at(sfShareMPTID);
+        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
+
+        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
+            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
+        env.close();
+
+        // Same bug, IOU asset. Needs a 2nd, minimal depositor: a sole
+        // shareholder would waive the loss subtraction (fixCleanup3_2_0),
+        // returning full value instead of zero.
+        {
+            Account const issuer{"issuer"};
+            Account const iouLender{"iouLender"};
+            Account const iouDepositorB{"iouDepositorB"};
+            Account const iouBorrower{"iouBorrower"};
+
+            env.fund(XRP(10'000'000), issuer, iouLender, iouDepositorB, iouBorrower);
+            env.close();
+
+            PrettyAsset const iouAsset = issuer[iouCurrency_];
+            env(trust(iouLender, iouAsset(10'000'000)));
+            env(trust(iouDepositorB, iouAsset(10'000'000)));
+            env(trust(iouBorrower, iouAsset(10'000'000)));
+            // iouLender funds the vault deposit and the broker's cover deposit.
+            env(pay(issuer, iouLender, iouAsset(9'000'000)));
+            env(pay(issuer, iouDepositorB, iouAsset(1)));
+            env.close();
+
+            // No management fee -> LossUnrealized ends up == AssetsTotal.
+            auto const iouBroker = createVaultAndBroker(
+                env,
+                iouAsset,
+                iouLender,
+                {.vaultDeposit = 3'999'999,
+                 .debtMax = 4'000'000,
+                 .coverDeposit = 4'000'000,
+                 .managementFeeRate = TenthBips16{0}});
+
+            env(v.deposit(
+                {.depositor = iouDepositorB,
+                 .id = iouBroker.vaultKeylet().key,
+                 .amount = iouAsset(1)}));
+            env.close();
+
+            auto const iouBrokerSle = env.le(iouBroker.brokerKeylet());
+            if (!BEAST_EXPECT(iouBrokerSle))
+                return;
+            auto const iouLoanKeylet = keylet::loan(
+                iouBroker.brokerID, SeqProxy::rawSequence(iouBrokerSle->at(sfLoanSequence)));
+
+            // Draw the entire vault out as a single loan.
+            env(set(iouBorrower, iouBroker.brokerID, Number{4'000'000}),
+                Sig(sfCounterpartySignature, iouLender),
+                kPaymentTotal(2),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+
+            auto const iouVaultSle = env.le(iouBroker.vaultKeylet());
+            if (!BEAST_EXPECT(iouVaultSle))
+                return;
+            BEAST_EXPECT(iouVaultSle->at(sfLossUnrealized) == iouVaultSle->at(sfAssetsTotal));
+
+            auto const iouShareAsset = iouVaultSle->at(sfShareMPTID);
+            STAmount const oneIouShare{MPTIssue{iouShareAsset}, Number(1)};
+
+            auto const iouLenderBalanceBefore = env.balance(iouLender, iouAsset);
+            auto const iouVaultAvailableBefore = iouVaultSle->at(sfAssetsAvailable);
+            // Env::balance can't be used for shares: it resolves the issuer
+            // name, and the share issuer is the vault pseudo-account, which
+            // Env doesn't know.
+            auto const lenderShares = [&]() -> std::uint64_t {
+                auto const sle = env.le(keylet::mptoken(iouShareAsset, iouLender.id()));
+                return sle ? sle->at(sfMPTAmount) : 0;
+            };
+            auto const iouLenderSharesBefore = lenderShares();
+            auto const iouIssuanceBefore = env.le(keylet::mptokenIssuance(iouShareAsset));
+            if (!BEAST_EXPECT(iouIssuanceBefore))
+                return;
+            auto const iouSharesOutstandingBefore = iouIssuanceBefore->at(sfOutstandingAmount);
+            env(v.withdraw(
+                    {.depositor = iouLender,
+                     .id = iouBroker.vaultKeylet().key,
+                     .amount = oneIouShare}),
+                fixed ? Ter(tesSUCCESS) : Ter(tecINVARIANT_FAILED));
+            env.close();
+
+            if (fixed)
+            {
+                // Confirm this was a true zero-value transfer: balances
+                // unchanged even though a share was burned.
+                BEAST_EXPECT(env.balance(iouLender, iouAsset) == iouLenderBalanceBefore);
+                BEAST_EXPECT(lenderShares() == iouLenderSharesBefore - 1);
+                auto const iouIssuanceAfter = env.le(keylet::mptokenIssuance(iouShareAsset));
+                if (BEAST_EXPECT(iouIssuanceAfter))
+                {
+                    BEAST_EXPECT(
+                        iouIssuanceAfter->at(sfOutstandingAmount) ==
+                        iouSharesOutstandingBefore - 1);
+                }
+                auto const iouVaultAfter = env.le(iouBroker.vaultKeylet());
+                if (BEAST_EXPECT(iouVaultAfter))
+                {
+                    BEAST_EXPECT(iouVaultAfter->at(sfAssetsAvailable) == iouVaultAvailableBefore);
+                }
+            }
+        }
+    }
+
+    // Companion to the Vault_test dust-debit tests, which use a single
+    // depositor so AssetsTotal == AssetsAvailable and both debitIsNonZeroDust
+    // operands in VaultWithdraw::doApply trip together. Here a loan draws
+    // almost the entire vault, leaving AssetsTotal (1e7) far above
+    // AssetsAvailable (100): redeeming 1 share moves 1e-10 assets, which is
+    // dust against AssetsTotal but representable against AssetsAvailable, so
+    // the AssetsTotal operand alone carries the rejection.
+    void
+    testBugVaultWithdrawDustVsAssetsTotal(FeatureBitset features)
+    {
+        testcase("bug: VaultWithdraw dust debit vs AssetsTotal only");
+
+        using namespace jtx;
+        using namespace loan;
+
+        bool const fixed = features[fixCleanup3_4_0];
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), issuer, lender, borrower);
+        env.close();
+
+        PrettyAsset const iouAsset = issuer[iouCurrency_];
+        env(trust(lender, iouAsset(100'000'000)));
+        env(trust(borrower, iouAsset(100'000'000)));
+        env(pay(issuer, lender, iouAsset(20'000'000)));
+        env.close();
+
+        // Scale 10 so 1 share is worth 1e-10 assets against the 1e7 pool.
+        auto const broker = createVaultAndBroker(
+            env,
+            iouAsset,
+            lender,
+            {.vaultDeposit = 10'000'000,
+             .debtMax = 10'000'000,
+             .coverDeposit = 1'000'000,
+             .vaultScale = 10});
+
+        // Draw all but 100 units: AssetsAvailable drops to 100 while
+        // AssetsTotal stays at 1e7 (the loan is still an asset of the vault).
+        env(set(borrower, broker.brokerID, Number{9'999'900}),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(2),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(broker.vaultKeylet());
+        if (!BEAST_EXPECT(vaultSle))
+            return;
+        BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == Number{10'000'000});
+        BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == Number{100});
+
+        // 1 share redeems 1e7 * 1 / 1e17 = 1e-10 assets. Subtracting that
+        // from AssetsTotal needs 18 significant digits and canonicalizes
+        // straight back to 1e7 (no-op), while AssetsAvailable would become
+        // 99.9999999999 — perfectly representable.
+        auto const shareAsset = vaultSle->at(sfShareMPTID);
+        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
+
+        Vault const v{env};
+        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
+            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
+        env.close();
+    }
+
+    // A near-zero interest rate on a 100 USD loan
+    // produces total interest of ~6 units at loanScale -9. Numerical error
+    // in the amortization formula pushes the theoretical principal above
+    // 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();
+    }
+
+    // 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..21772d0617
--- /dev/null
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -0,0 +1,540 @@
+#include 
+#include 
+#include 
+#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:
+    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));
+
+        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(), 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});
+    }
+
+    void
+    runAmendmentIndependent()
+    {
+        testRIPD3901();
+    }
+
+    // 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..3571853b47
--- /dev/null
+++ b/src/test/app/lending/LoanSet_test.cpp
@@ -0,0 +1,733 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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);
+    }
+
+    // LoanSet in a closed-ended vault — phase gating and maturity bound.
+    void
+    testLoanSetClosedEnded()
+    {
+        testcase("LoanSet closed-ended: phase and maturity bound");
+        using namespace jtx;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        // Common loan schedule used by the phase-rejection cases below.
+        constexpr std::uint32_t kInterval = 3600u * 24u;  // 1 day
+        constexpr std::uint32_t kTotal = 2u;
+
+        // featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on
+        // `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without
+        // it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY.
+        auto const withEnv = [&, this](auto&& body) {
+            Env env(*this, testableAmendments() | featureLendingProtocolV1_1);
+            env.fund(XRP(1'000'000'000), issuer, lender, borrower);
+            env.close();
+            PrettyAsset const asset{xrpIssue(), 1'000'000};
+            body(env, asset);
+        };
+
+        auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) {
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(kTotal),
+                kPaymentInterval(kInterval),
+                Ter(expected));
+            env.close();
+        };
+
+        // 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance
+        // = true), then LoanSet is attempted before advancing past SubscriptionDate.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env,
+                asset,
+                lender,
+                BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true});
+            setLoan(env, broker, tecTOO_SOON);
+        });
+
+        // 2. Rejected during Redemption: broker is set up normally (which lands the vault in
+        // Investment), then advance the clock past RedemptionDate before attempting LoanSet.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+            using d = NetClock::duration;
+            using tp = NetClock::time_point;
+            env.close(tp{d{*broker.redemptionDate + 1}});
+            setLoan(env, broker, tecEXPIRED);
+        });
+
+        // 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            setLoan(env, broker, tesSUCCESS);
+        });
+
+        // 4. Rejected during Investment when the loan's final payment would land on or after
+        // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well
+        // past that boundary.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u;
+            auto const broker = createVaultAndBroker(
+                env,
+                asset,
+                lender,
+                BrokerParameters{
+                    .vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset});
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(10u),
+                kPaymentInterval(kInterval),
+                Ter(tecNO_PERMISSION));
+            env.close();
+        });
+
+        // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted,
+        // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic
+        // is simple: finalPayment = startDate + interval.
+        withEnv([&](Env& env, PrettyAsset const& asset) {
+            auto const broker = createVaultAndBroker(
+                env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
+            BEAST_EXPECT(broker.redemptionDate.has_value());
+
+            auto const startDate = env.now().time_since_epoch().count();
+            auto const acceptInterval = *broker.redemptionDate - 1 - startDate;
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(1u),
+                kPaymentInterval(acceptInterval),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const rejectInterval =
+                *broker.redemptionDate - env.now().time_since_epoch().count();
+            env(set(lender, broker.brokerID, broker.asset(100).value()),
+                kCounterparty(borrower),
+                Sig(sfCounterpartySignature, borrower),
+                Fee(env.current()->fees().base * 5),
+                kPaymentTotal(1u),
+                kPaymentInterval(rejectInterval),
+                Ter(tecNO_PERMISSION));
+            env.close();
+        });
+    }
+
+public:
+    void
+    run() override
+    {
+        for (auto const& features : jtx::amendmentCombinations(
+                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
+            testLoanSet(features);
+
+        testLoanSetClosedEnded();
+    }
+};
+
+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..b3669742fe
--- /dev/null
+++ b/src/test/app/lending/LoanTestBase.h
@@ -0,0 +1,3011 @@
+#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 whole-life-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;
+        // 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 be strictly before RedemptionDate). Default sized to comfortably
+        // exceed any schedule realistic tests are likely to configure.
+        std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u;
+        // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate.
+        // Useful for tests that need to observe the vault while it is still in the Subscription
+        // phase. Ignored for open-ended vaults.
+        bool skipPhaseAdvance = false;
+
+        [[nodiscard]] Number
+        maxCoveredLoanValue(Number const& currentDebt) const
+        {
+            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;
+
+        std::optional subscriptionDate;
+        std::optional redemptionDate;
+        if (params.vaultKind == VaultKind::ClosedEnded)
+        {
+            auto const nowSec = env.now().time_since_epoch().count();
+            subscriptionDate = nowSec + params.subscriptionOffset;
+            redemptionDate = *subscriptionDate + params.redemptionOffset;
+        }
+
+        auto [tx, vaultKeylet] = vault.create(
+            {.owner = lender,
+             .asset = asset,
+             .vaultKind = params.vaultKind == VaultKind::OpenEnded
+                 ? std::optional{}
+                 : std::optional{std::to_underlying(params.vaultKind)},
+             .subscriptionDate = subscriptionDate,
+             .redemptionDate = redemptionDate});
+        if (params.vaultScale)
+            tx[sfScale] = *params.vaultScale;
+        env(tx);
+        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;
+    }
+
+    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);
+        // 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(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);
+                    // 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
+    }
+};
+
+}  // 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..c6ff22bbb3
--- /dev/null
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -0,0 +1,566 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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
+        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};
+
+        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
+    runAmendmentIndependent()
+    {
+        testDisabled();
+        for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded})
+            testInvalidLoanSet(kind);
+        testInvalidLoanDelete();
+        testInvalidLoanManage();
+        testInvalidLoanPay();
+        testRequireAuth();
+        testLimitExceeded();
+    }
+
+    // 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/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
new file mode 100644
index 0000000000..70a350a4f1
--- /dev/null
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -0,0 +1,994 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultBugs_test : public VaultTestBase
+{
+private:
+    // Bug: the equality check (vault outflow == destination inflow) was
+    // skipped whenever the destination delta rounded to zero at localMinScale,
+    // including cases where the vault outflow rounded to a non-zero value and
+    // a representable amount of value was genuinely destroyed.
+    //
+    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
+    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
+    // 6 USD shifts his balance across that boundary: the exponent increments
+    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
+    // consumed by the precision-boundary rounding and cannot be credited.
+    //
+    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
+    // so the check treats it as an unavoidable IOU-precision artefact and
+    // lets the transaction succeed.
+    //
+    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
+    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
+    // representable and indicates a real accounting bug.
+    //
+    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
+    // because roundedDestinationDelta = 0 ≤ 0.
+    void
+    testVaultWithdrawEqualityEnforced()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            // Bob's balance sits 5 units below the 10^16 STAmount precision
+            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
+            // STAmount records +5, not +6 (1 USD is lost to rounding).
+            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
+
+            env(trust(alice, aliceLimit));
+            env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            env(pay(issuer, bob, atEdge2));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
+            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
+            tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary fires "
+                "invariant (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
+                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
+    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
+    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
+    //
+    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
+    // applies, then VaultInvariant's "deposit must increase vault
+    // balance" assertion fires at finalize time on the rounded vault
+    // delta of zero, returning tecINVARIANT_FAILED.
+    // Post-amendment: reject deposit that is not representable at Vault scale.
+    void
+    testBugIssuerVaultDepositAtEdge()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+
+            env.fund(XRP(100'000), issuer, owner);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const trustLimit{usd.raw(), 2, 16};
+            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(owner, trustLimit));
+            env.close();
+            env(pay(issuer, owner, ownerFund));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
+            env.close();
+
+            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
+            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
+            // tecPRECISION_LOSS proactively. Either way, no value moves.
+            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge fires "
+                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit by issuer at IOU edge rejects with "
+                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
+    // sfAssetsTotal/Available deltas.  This is symmetric to
+    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
+    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
+    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
+    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
+    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
+    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
+    // even though the state change is consistent at every precision boundary.
+    //
+    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
+    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
+    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
+    // the invariant passes.  However the transactor's own precision guard fires
+    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
+    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
+    // the depositor is protected from silently losing 1 USD to rounding.
+    void
+    testBugMakeDeltaPosteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
+            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
+            // in Number space, crossing the 1e16 boundary in IOU space.
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env(trust(bob, usd(100)));
+            env.close();
+            env(pay(issuer, alice, atEdge));
+            env(pay(issuer, bob, usd(2)));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
+            env.close();
+
+            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
+            // but exact at the Number scale retained by sfAssetsTotal.
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
+    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
+    // same max() for the vault pseudo-account RippleState.  When
+    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
+    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
+    // ULP = 1), all three computations pick the anterior coarser scale 1.
+    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
+    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
+    // valid and fully consistent at IOU precision.
+    //
+    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
+    // sfAssetsTotal/Available deltas directly in Number space, bypassing
+    // scale-coarsened rounding.
+    void
+    testBugMakeDeltaAnteriorScale()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+
+            env.fund(XRP(100'000), issuer, alice);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
+            // IOU scale-1 boundary (exponent 1, ULP = 10).
+            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
+
+            env(trust(alice, STAmount{usd.raw(), 2, 16}));
+            env.close();
+            env(pay(issuer, alice, fundAndDeposit));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
+            env(vault.deposit(
+                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
+            env.close();
+
+            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
+            // but exact at the posterior scale (ULP = 1).  The state change is
+            // consistent; only the invariant's scale selection is wrong.
+            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw across IOU scale boundary succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Bug: when a depositor's IOU trustline balance is very large (e.g.
+    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
+    // unchanged at IOU precision because the increment is sub-ULP at the
+    // vault's current asset scale.  The vault records the deposit, mints
+    // shares, and decrements the depositor's trustline, but sfAssetsTotal
+    // does not change — the conservation invariant fires because the rail
+    // delta is zero.
+    //
+    // Two sub-cases are exercised:
+    //   1. First-ever deposit into an empty vault: the depositor's own
+    //      trustline has a large balance so 1 USD canonicalizes to zero
+    //      when written back through the IOU rail.
+    //   2. Subsequent deposit after the vault already holds a large
+    //      sfAssetsTotal: a different depositor (bob, with a small balance)
+    //      sends 1 USD, which again rounds to zero at the vault's coarse
+    //      asset scale.
+    //
+    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
+    // roundToAsset(amount, vault_scale) == 0 and rejects early with
+    // tecPRECISION_LOSS before any state is modified.
+    void
+    testVaultDepositCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+
+            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
+            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
+
+            env(trust(alice, trustLimit));
+            env(trust(bob, trustLimit));
+            env.close();
+
+            env(pay(issuer, alice, aliceFund));
+            env(pay(issuer, bob, usd(1000)));
+            env.close();
+
+            Vault const vault{env};
+
+            // Scale=0 so sfAssetsTotal stores whole USD
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // Alice's deposit canonicalizes to zero at her own trustline scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+
+            // Increase vault-scale
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
+            env.close();
+
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
+                Ter(expected));
+            env.close();
+        };
+
+        {
+            // fixCleanup3_4_0 has to be off as well: its depositor-side check
+            // rejects alice's deposit for the same reason, so the invariant is
+            // only reachable with neither guard in place.
+            testcase(
+                "bug: VaultDeposit below Vault precision canonicalized to zero "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0 - 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();
+        };
+
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0,
+                Line::Holding,
+                tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)");
+            runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS);
+        }
+    }
+
+    // Bug: ValidVault::visitEntry computes destinationDelta.scale as
+    // max(before_exponent, after_exponent) for RippleState entries.  When a
+    // withdrawal credits a destination whose IOU balance sits just below a
+    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
+    // STAmount rounds up one exponent (exponent 0 → 1), making
+    // destinationDelta.scale = 1.  The invariant then calls
+    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
+    // "withdrawal must increase destination balance".
+    //
+    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
+    // Number space, bypassing scale-coarsened rounding.  The transaction
+    // itself succeeds because the effective IOU credit is non-trivial at
+    // Number precision even though the STAmount exponent shifted.
+    void
+    testVaultWithdrawCanonicalizeToZero()
+    {
+        using namespace test::jtx;
+
+        enum class DestKind : bool { ThirdParty = false, Self = true };
+
+        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            STAmount const aliceLimit{usd.raw(), 2, 16};
+            STAmount const bobLimit{usd.raw(), 2, 16};
+            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
+
+            env(trust(alice, aliceLimit));
+            if (destKind == DestKind::ThirdParty)
+                env(trust(bob, bobLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            if (destKind == DestKind::ThirdParty)
+                env(pay(issuer, bob, atEdge));
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // For the self-destination case, push alice's own trust line to
+            // the IOU edge so the next withdraw inflow crosses the boundary.
+            if (destKind == DestKind::Self)
+            {
+                env(pay(issuer, alice, atEdge));
+                env.close();
+            }
+
+            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
+            if (destKind == DestKind::ThirdParty)
+                tx[sfDestination] = bob.human();
+            env(tx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to third-party at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge fires invariant "
+                "(pre-fixCleanup3_2_0)");
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw to self at IOU edge succeeds "
+                "(post-fixCleanup3_2_0)");
+            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
+        }
+    }
+
+    // Bug: a debit can be genuinely non-zero yet still be dust relative to a
+    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's precision, e.g.
+    // AssetsTotal 2e12 minus a 1e-6 debit needs 19 significant digits and rounds straight
+    // back to 2e12. The shares still move, so ValidVault later fails with "must decrease
+    // vault balance" instead of a clean upfront rejection.
+    //
+    // Fix (fixCleanup3_4_0): reject upfront with tecPRECISION_LOSS if the debit would
+    // canonicalize back to the prior stored value.
+    //
+    // With a single depositor AssetsTotal == AssetsAvailable, so both
+    // debitIsNonZeroDust operands trip together here. LoanRounding_test's
+    // "dust debit vs AssetsTotal only" case isolates the AssetsTotal operand
+    // via a heavily-loaned vault.
+    void
+    testBugVaultDustDebitCanonicalizesToNoOp()
+    {
+        using namespace test::jtx;
+
+        // Fund a single depositor and have them deposit `total` USD in one shot (default
+        // scale 6, so shares mint at exactly total*1e6).
+        auto const seedVault = [](Env& env, Number const& total) {
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const holder{"holder"};
+
+            env.fund(XRP(1'000'000), issuer, owner, holder);
+            env.close();
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(holder, usd(100'000'000'000'000LL)));
+            env.close();
+            env(pay(issuer, holder, usd(total)));
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = holder, .id = keylet.key, .amount = usd(total)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            return keylet;
+        };
+
+        {
+            auto runScenario = [&](FeatureBitset features, TER expected) {
+                Env env(*this, features);
+                Number const total{2, 12};
+                auto const keylet = seedVault(env, total);
+
+                Account const issuer{"issuer"};
+                PrettyAsset const usd{issuer["USD"]};
+
+                // 1 share's worth of assets: 1e-6, below AssetsTotal's storage precision.
+                env(Vault::clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = Account{"holder"},
+                         .amount = usd(Number{1, -6}).value()}),
+                    Ter(expected));
+                env.close();
+            };
+
+            testcase("bug: VaultClawback dust debit fires invariant (pre-fixCleanup3_4_0)");
+            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+            testcase("bug: VaultClawback dust debit rejected cleanly (post-fixCleanup3_4_0)");
+            runScenario(all_, tecPRECISION_LOSS);
+        }
+
+        {
+            auto runScenario = [&](FeatureBitset features, TER expected) {
+                Env env(*this, features);
+                Number const total{2, 12};
+                auto const keylet = seedVault(env, total);
+
+                MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
+
+                // Redeem 1 share, worth 1e-6 assets, below AssetsTotal's storage precision.
+                env(Vault::withdraw(
+                        {.depositor = Account{"holder"},
+                         .id = keylet.key,
+                         .amount = STAmount{share, 1}}),
+                    Ter(expected));
+                env.close();
+            };
+
+            testcase("bug: VaultWithdraw dust debit fires invariant (pre-fixCleanup3_4_0)");
+            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+            testcase("bug: VaultWithdraw dust debit rejected cleanly (post-fixCleanup3_4_0)");
+            runScenario(all_, tecPRECISION_LOSS);
+        }
+    }
+
+    // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
+    // shFULL_BALANCE), which for an IOU asset adds the counterparty's
+    // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
+    // getTrustLineBalance with includeOppositeLimit=true). When the
+    // depositor's raw balance < deposit amount but raw + opposite limit >=
+    // amount, preclaim is satisfied. doApply then calls
+    // directSendNoFeeIOU, which unconditionally subtracts saAmount from
+    // saBalance — driving the trust line negative — and returns tesSUCCESS.
+    // The post-send sanity check uses the default shSIMPLE_BALANCE (no
+    // opposite-limit add), sees a negative balance, and returns tefINTERNAL.
+    void
+    testVaultDepositNegativeBalanceFromOppositeLimit()
+    {
+        auto runTest = [&](FeatureBitset f, TER expected) {
+            using namespace test::jtx;
+            using namespace std::literals;
+
+            Env env{*this, f};
+            Account const gw{"gateway"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+
+            env.fund(XRP(10000), gw, owner, depositor);
+            env.close();
+
+            // Gateway with DefaultRipple so vault creation on its IOU works.
+            env(fset(gw, asfDefaultRipple));
+            env.close();
+
+            // Depositor opens a trust line to gateway and receives a small
+            // balance.
+            PrettyAsset const usd = gw["USD"];
+            env.trust(usd(1000), depositor);
+            env(pay(gw, depositor, usd(100)));  // raw trust-line balance: 100
+            env.close();
+
+            // Key precondition: gateway sets a non-zero limit on the same
+            // RippleState — the "opposite field" from depositor's perspective.
+            // This is what inflates shFULL_BALANCE in preclaim above the raw
+            // balance.
+            env(trust(gw, depositor["USD"](1000)));
+            env.close();
+
+            // Create the IOU vault.
+            Vault const vault{env};
+            auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
+            env(vaultTx);
+            env.close();
+
+            // Submit a deposit of 500 USD:
+            //   - raw balance:                100 USD
+            //   - opposite limit (gw's side): 1000 USD
+            //   - preclaim sees 100 + 1000 = 1100, passes (>= 500)
+            //   - doApply transfers 500, depositor's trust-line balance
+            //     becomes -400
+            //   - sanity check at VaultDeposit.cpp:256 fires
+            //   - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
+            auto depositTx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
+            env(depositTx, Ter(expected));
+            env.close();
+        };
+
+        {
+            testcase(
+                "IOU vault deposit exceeding depositor's balance but "
+                "within counterparty's trust limit, pre-fixCleanup3_2_0 "
+                "(tefINTERNAL)");
+            runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
+        }
+        {
+            testcase(
+                "IOU vault deposit exceeding depositor's balance but "
+                "within counterparty's trust limit, post-fixCleanup3_2_0 "
+                "(tesSUCCESS)");
+            runTest(test::jtx::testableAmendments(), tesSUCCESS);
+        }
+    }
+
+    // Reproduction: canWithdraw IOU limit check bypassed when
+    // withdrawal amount is specified in shares (MPT) rather than in assets.
+    void
+    testBug6LimitBypassWithShares()
+    {
+        using namespace test::jtx;
+        testcase("Bug6 - limit bypass with share-denominated withdrawal");
+
+        auto const allAmendments = testableAmendments() | featureSingleAssetVault;
+
+        for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
+        {
+            bool const withFix = features[fixCleanup3_1_3];
+
+            Env env{*this, features};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            Account const charlie{"charlie"};
+            Vault const vault{env};
+
+            env.fund(XRP(1000), issuer, owner, depositor, charlie);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env.trust(asset(1000), depositor);
+            env(pay(issuer, owner, asset(200)));
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            // Charlie gets a LOW trustline limit of 5
+            env.trust(asset(5), charlie);
+            env.close();
+
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const depositTx =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            env(depositTx);
+            env.close();
+
+            // Get the share MPT info
+            auto const vaultSle = env.le(keylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
+            MPTIssue const shares(mptIssuanceID);
+            PrettyAsset const share(shares);
+
+            // CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
+            // Charlie's limit is 5, so this should be rejected with tecNO_LINE
+            // regardless of the amendment.
+            {
+                auto withdrawTx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                withdrawTx[sfDestination] = charlie.human();
+                env(withdrawTx, Ter{tecNO_LINE});
+                env.close();
+            }
+            auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get());
+
+            // Withdraw the equivalent amount in shares to charlie.
+            // Post-fix: rejected (tecNO_LINE) because the share amount is
+            //   converted to assets and the trustline limit is checked.
+            // Pre-fix: succeeds (tesSUCCESS) because the limit check was
+            //   skipped for share-denominated withdrawals.
+            {
+                auto withdrawTx = vault.withdraw(
+                    {.depositor = depositor,
+                     .id = keylet.key,
+                     .amount = STAmount(share, 10'000'000)});
+                withdrawTx[sfDestination] = charlie.human();
+                env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
+                env.close();
+
+                auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get());
+                if (withFix)
+                {
+                    // Post-fix: charlie's balance is unchanged — the withdrawal
+                    // was correctly rejected despite being share-denominated.
+                    BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
+                }
+                else
+                {
+                    // Pre-fix: charlie received the assets, bypassing the
+                    // trustline limit.
+                    BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
+                }
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultWithdrawEqualityEnforced();
+        testBugIssuerVaultDepositAtEdge();
+        testBugMakeDeltaPosteriorScale();
+        testBugMakeDeltaAnteriorScale();
+        testVaultDepositCanonicalizeToZero();
+        testBugDepositShareTruncationSubUlp();
+        testVaultWithdrawCanonicalizeToZero();
+        testBugVaultDustDebitCanonicalizesToNoOp();
+        testVaultDepositNegativeBalanceFromOppositeLimit();
+        testBug6LimitBypassWithShares();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultBugs, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
new file mode 100644
index 0000000000..2a9fe42b1c
--- /dev/null
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -0,0 +1,1122 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultClawback_test : public VaultTestBase
+{
+private:
+    void
+    testVaultClawbackBurnShares()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        Env env(*this, beast::Severity::Warning);
+
+        auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault != nullptr);
+
+            return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
+        };
+
+        auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault != nullptr);
+
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            BEAST_EXPECT(sleIssuance != nullptr);
+
+            return sleIssuance->at(sfOutstandingAmount);
+        };
+
+        auto const setupVault = [&](PrettyAsset const& asset,
+                                    Account const& owner,
+                                    Account const& depositor) -> std::pair {
+            Vault const vault{env};
+
+            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const& vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+
+            Asset const share = vaultSle->at(sfShareMPTID);
+
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
+            BEAST_EXPECT(availablePreDefault == totalPreDefault);
+            BEAST_EXPECT(availablePreDefault == asset(100).value());
+
+            // attempt to clawback shares while there are assets fails
+            env(vault.clawback(
+                    {.issuer = owner,
+                     .id = vaultKeylet.key,
+                     .holder = depositor,
+                     .amount = share(0).value()}),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
+            auto const& brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+
+            env(set(owner, vaultKeylet.key));
+            env.close();
+
+            auto const& loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+
+            // Create a simple Loan for the full amount of Vault assets
+            env(set(depositor, brokerKeylet.key, asset(100).value()),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // attempt to clawback shares while there assetsAvailable == 0 and
+            // assetsTotal > 0 fails
+            env(vault.clawback(
+                    {.issuer = owner,
+                     .id = vaultKeylet.key,
+                     .holder = depositor,
+                     .amount = share(0).value()}),
+                Ter(tecNO_PERMISSION));
+            env.close();
+
+            env.close(std::chrono::seconds{120 + 60});
+
+            env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+
+            auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet);
+
+            BEAST_EXPECT(availablePostDefault == totalPostDefault);
+            BEAST_EXPECT(availablePostDefault == asset(0).value());
+            BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable);
+
+            return std::make_pair(vault, vaultKeylet);
+        };
+
+        auto const testCase = [&](PrettyAsset const& asset,
+                                  std::string const& prefix,
+                                  Account const& owner,
+                                  Account const& depositor) {
+            {
+                testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                // when asset is XRP or owner is not issuer clawback fail
+                // when owner is issuer precision loss occurs as vault is
+                // empty
+                auto const expectedTer = [&]() {
+                    if (asset.native())
+                        return Ter(temMALFORMED);
+                    if (asset.raw().getIssuer() != owner.id())
+                        return Ter(tecNO_PERMISSION);
+                    return Ter(tecPRECISION_LOSS);
+                }();
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    expectedTer);
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(1).value(),
+                    }),
+                    Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix +
+                    " owner implicit complete share clawback");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    // when owner is issuer implicit clawback fails
+                    asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS)
+                                                                            : Ter(tecWRONG_ASSET));
+                env.close();
+            }
+
+            {
+                testcase(
+                    "VaultClawback (share) - " + prefix +
+                    " owner explicit complete share clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+            }
+            {
+                testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+            }
+
+            {
+                testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tesSUCCESS));
+
+                // Now the vault is empty, clawback again fails
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = owner,
+                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+                env.close();
+            }
+        };
+
+        Account const owner{"alice"};
+        Account const depositor{"bob"};
+        Account const issuer{"issuer"};
+
+        env.fund(XRP(10000), issuer, owner, depositor);
+        env.close();
+
+        // Test XRP
+        PrettyAsset const xrp = xrpIssue();
+        testCase(xrp, "XRP", owner, depositor);
+        testCase(xrp, "XRP (depositor is owner)", owner, owner);
+
+        // Test IOU
+        PrettyAsset const iou = issuer["IOU"];
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        env.trust(iou(1000), owner);
+        env.trust(iou(1000), depositor);
+        env(pay(issuer, owner, iou(100)));
+        env(pay(issuer, depositor, iou(100)));
+        env.close();
+        testCase(iou, "IOU", owner, depositor);
+        testCase(iou, "IOU (owner is issuer)", issuer, depositor);
+
+        // Test MPT
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+        PrettyAsset const mpt = mptt.issuanceID();
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = depositor});
+        env(pay(issuer, owner, mpt(1000)));
+        env(pay(issuer, depositor, mpt(1000)));
+        env.close();
+        testCase(mpt, "MPT", owner, depositor);
+        testCase(mpt, "MPT (owner is issuer)", issuer, depositor);
+    }
+
+    void
+    testVaultClawbackAssets()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+        Env env(*this);
+        env.enableFeature(fixCleanup3_1_3);
+
+        auto const setupVault = [&](PrettyAsset const& asset,
+                                    Account const& owner,
+                                    Account const& depositor,
+                                    Account const& issuer) -> std::pair {
+            Vault const vault{env};
+
+            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const& vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            return std::make_pair(vault, vaultKeylet);
+        };
+
+        auto const testCase = [&](PrettyAsset const& asset,
+                                  std::string const& prefix,
+                                  Account const& owner,
+                                  Account const& depositor,
+                                  Account const& issuer) {
+            if (asset.native())
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+                // If the asset is XRP, clawback with amount fails as malformed
+                // when asset is specified.
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(temMALFORMED));
+                // When asset is implicit, clawback fails as no permission.
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecNO_PERMISSION));
+                return;
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix + " clawback for different asset fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                Account const issuer2{"issuer2"};
+                PrettyAsset const asset2 = issuer2["FOO"];
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset2(1).value(),
+                    }),
+                    Ter(tecWRONG_ASSET));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " ambiguous owner/issuer asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer);
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecWRONG_ASSET));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tecNO_PERMISSION));
+
+                env(vault.clawback({
+                        .issuer = owner,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer);
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = issuer,
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+                auto const& vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                Asset const share = vaultSle->at(sfShareMPTID);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = share(1).value(),
+                    }),
+                    Ter(tecNO_PERMISSION));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " partial issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(1).value(),
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " implicit full issuer asset clawback succeeds");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tesSUCCESS));
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " zero-amount clawback clamped with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units, reducing assetsAvailable to 60
+                // while assetsTotal stays at 100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Zero-amount clawback (= "clawback all") should succeed,
+                // clamped to assetsAvailable (60) rather than the full
+                // share value (100).
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Only 60 assets clawed back; loan's 40 still outstanding
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " non-zero clawback clamped with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Request 100 but only 60 available — clamped to 60
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(100).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " partial clawback below available with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                // Create a loan broker backed by this vault
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                // Clawback 30 — well under available (60), no clamping needed
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(30).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value());
+
+                    // 30 of 100 shares destroyed (1:1 ratio), 70 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " clawback exactly equal to available with outstanding loan");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(40).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                // Clawback exactly 60 — at the boundary, no clamping needed
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(60).value(),
+                    }),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+
+                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
+                }
+            }
+
+            {
+                testcase(
+                    "VaultClawback (asset) - " + prefix +
+                    " clawback with zero available (fully borrowed)");
+                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
+
+                auto const vaultSle = env.le(vaultKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+                auto const brokerKeylet =
+                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(set(owner, vaultKeylet.key));
+                env.close();
+
+                // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100
+                env(set(depositor, brokerKeylet.key, asset(100).value()),
+                    loan::kInterestRate(TenthBips32(0)),
+                    kGracePeriod(60),
+                    kPaymentInterval(120),
+                    kPaymentTotal(10),
+                    Sig(sfCounterpartySignature, owner),
+                    Fee(env.current()->fees().base * 2),
+                    Ter(tesSUCCESS));
+                env.close();
+
+                {
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                }
+
+                auto const sharesBefore = env.balance(depositor, shares);
+
+                // Zero-amount clawback — nothing available, clamped to 0,
+                // resulting in zero shares destroyed → tecPRECISION_LOSS
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                    }),
+                    Ter(tecPRECISION_LOSS));
+                env.close();
+
+                // Explicit amount clawback — also nothing available
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = vaultKeylet.key,
+                        .holder = depositor,
+                        .amount = asset(50).value(),
+                    }),
+                    Ter(tecPRECISION_LOSS));
+                env.close();
+
+                {
+                    // Nothing changed — vault and shares unchanged
+                    auto const sle = env.le(vaultKeylet);
+                    BEAST_EXPECT(sle != nullptr);
+                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
+                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
+                    auto const sharesAfter = env.balance(depositor, shares);
+                    BEAST_EXPECT(sharesAfter == sharesBefore);
+                }
+            }
+        };
+
+        Account const owner{"alice"};
+        Account const depositor{"bob"};
+        Account const issuer{"issuer"};
+
+        env.fund(XRP(10000), issuer, owner, depositor);
+        env.close();
+
+        // Test XRP
+        PrettyAsset const xrp = xrpIssue();
+        testCase(xrp, "XRP", owner, depositor, issuer);
+
+        // Test IOU
+        PrettyAsset const iou = issuer["IOU"];
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+        env.trust(iou(2000), owner);
+        env.trust(iou(2000), depositor);
+        env(pay(issuer, owner, iou(2000)));
+        env(pay(issuer, depositor, iou(2000)));
+        env.close();
+        testCase(iou, "IOU", owner, depositor, issuer);
+
+        // Test MPT
+        MPTTester mptt{env, issuer, kMptInitNoFund};
+        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+
+        PrettyAsset const mpt = mptt.issuanceID();
+        mptt.authorize({.account = owner});
+        mptt.authorize({.account = depositor});
+        env(pay(issuer, depositor, mpt(2000)));
+        env.close();
+        testCase(mpt, "MPT", owner, depositor, issuer);
+
+        // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback
+        // returns early without clamping to assetsAvailable.
+        {
+            testcase(
+                "VaultClawback (asset) - IOU pre-fixCleanup3_1_3"
+                " zero-amount clawback unclamped with outstanding loan");
+
+            env.disableFeature(fixCleanup3_1_3);
+
+            auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer);
+
+            auto const vaultSle = env.le(vaultKeylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            if (!vaultSle)
+                return;
+
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Create a loan broker backed by this vault
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(set(owner, vaultKeylet.key));
+            env.close();
+
+            // Depositor borrows 40 units, reducing assetsAvailable to 60
+            // while assetsTotal stays at 100
+            env(set(depositor, brokerKeylet.key, iou(40).value()),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
+            }
+
+            auto const sharesBefore = env.balance(depositor, shares);
+
+            // Legacy: zero-amount clawback tries to recover the full
+            // share value (100) without clamping to assetsAvailable (60).
+            // This causes the vault balance to go negative, triggering
+            // the sanity check in doApply → tefINTERNAL.
+            env(vault.clawback({
+                    .issuer = issuer,
+                    .id = vaultKeylet.key,
+                    .holder = depositor,
+                }),
+                Ter(tefINTERNAL));
+            env.close();
+
+            {
+                // Transaction rolled back — vault and shares unchanged
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle != nullptr);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
+                auto const sharesAfter = env.balance(depositor, shares);
+                BEAST_EXPECT(sharesAfter == sharesBefore);
+            }
+
+            env.enableFeature(fixCleanup3_1_3);
+        }
+    }
+
+    void
+    testVaultEscrowedMPT()
+    {
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        // Verify vault deposit/withdraw/clawback respect sfLockedAmount.
+        // When MPT tokens are escrowed, sfMPTAmount is reduced and
+        // sfLockedAmount is increased. Vault operations go through
+        // accountSend/accountHolds which read sfMPTAmount, so escrowed
+        // tokens are naturally excluded.
+
+        {
+            testcase("Vault deposit fails when MPT asset is escrowed");
+
+            Env env{*this, testableAmendments()};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            mptt.authorize({.account = bob});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40
+            auto const escrowSeq = env.seq(depositor);
+            env(escrow::create(depositor, bob, asset(60)),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 should fail — only 40 spendable
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tecINSUFFICIENT_FUNDS));
+            env.close();
+
+            // Deposit 40 (the unlocked balance) should succeed
+            env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
+            }
+
+            // Clean up escrow
+            env(escrow::finish(bob, depositor, escrowSeq),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFulfillment(escrow::kFb1),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+        }
+
+        {
+            testcase("Vault withdraw respects escrowed shares");
+
+            Env env{*this, testableAmendments()};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 → get shares
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultSle = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Authorize bob for share MPT so he can receive escrowed shares
+            auto const shareMPTID = vaultSle->at(sfShareMPTID);
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
+                jv[jss::TransactionType] = jss::MPTokenAuthorize;
+                env(jv, Ter(tesSUCCESS));
+                env.close();
+            }
+
+            // Escrow 60% of shares
+            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
+            env(escrow::create(depositor, bob, escrowAmount),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Withdraw all 100 should fail — only 40% of shares are unlocked
+            env(vault.withdraw(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tecINSUFFICIENT_FUNDS));
+            env.close();
+
+            // Withdraw 40 (matching unlocked shares) should succeed
+            env(vault.withdraw(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
+            }
+        }
+
+        {
+            testcase("Vault clawback only recovers unlocked shares");
+
+            Env env{*this, testableAmendments() | fixCleanup3_1_3};
+            auto const baseFee = env.current()->fees().base;
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(10000), issuer, owner, depositor, bob);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, depositor, asset(100)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            // Deposit 100 → get shares
+            env(vault.deposit(
+                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const vaultSle = env.le(vaultKeylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            env.memoize(Account("vault", vaultSle->at(sfAccount)));
+            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
+
+            // Authorize bob for share MPT so he can receive escrowed shares
+            auto const shareMPTID = vaultSle->at(sfShareMPTID);
+            {
+                json::Value jv;
+                jv[jss::Account] = bob.human();
+                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
+                jv[jss::TransactionType] = jss::MPTokenAuthorize;
+                env(jv, Ter(tesSUCCESS));
+                env.close();
+            }
+
+            // Escrow 60% of shares
+            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
+            env(escrow::create(depositor, bob, escrowAmount),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Zero-amount clawback ("all") — should only recover assets
+            // corresponding to unlocked shares (40%)
+            env(vault.clawback({
+                    .issuer = issuer,
+                    .id = vaultKeylet.key,
+                    .holder = depositor,
+                }),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(vaultKeylet);
+                BEAST_EXPECT(sle != nullptr);
+                // Only 40 of 100 assets recovered (matching 40% unlocked shares)
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
+
+                // Depositor's unlocked shares are now 0
+                auto const sharesAfter = env.balance(depositor, shares);
+                BEAST_EXPECT(sharesAfter == shares(0));
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultClawbackBurnShares();
+        testVaultClawbackAssets();
+        testVaultEscrowedMPT();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultClawback, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp
new file mode 100644
index 0000000000..252a7f4990
--- /dev/null
+++ b/src/test/app/vault/VaultClosedEnded_test.cpp
@@ -0,0 +1,1008 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultClosedEnded_test : public VaultTestBase
+{
+private:
+    // VaultCreate malformation and happy paths for closed-ended vaults, plus the
+    // featureLendingProtocolV1_1 gate.
+    void
+    testVaultCreateClosedEnded()
+    {
+        testcase("closed-ended VaultCreate");
+        using namespace test::jtx;
+
+        auto const withEnv = [this](FeatureBitset features, auto&& body) {
+            Env env{*this, features};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+            Vault vault{env};
+            body(env, owner, vault);
+        };
+
+        Asset const asset = xrpIssue();
+        auto const minPeriod = kMinInvestmentPeriod;
+        auto const maxPeriod = kMaxInvestmentPeriod;
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+
+        // Gate: the three new fields require featureLendingProtocolV1_1.
+        withEnv(
+            testableAmendments() - featureLendingProtocolV1_1,
+            [&](Env& env, Account const& owner, Vault& vault) {
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto [tx, keylet] = vault.create(
+                    {.owner = owner,
+                     .asset = asset,
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = sub + minPeriod});
+                env(tx, Ter{temDISABLED});
+            });
+
+        /*
+         * Valid closed-ended creation with a comfortably interior gap (well above
+         * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD).
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + 86400;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
+                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .redemptionDate = sub + minPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        /*
+         * SubscriptionDate not strictly after parent close time (preclaim, state-dependent -
+         * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see
+         * the note below the next case. Note: there is no separate "expired RedemptionDate" test
+         * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past
+         * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the
+         * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the
+         * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause
+         * of tecEXPIRED.
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const nowSec = env.now().time_since_epoch().count();
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = nowSec,
+                 .redemptionDate = nowSec + minPeriod});
+            env(tx, Ter{tecEXPIRED});
+        });
+
+        /*
+         * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >=
+         * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
+         * sub case, the latter yielding a negative signed int64 gap that is caught by the
+         * sub-minimum branch of the gap check.
+         */
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + minPeriod - 1});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub - 1});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + maxPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as
+        // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = sub + maxPeriod + 1});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is
+        // inclusive).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + minPeriod;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is
+        // accepted (upper bound is exclusive).
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + maxPeriod - 1;
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        });
+
+        // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present
+        // => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub});
+            env(tx, Ter{temMALFORMED});
+        });
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto [tx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Unrecognised VaultKind => temMALFORMED.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = static_cast(closedEnded + 1)});
+            env(tx, Ter{temMALFORMED});
+        });
+
+        // Happy path: open-ended vault (no new fields present) is unaffected.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
+                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
+                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
+            }
+        });
+
+        // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same
+        // as absent. Per spec, absent and OpenEnded are equivalent.
+        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = std::to_underlying(VaultKind::OpenEnded)});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                // OpenEnded is sfVaultKind's default; SoeDefault fields
+                // aren't serialized when they hold the default value.
+                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
+                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
+                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
+            }
+        });
+    }
+
+    // SubscriptionDate boundary cases at the top of the UINT32 range.
+    // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits
+    // the inclusive lower bound of the kMinInvestmentPeriod gap check.
+    // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is
+    // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value
+    // can satisfy the gap check.
+    void
+    testVaultCreateSubscriptionDateBoundary()
+    {
+        testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX");
+        using namespace test::jtx;
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+        Asset const asset = xrpIssue();
+
+        {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod;
+            auto const red = std::numeric_limits::max();
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            env.close();
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
+                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
+            }
+        }
+
+        // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod
+        // wraps in a UINT32. Every candidate red must fall to temMALFORMED via
+        // the gap check in preflight.
+        auto const rejectAtMax = [&, this](std::uint32_t red) {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(1000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create(
+                {.owner = owner,
+                 .asset = asset,
+                 .vaultKind = closedEnded,
+                 .subscriptionDate = std::numeric_limits::max(),
+                 .redemptionDate = red});
+            env(tx, Ter{temMALFORMED});
+        };
+        rejectAtMax(std::numeric_limits::max());
+        rejectAtMax(0u);
+        rejectAtMax(kMinInvestmentPeriod - 1u);
+    }
+
+    // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now
+    // == SubscriptionDate case (which must still resolve to Subscription).
+    void
+    testVaultPhaseDerivation()
+    {
+        testcase("closed-ended phase derivation");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), owner, depositor);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
+
+        // Pre-seed shares during Subscription so the depositor has capital to
+        // withdraw at the Redemption boundary below.
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()}));
+        env.close();
+
+        auto const deposit =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.deposit(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+            };
+        auto const withdraw =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.withdraw(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+            };
+
+        auto const runTest = [&](TER expectedDeposit,
+                                 TER expectedWithdraw,
+                                 std::source_location const& loc =
+                                     std::source_location::current()) {
+            deposit(expectedDeposit, loc);
+            withdraw(expectedWithdraw, loc);
+        };
+
+        // Assert both deposit and withdraw return codes at each point so the
+        // active phase is uniquely identified:
+        //   Subscription: deposit tesSUCCESS, withdraw tesSUCCESS
+        //   Investment:   deposit tecEXPIRED, withdraw tecTOO_SOON
+        //   Redemption:   deposit tecEXPIRED, withdraw tesSUCCESS
+
+        // Ledger time comfortably before SubscriptionDate: Subscription.
+        runTest(tesSUCCESS, tesSUCCESS);
+
+        // Boundary: parent close time exactly at SubscriptionDate must still
+        // be Subscription.
+        closeToTime(env, tp{d{sub}});
+        runTest(tesSUCCESS, tesSUCCESS);
+
+        // One second past SubscriptionDate: Investment.
+        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
+        runTest(tecEXPIRED, tecTOO_SOON);
+
+        // Any point strictly before RedemptionDate remains Investment.
+        closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env));
+        runTest(tecEXPIRED, tecTOO_SOON);
+
+        // Boundary: parent close time == RedemptionDate is Redemption (per
+        // spec table: now >= RedemptionDate). Deposits are rejected but
+        // withdrawals succeed.
+        closeToTime(env, tp{d{red}});
+        runTest(tecEXPIRED, tesSUCCESS);
+        env.close();
+    }
+
+    // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any
+    // dates present on the vault.
+    void
+    testVaultPhaseDerivationOpenEnded()
+    {
+        testcase("open-ended phase derivation");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        env.fund(XRP(1000), owner);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        Vault const vault{env};
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        auto const checkPhaseAt = [&](NetClock::time_point at) {
+            closeToTime(env, at);
+            auto const sle = env.le(keylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase);
+        };
+
+        // Advance the clock through a wide range of ledger times: an open-ended vault's phase
+        // must be NoPhase at every one of them, because the derivation short-circuits on
+        // VaultKind::OpenEnded before it looks at any dates.
+        auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution;
+        checkPhaseAt(ledgerTime);
+        checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod});
+        checkPhaseAt(
+            ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} -
+            env.closed()->header().closeTimeResolution);
+    }
+
+    // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and
+    // Redemption.
+    void
+    testVaultDepositClosedEnded()
+    {
+        testcase("closed-ended VaultDeposit phase gating");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), owner, depositor);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
+
+        auto const deposit =
+            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
+                env(
+                    WithSourceLocation{
+                        vault.deposit(
+                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
+                        loc},
+                    Ter{expected});
+                env.close();
+            };
+
+        // Subscription: allowed.
+        deposit(tesSUCCESS);
+
+        // Investment: rejected.
+        env.close(tp{d{sub + 1}});
+        deposit(tecEXPIRED);
+
+        // Redemption: rejected.
+        env.close(tp{d{red}});
+        deposit(tecEXPIRED);
+    }
+
+    // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The
+    // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with
+    // capital deployed as an outstanding loan.
+    void
+    testVaultWithdrawClosedEnded()
+    {
+        testcase("closed-ended VaultWithdraw phase gating");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, depositor, borrower);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        // Widen the Investment window so a single-payment loan (min payment
+        // interval kMinPaymentInterval = 60s) fits before RedemptionDate.
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u);
+
+        // Deposit XRP(100) in Subscription so the depositor's shares are
+        // worth XRP(100). The vault holds XRP(100) with
+        // AssetsAvailable == AssetsTotal.
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        // Create a loan broker backed by this vault. LoanBrokerSet has no
+        // phase gate, so this is fine to do in Subscription.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        auto const withdraw = [&](STAmount const& amount,
+                                  TER expected,
+                                  std::source_location const& loc =
+                                      std::source_location::current()) {
+            env(
+                WithSourceLocation{
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}),
+                    loc},
+                Ter{expected});
+            env.close();
+        };
+
+        // Subscription: allowed (LP cancel).
+        withdraw(XRP(1).value(), tesSUCCESS);
+
+        // Investment: rejected.
+        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
+        withdraw(XRP(1).value(), tecTOO_SOON);
+
+        // Deploy capital: borrower takes a loan of XRP(60) against the
+        // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal
+        // remains ~XRP(99).
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small
+        // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share
+        // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the
+        // vault-shortage guard (not the insufficient-shares guard).
+        closeToTime(env, tp{d{red}});
+        withdraw(XRP(10).value(), tesSUCCESS);
+        withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS);
+    }
+
+    // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with
+    // multiple depositors and a real loan originated through the Investment leg. Exercises every
+    // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each
+    // phase.
+    void
+    testVaultClosedEndedLifecycle()
+    {
+        testcase("closed-ended vault lifecycle (subscribe → invest → redeem)");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, alice, bob, borrower);
+        env.close();
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+        Asset const asset = xrpIssue();
+        // Widen the Investment window so a single-payment loan (min payment interval
+        // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom.
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        auto const sleCreate = env.le(keylet);
+        BEAST_EXPECT(sleCreate);
+        MPTIssue const shares{sleCreate->at(sfShareMPTID)};
+
+        auto const balancesEq = [&](STAmount const& available, STAmount const& total) {
+            auto const sle = env.le(keylet);
+            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
+            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
+        };
+        auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); };
+
+        // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share
+        // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the
+        // MPToken SLE directly to avoid the lookup.
+        auto const sharesEq = [&](Account const& holder, std::uint64_t expected) {
+            auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id()));
+            std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u;
+            BEAST_EXPECT(actual == expected);
+        };
+
+        // ---- Subscription phase ----
+        // A legitimate VaultSet succeeds (positive control for 3.7).
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfData] = "AA";
+            env(tx);
+            env.close();
+        }
+
+        // alice deposits 100 XRP.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        sharesEq(alice, 100'000'000);
+        availableEq(XRP(100).value());
+
+        // bob deposits 200 XRP.
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}));
+        env.close();
+        sharesEq(bob, 200'000'000);
+        availableEq(XRP(300).value());
+
+        // alice cancels 25 XRP (LP cancel is permitted in Subscription).
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()}));
+        env.close();
+        sharesEq(alice, 75'000'000);
+        availableEq(XRP(275).value());
+
+        // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is
+        // fine to do in Subscription.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        // ---- Investment phase (now == sub + 1) ----
+        env.close(tp{d{sub + 1}});
+
+        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecEXPIRED});
+        env.close();
+        // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON.
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecTOO_SOON});
+        env.close();
+
+        // A real loan is originated during Investment (permitted only in this phase). Zero-interest
+        // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis
+        // accounting recognise no interest at origination); AssetsAvailable drops by the loan
+        // principal.
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+        auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key));
+        BEAST_EXPECT(sleBroker);
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        BEAST_EXPECT(env.le(loanKeylet));
+        balancesEq(XRP(215).value(), XRP(275).value());
+
+        // Non-immutable VaultSet still works in Investment (positive control).
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfData] = "BB";
+            env(tx);
+            env.close();
+        }
+
+        // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved.
+        sharesEq(alice, 75'000'000);
+        sharesEq(bob, 200'000'000);
+
+        // ---- Redemption phase (now == red) ----
+        env.close(tp{d{red}});
+
+        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both
+        // Investment and Redemption.
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
+            Ter{tecEXPIRED});
+        env.close();
+
+        // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215).
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()}));
+        env.close();
+        sharesEq(alice, 0);
+        balancesEq(XRP(140).value(), XRP(200).value());
+
+        // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
+        // sits in the outstanding loan). A full 200 XRP withdrawal fails against the
+        // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
+        // by the loan receivable — the realistic outcome when capital is still deployed at
+        // Redemption.
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
+            Ter{tecINSUFFICIENT_FUNDS});
+        env.close();
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
+        env.close();
+        sharesEq(bob, 60'000'000);
+        balancesEq(XRP(0).value(), XRP(60).value());
+
+        // Defensive spot-check that the three immutable fields have not changed across the entire
+        // lifecycle. Direct immutability coverage lives with the invariant tests.
+        auto const sleFinal = env.le(keylet);
+        if (BEAST_EXPECT(sleFinal))
+        {
+            BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded);
+            BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub);
+            BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red);
+        }
+    }
+
+    // A loan whose payment is made after the Investment phase has ended
+    // (well past its next-due-date and grace period, into Redemption) must
+    // still be repayable. The vault phase must not gate LoanPay.
+    void
+    testVaultLoanLatePaymentAfterInvestment()
+    {
+        testcase("closed-ended vault: late loan payment during Redemption succeeds");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const borrower{"borrower"};
+        env.fund(XRP(10'000), owner, alice, borrower);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        // Investment phase: originate a zero-interest, single-payment loan
+        // with a 300s payment interval and 60s grace. The payment is due
+        // shortly after origination and well before RedemptionDate.
+        env.close(tp{d{sub + 1}});
+        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
+            loan::kInterestRate(TenthBips32(0)),
+            kGracePeriod(60),
+            kPaymentInterval(300),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        BEAST_EXPECT(env.le(loanKeylet));
+
+        // Advance to Redemption. The payment is now past its due date and
+        // grace, and the vault is no longer in Investment.
+        closeToTime(env, tp{d{red}});
+
+        env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment));
+        env.close();
+
+        // Loan principal returned to the vault; assetsAvailable == assetsTotal.
+        auto const sleAfter = env.le(keylet);
+        if (BEAST_EXPECT(sleAfter))
+        {
+            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal));
+            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value());
+        }
+
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+    }
+
+    // Two concurrent loans against the same closed-ended vault in Investment
+    // must coexist: both loan SLEs are created, AssetsAvailable reflects the
+    // sum of the two outstanding principals, and each can be repaid
+    // independently.
+    void
+    testVaultClosedEndedMultipleLoans()
+    {
+        testcase("closed-ended vault: multiple concurrent loans in Investment");
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        Account const bob{"bob"};
+        Account const borrower1{"borrower1"};
+        Account const borrower2{"borrower2"};
+        env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2);
+        env.close();
+
+        Asset const asset = xrpIssue();
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(loan_broker::set(owner, keylet.key));
+        env.close();
+
+        env.close(tp{d{sub + 1}});
+
+        auto const originate = [&](Account const& b, STAmount const& principal) {
+            env(loan::set(b, brokerKeylet.key, principal),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(300),
+                kPaymentTotal(1),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+        };
+        originate(borrower1, XRP(50).value());
+        originate(borrower2, XRP(70).value());
+
+        auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
+        auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u));
+        BEAST_EXPECT(env.le(loan1));
+        BEAST_EXPECT(env.le(loan2));
+
+        // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable
+        // drops by the sum of the two loan principals.
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value());
+            }
+        }
+
+        // Repay the first loan; the second remains outstanding.
+        env(loan::pay(borrower1, loan1.key, XRP(50).value()));
+        env.close();
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value());
+            }
+        }
+
+        // Repay the second loan; vault is fully liquid again.
+        env(loan::pay(borrower2, loan2.key, XRP(70).value()));
+        env.close();
+        {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+            {
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal));
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value());
+            }
+        }
+
+        // Redemption: both depositors withdraw in full.
+        env.close(tp{d{red}});
+        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
+        env.close();
+    }
+
+    // VaultClawback has no phase gate: an issuer must be able to reclaim
+    // asset from a depositor in Subscription, Investment and Redemption
+    // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path
+    // is exercised (XRP clawback with an explicit amount is temMALFORMED).
+    void
+    testVaultClawbackClosedEndedPhases()
+    {
+        testcase("closed-ended vault: VaultClawback succeeds in each phase");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const alice{"alice"};
+        env.fund(XRP(10'000), issuer, owner, alice);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const iou = issuer["IOU"];
+        env.trust(iou(10'000), alice);
+        env(pay(issuer, alice, iou(1'000)));
+        env.close();
+
+        auto const [vault, keylet, sub, red] =
+            makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u);
+
+        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()}));
+        env.close();
+
+        auto const totalsEq = [&](STAmount const& expected) {
+            auto const sle = env.le(keylet);
+            if (BEAST_EXPECT(sle))
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == expected);
+        };
+
+        // Subscription phase clawback.
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(290).value());
+
+        // Investment phase clawback.
+        env.close(tp{d{sub + 1}});
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(280).value());
+
+        // Redemption phase clawback.
+        env.close(tp{d{red}});
+        env(vault.clawback(
+            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
+        env.close();
+        totalsEq(iou(270).value());
+    }
+
+public:
+    void
+    run() override
+    {
+        testVaultCreateClosedEnded();
+        testVaultCreateSubscriptionDateBoundary();
+        testVaultPhaseDerivation();
+        testVaultPhaseDerivationOpenEnded();
+        testVaultDepositClosedEnded();
+        testVaultWithdrawClosedEnded();
+        testVaultClosedEndedLifecycle();
+        testVaultLoanLatePaymentAfterInvestment();
+        testVaultClosedEndedMultipleLoans();
+        testVaultClawbackClosedEndedPhases();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultClosedEnded, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp
new file mode 100644
index 0000000000..5af0842962
--- /dev/null
+++ b/src/test/app/vault/VaultDomain_test.cpp
@@ -0,0 +1,696 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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});
+        }
+    }
+
+    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();
+        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/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp
new file mode 100644
index 0000000000..ce91ca857a
--- /dev/null
+++ b/src/test/app/vault/VaultLifecycle_test.cpp
@@ -0,0 +1,1776 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultLifecycle_test : public VaultTestBase
+{
+private:
+    void
+    testSequences()
+    {
+        using namespace test::jtx;
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const charlie{"charlie"};  // authorized 3rd party
+        Account const dave{"dave"};
+
+        auto const testSequence = [&, this](
+                                      std::string const& prefix,
+                                      Env& env,
+                                      Vault& vault,
+                                      PrettyAsset const& asset) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfData] = "AFEED00E";
+            tx[sfAssetsMaximum] = asset(100).number();
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.le(keylet));
+            std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6;
+
+            auto const [share, vaultAccount] =
+                [&env, keylet = keylet, asset, this]() -> std::tuple {
+                auto const vault = env.le(keylet);
+                BEAST_EXPECT(vault != nullptr);
+                if (!asset.integral())
+                {
+                    BEAST_EXPECT(vault->at(sfScale) == 6);
+                }
+                else
+                {
+                    BEAST_EXPECT(vault->at(sfScale) == 0);
+                }
+                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
+                BEAST_EXPECT(shares != nullptr);
+                if (!asset.integral())
+                {
+                    BEAST_EXPECT(shares->at(sfAssetScale) == 6);
+                }
+                else
+                {
+                    BEAST_EXPECT(shares->at(sfAssetScale) == 0);
+                }
+                return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))};
+            }();
+            auto const shares = share.raw().get();
+            env.memoize(vaultAccount);
+
+            // Several 3rd party accounts which cannot receive funds
+            Account const alice{"alice"};
+            Account const erin{"erin"};  // not authorized by issuer
+            env.fund(XRP(1000), alice, erin);
+            env(fset(alice, asfDepositAuth));
+            env.close();
+
+            {
+                testcase(prefix + " fail to deposit more than assets held");
+                auto tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(10000)});
+                env(tx, Ter(tecINSUFFICIENT_FUNDS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit non-zero amount");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
+            }
+
+            {
+                testcase(prefix + " deposit non-zero amount again");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
+            }
+
+            {
+                testcase(prefix + " fail to delete non-empty vault");
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                env(tx, Ter(tecHAS_OBLIGATIONS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to update because wrong owner");
+                auto tx = vault.set({.owner = issuer, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(50).number();
+                env(tx, Ter(tecNO_PERMISSION));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to set maximum lower than current amount");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(50).number();
+                env(tx, Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set maximum higher than current amount");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(150).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set maximum is idempotent, set it again");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(150).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " set data");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfData] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to set domain on public vault");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to deposit more than maximum");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecLIMIT_EXCEEDED));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " reset maximum to zero i.e. not enforced");
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfAssetsMaximum] = asset(0).number();
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw more than assets held");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx, Ter(tecINSUFFICIENT_FUNDS));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit some more");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
+            }
+
+            {
+                testcase(prefix + " clawback some");
+                auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS);
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)});
+                env(tx, code);
+                env.close();
+                if (!asset.raw().native())
+                {
+                    BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale));
+                }
+            }
+
+            {
+                testcase(prefix + " clawback all");
+                auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS);
+                auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor});
+                env(tx, code);
+                env.close();
+                if (!asset.raw().native())
+                {
+                    BEAST_EXPECT(env.balance(depositor, shares) == share(0));
+
+                    {
+                        auto tx = vault.clawback(
+                            {.issuer = issuer,
+                             .id = keylet.key,
+                             .holder = depositor,
+                             .amount = asset(10)});
+                        env(tx, Ter{tecPRECISION_LOSS});
+                        env.close();
+                    }
+
+                    {
+                        auto tx = vault.withdraw(
+                            {.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                        env(tx, Ter{tecPRECISION_LOSS});
+                        env.close();
+                    }
+                }
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " deposit again");
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
+            }
+            else
+            {
+                testcase(prefix + " deposit/withdrawal same or less than fee");
+                auto const amount = env.current()->fees().base;
+
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
+                env(tx);
+                env.close();
+
+                // Withdraw to 3rd party
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
+                tx[sfDestination] = charlie.human();
+                env(tx);
+                env.close();
+
+                tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1});
+                env(tx);
+                env.close();
+
+                tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = amount - 1});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = alice.human();
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to zero destination");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                tx[sfDestination] = "0";
+                env(tx, Ter(temMALFORMED));
+                env.close();
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " fail to withdraw to 3rd party no authorization");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = erin.human();
+                env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag");
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                tx[sfDestination] = dave.human();
+                env(tx, Ter{tecDST_TAG_NEEDED});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw to 3rd party lsfRequireDestTag");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = dave.human();
+                tx[sfDestinationTag] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " deposit again");
+                auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to withdraw lsfRequireDestTag");
+                auto tx =
+                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                env(tx, Ter{tecDST_TAG_NEEDED});
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw with tag");
+                auto tx =
+                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestinationTag] = "0";
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " withdraw to authorized 3rd party");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = charlie.human();
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
+            }
+
+            {
+                testcase(prefix + " withdraw to issuer");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                tx[sfDestination] = issuer.human();
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
+            }
+
+            if (!asset.raw().native())
+            {
+                testcase(prefix + " issuer deposits");
+                auto tx =
+                    vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale));
+
+                testcase(prefix + " issuer withdraws");
+                tx = vault.withdraw(
+                    {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale));
+            }
+
+            {
+                testcase(prefix + " withdraw remaining assets");
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(depositor, shares) == share(0));
+
+                if (!asset.raw().native())
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = depositor,
+                         .amount = asset(0)});
+                    env(tx, Ter{tecPRECISION_LOSS});
+                    env.close();
+                }
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = share(10)});
+                    env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                    env.close();
+                }
+            }
+
+            if (!asset.integral())
+            {
+                testcase(prefix + " temporary authorization for 3rd party");
+                env(trust(erin, asset(1000)));
+                env(trust(issuer, asset(0), erin, tfSetfAuth));
+                env(pay(issuer, erin, asset(10)));
+
+                // Erin deposits all in vault, then sends shares to depositor
+                auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)});
+                env(tx);
+                env.close();
+                {
+                    auto tx = pay(erin, depositor, share(10 * scale));
+
+                    // depositor no longer has MPToken for shares
+                    env(tx, Ter{tecNO_AUTH});
+                    env.close();
+
+                    // depositor will gain MPToken for shares again
+                    env(vault.deposit(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+                    env.close();
+
+                    env(tx);
+                    env.close();
+                }
+
+                testcase(prefix + " withdraw to authorized 3rd party");
+                // Depositor withdraws assets, destined to Erin
+                tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                tx[sfDestination] = erin.human();
+                env(tx);
+                env.close();
+
+                // Erin returns assets to issuer
+                env(pay(erin, issuer, asset(10)));
+                env.close();
+
+                testcase(prefix + " fail to pay to unauthorized 3rd party");
+                env(trust(erin, asset(0)));
+                env.close();
+
+                // Erin has MPToken but is no longer authorized to hold assets
+                env(pay(depositor, erin, share(1)), Ter{tecNO_LINE});
+                env.close();
+
+                // Depositor withdraws remaining single asset
+                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                testcase(prefix + " fail to delete because wrong owner");
+                auto tx = vault.del({.owner = issuer, .id = keylet.key});
+                env(tx, Ter(tecNO_PERMISSION));
+                env.close();
+            }
+
+            {
+                testcase(prefix + " delete empty vault");
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(!env.le(keylet));
+            }
+        };
+
+        auto testCases = [&, this](
+                             std::string prefix, std::function setup) {
+            Env env{*this, testableAmendments()};
+
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner, depositor, charlie, dave);
+            env.close();
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env(fset(issuer, asfRequireAuth));
+            env(fset(dave, asfRequireDest));
+            env.close();
+            env.require(Flags(issuer, asfAllowTrustLineClawback));
+            env.require(Flags(issuer, asfRequireAuth));
+
+            PrettyAsset const asset = setup(env);
+            testSequence(prefix, env, vault, asset);
+        };
+
+        testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; });
+
+        testCases("IOU", [&](Env& env) -> Asset {
+            PrettyAsset const asset = issuer["IOU"];
+            env(trust(owner, asset(1000)));
+            env(trust(depositor, asset(1000)));
+            env(trust(charlie, asset(1000)));
+            env(trust(dave, asset(1000)));
+            env(trust(issuer, asset(0), owner, tfSetfAuth));
+            env(trust(issuer, asset(0), depositor, tfSetfAuth));
+            env(trust(issuer, asset(0), charlie, tfSetfAuth));
+            env(trust(issuer, asset(0), dave, tfSetfAuth));
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+            return asset;
+        });
+
+        testCases("MPT", [&](Env& env) -> Asset {
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = depositor});
+            mptt.authorize({.account = charlie});
+            mptt.authorize({.account = dave});
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+            return asset;
+        });
+    }
+
+    void
+    testWithMPT()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            bool enableClawback = true;
+            bool requireAuth = true;
+            int initialXRP = 1000;
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [this](
+                            std::function test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(args.initialXRP), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            auto const kNone = LedgerSpecificFlags(0);
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock |
+                     (args.enableClawback ? tfMPTCanClawback : kNone) |
+                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            if (args.requireAuth)
+            {
+                mptt.authorize({.account = issuer, .holder = owner});
+                mptt.authorize({.account = issuer, .holder = depositor});
+            }
+
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+
+            test(env, issuer, owner, depositor, asset, vault, mptt);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT nothing to clawback from");
+            auto tx = vault.clawback(
+                {.issuer = issuer,
+                 .id = keylet::skip().key,
+                 .holder = depositor,
+                 .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT global lock blocks create");
+            mptt.set({.account = issuer, .flags = tfMPTLock});
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tecLOCKED));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT only issuer can clawback");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+            env(tx);
+            env.close();
+
+            {
+                auto tx = vault.clawback({
+                    .issuer = depositor,
+                    .id = keylet.key,
+                    .holder = depositor,
+                });
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+
+            {
+                auto tx = vault.clawback({
+                    .issuer = owner,
+                    .id = keylet.key,
+                    .holder = depositor,
+                });
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+        });
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT depositor without MPToken, auth required");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx);
+                env.close();
+
+                {
+                    // Remove depositor MPToken and it will not be re-created
+                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{tecNO_AUTH});
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 == nullptr);
+                }
+
+                {
+                    // Set destination to 3rd party without MPToken
+                    Account const charlie{"charlie"};
+                    env.fund(XRP(1000), charlie);
+                    env.close();
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    tx[sfDestination] = charlie.human();
+                    env(tx, Ter(tecNO_AUTH));
+                }
+            },
+            {.requireAuth = true});
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT depositor without MPToken, no auth required");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+                auto v = env.le(keylet);
+                BEAST_EXPECT(v);
+
+                tx = vault.deposit(
+                    {.depositor = depositor,
+                     .id = keylet.key,
+                     .amount = asset(1000)});  // all assets held by depositor
+                env(tx);
+                env.close();
+
+                {
+                    // Remove depositor's MPToken and it will be re-created
+                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    env(tx);
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 != nullptr);
+                    BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100);
+                }
+
+                {
+                    // Remove 3rd party MPToken and it will not be re-created
+                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
+                    auto const sleMPT1 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT1 == nullptr);
+
+                    tx = vault.withdraw(
+                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                    tx[sfDestination] = owner.human();
+                    env(tx, Ter(tecNO_AUTH));
+                    env.close();
+
+                    auto const sleMPT2 = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT2 == nullptr);
+                }
+            },
+            {.requireAuth = false});
+
+        auto const [acctReserve, incReserve] = [this]() -> std::pair {
+            Env const env{*this, testableAmendments()};
+            return {
+                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
+                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
+        }();
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT fail reserve to re-create MPToken");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+                auto v = env.le(keylet);
+                BEAST_EXPECT(v);
+
+                env(pay(depositor, owner, asset(1000)));
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(1000)});  // all assets held by owner
+                env(tx);
+                env.close();
+
+                {
+                    // Remove owners's MPToken and it will not be re-created
+                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
+                    env.close();
+
+                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
+                    auto const sleMPT = env.le(mptoken);
+                    BEAST_EXPECT(sleMPT == nullptr);
+
+                    // Use one reserve so the next transaction fails
+                    env(ticket::create(owner, 1));
+                    env.close();
+
+                    // No reserve to create MPToken for asset in VaultWithdraw
+                    tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{tecINSUFFICIENT_RESERVE});
+                    env.close();
+
+                    env(pay(depositor, owner, XRP(incReserve)));
+                    env.close();
+
+                    // Withdraw can now create asset MPToken, tx will succeed
+                    env(tx);
+                    env.close();
+                }
+            },
+            {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1});
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT issuance deleted");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx);
+            }
+
+            mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()});
+            env.close();
+
+            {
+                auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx, Ter{tecOBJECT_NOT_FOUND});
+            }
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT vault owner can receive shares unless unauthorized");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
+                auto const vault = env.le(keylet);
+                return vault->at(sfShareMPTID);
+            }(keylet);
+            PrettyAsset const shares = MPTIssue(issuanceId);
+
+            {
+                // owner has MPToken for shares they did not explicitly create
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)});
+                env(tx);
+                env.close();
+
+                // owner's MPToken for vault shares not destroyed by withdraw
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
+                env(tx);
+                env.close();
+
+                // owner's MPToken for vault shares not destroyed by clawback
+                env(pay(depositor, owner, shares(1)));
+                env.close();
+
+                // pay back, so we can destroy owner's MPToken now
+                env(pay(owner, depositor, shares(1)));
+                env.close();
+
+                {
+                    // explicitly destroy vault owners MPToken with zero balance
+                    json::Value jv;
+                    jv[sfAccount] = owner.human();
+                    jv[sfMPTokenIssuanceID] = to_string(issuanceId);
+                    jv[sfFlags] = tfMPTUnauthorize;
+                    jv[sfTransactionType] = jss::MPTokenAuthorize;
+                    env(jv);
+                    env.close();
+                }
+
+                // owner no longer has MPToken for vault shares
+                tx = pay(depositor, owner, shares(1));
+                env(tx, Ter{tecNO_AUTH});
+                env.close();
+
+                // destroy all remaining shares, so we can delete vault
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
+                env(tx);
+                env.close();
+
+                // will soft fail destroying MPToken for vault owner
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            }
+        });
+
+        testCase(
+            [this](
+                Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Account const& depositor,
+                PrettyAsset const& asset,
+                Vault& vault,
+                MPTTester& mptt) {
+                testcase("MPT clawback disabled");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                tx = vault.deposit(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+                env(tx);
+                env.close();
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer,
+                         .id = keylet.key,
+                         .holder = depositor,
+                         .amount = asset(0)});
+                    env(tx, Ter{tecNO_PERMISSION});
+                }
+            },
+            {.enableClawback = false});
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault,
+                     MPTTester& mptt) {
+            testcase("MPT un-authorization");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
+            env(tx);
+            env.close();
+
+            mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize});
+            env.close();
+
+            {
+                auto tx = vault.withdraw(
+                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecNO_AUTH));
+
+                // Withdrawal to other (authorized) accounts works
+                tx[sfDestination] = issuer.human();
+                env(tx);
+                env.close();
+
+                tx[sfDestination] = owner.human();
+                env(tx);
+                env.close();
+            }
+
+            {
+                // Cannot deposit some more
+                auto tx =
+                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter(tecNO_AUTH));
+            }
+
+            {
+                // Cannot clawback if issuer is the holder
+                tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)});
+                env(tx, Ter(tecNO_PERMISSION));
+            }
+            // Clawback works
+            tx = vault.clawback(
+                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)});
+            env(tx);
+            env.close();
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+        });
+
+        {
+            testcase("MPT shares to a vault");
+
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            env.fund(XRP(1000000), owner, issuer);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create(
+                {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth});
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            PrettyAsset const asset = mptt.issuanceID();
+            env(pay(issuer, owner, asset(100)));
+            auto [tx1, k1] = vault.create({.owner = owner, .asset = asset});
+            env(tx1);
+            env.close();
+
+            auto const shares = [&env, keylet = k1, this]() -> Asset {
+                auto const vault = env.le(keylet);
+                BEAST_EXPECT(vault != nullptr);
+                return MPTIssue(vault->at(sfShareMPTID));
+            }();
+
+            auto [tx2, k2] = vault.create({.owner = owner, .asset = shares});
+            env(tx2, Ter{tecWRONG_ASSET});
+            env.close();
+        }
+
+        {
+            testcase("MPT locked: vault shares inherit underlying lock");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            Account const carol{"carol"};
+            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester asset{
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {owner, alice, bob, carol},
+                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
+            env(pay(issuer, alice, asset(1'000)));
+            env(pay(issuer, bob, asset(1'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
+            // Bob also deposits so he has a share MPToken to receive into.
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+            auto const shareMptID = shares.raw().get().getMptID();
+            auto const shareBalance = [&](Account const& account) {
+                auto const sle = env.le(keylet::mptoken(shareMptID, account));
+                return sle ? sle->at(sfMPTAmount) : 0;
+            };
+
+            // Sanity: before the underlying lock, peer-to-peer share
+            // transfers are allowed.
+            env(pay(alice, bob, shares(1)));
+            env.close();
+
+            // Create the offer while shares are spendable, then lock the
+            // underlying to test whether a stale offer can still be crossed.
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            // Lock the underlying after the vault and share balances exist.
+            asset.set({.account = issuer, .flags = tfMPTLock});
+            env.close();
+
+            // Direct vault share payment inherits the underlying lock via
+            // sfReferenceHolding.
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+
+            // The same inherited lock must also block DEX payment paths that
+            // would consume an offer selling vault shares.
+            env(pay(carol, bob, shares(1)),
+                Sendmax(XRP(1)),
+                Path(BookSpec{shares.raw()}),
+                Ter{tecPATH_PARTIAL});
+            env.close();
+            BEAST_EXPECT(shareBalance(alice) == 499);
+            BEAST_EXPECT(shareBalance(bob) == 501);
+            BEAST_EXPECT(expectOffers(env, alice, 1));
+        }
+
+        {
+            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
+
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const alice{"alice"};
+            Account const bob{"bob"};
+            env.fund(XRP(100'000), issuer, owner, alice, bob);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = alice});
+            mptt.authorize({.account = bob});
+            env(pay(issuer, alice, asset(10'000)));
+            env(pay(issuer, bob, asset(10'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            // Seed shares so we can later place them on trading venues.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
+            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
+            env.close();
+
+            auto const shares = [&]() -> PrettyAsset {
+                auto const sle = env.le(keylet);
+                BEAST_EXPECT(sle != nullptr);
+                return MPTIssue(sle->at(sfShareMPTID));
+            }();
+
+            // CanTrade is not set on the underlying, both the asset and
+            // the vault share are blocked on the DEX.
+            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
+            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
+            env.close();
+
+            // Deposit still works before enabling CanTrade.
+            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            // Peer-to-peer share transfers still work (CanTransfer is set on
+            // both layers).
+            env(pay(alice, bob, shares(1)));
+            env.close();
+
+            // Withdraw still works before enabling CanTrade.
+            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            // Enable CanTrade on the underlying.
+            mptt.set({.flags = tfMPTSetCanTrade});
+            env.close();
+
+            env(offer(alice, XRP(1), asset(10)));
+            env(offer(alice, XRP(1), shares(1)));
+            env.close();
+
+            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
+        }
+
+        {
+            testcase("MPT OutstandingAmount > MaximumAmount");
+
+            Env env{*this, testableAmendments() | featureSingleAssetVault};
+            Account const alice{"alice"};
+            Account const issuer{"issuer"};
+            env.fund(XRP(1'000), alice, issuer);
+            env.close();
+            Vault const vault{env};
+
+            MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100});
+
+            auto [tx, k] = vault.create({.owner = issuer, .asset = btc});
+            env(tx);
+            env.close();
+
+            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)});
+            // accountHolds is the first check and the issuer has only BTC(100)
+            // available
+            env(tx, Ter{tecINSUFFICIENT_FUNDS});
+            env.close();
+
+            // OutstandingAmount == MaximumAmount
+            env(pay(issuer, alice, btc(100)));
+            env.close();
+
+            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)});
+            // the issuer has BTC(0) available
+            env(tx, Ter{tecINSUFFICIENT_FUNDS});
+            env.close();
+
+            tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)});
+            // alice transfers BTC(100), OutstandingAmount is 100
+            env(tx);
+            env.close();
+        }
+    }
+
+    void
+    testWithIOU()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            int initialXRP = 1000;
+            Number initialIOU = 200;
+            double transferRate = 1.0;
+            bool charlieRipple = true;
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [&, this](
+                            std::function vaultAccount,
+                                Vault& vault,
+                                PrettyAsset const& asset,
+                                std::function issuanceId)> test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const charlie{"charlie"};
+            Vault vault{env};
+            env.fund(XRP(args.initialXRP), issuer, owner, charlie);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env(pay(issuer, owner, asset(args.initialIOU)));
+            env.close();
+            if (!args.charlieRipple)
+            {
+                env(fset(issuer, 0, asfDefaultRipple));
+                env.close();
+                env.trust(asset(1000), charlie);
+                env.close();
+                env(pay(issuer, charlie, asset(args.initialIOU)));
+                env.close();
+                env(fset(issuer, asfDefaultRipple));
+            }
+            else
+            {
+                env.trust(asset(1000), charlie);
+            }
+            env.close();
+            env(rate(issuer, args.transferRate));
+            env.close();
+
+            auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account {
+                return Account("vault", env.le(keylet)->at(sfAccount));
+            };
+            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
+                return env.le(keylet)->at(sfShareMPTID);
+            };
+
+            test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId);
+        };
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const&,
+                     auto vaultAccount,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU cannot use different asset");
+            PrettyAsset const foo = issuer["FOO"];
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            {
+                // Cannot create new trustline to a vault
+                auto tx = [&, account = vaultAccount(keylet)]() {
+                    json::Value jv;
+                    jv[jss::Account] = issuer.human();
+                    {
+                        auto& ja = jv[jss::LimitAmount] =
+                            foo(0).value().getJson(JsonOptions::Values::None);
+                        ja[jss::issuer] = toBase58(account);
+                    }
+                    jv[jss::TransactionType] = jss::TrustSet;
+                    jv[jss::Flags] = tfSetFreeze;
+                    return jv;
+                }();
+                env(tx, Ter{tecNO_PERMISSION});
+                env.close();
+            }
+
+            {
+                auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
+                env(tx, Ter{tecWRONG_ASSET});
+                env.close();
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
+                env(tx, Ter{tecWRONG_ASSET});
+                env.close();
+            }
+
+            env(vault.del({.owner = owner, .id = keylet.key}));
+            env.close();
+        });
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto issuanceId) {
+                testcase("IOU transfer fees not applied");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+                env.close();
+
+                auto const issue = asset.raw().get();
+                Asset const share = Asset(issuanceId(keylet));
+
+                // transfer fees ignored on deposit
+                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
+                    env(tx);
+                    env.close();
+                }
+
+                // transfer fees ignored on clawback
+                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
+
+                env(vault.withdraw(
+                    {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
+
+                // transfer fees ignored on withdraw
+                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
+                    tx[sfDestination] = charlie.human();
+                    env(tx);
+                }
+
+                // transfer fees ignored on withdraw to 3rd party
+                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
+                BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
+                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
+
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            },
+            CaseArgs{.transferRate = 1.25});
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const& charlie,
+                     auto,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU no trust line to 3rd party");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            Account const erin{"erin"};
+            env.fund(XRP(1000), erin);
+            env.close();
+
+            // Withdraw to 3rd party without trust line
+            auto const tx1 = [&](xrpl::Keylet keylet) {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfDestination] = erin.human();
+                return tx;
+            }(keylet);
+            env(tx1, Ter{tecNO_LINE});
+        });
+
+        testCase([&, this](
+                     Env& env,
+                     Account const& owner,
+                     Account const& issuer,
+                     Account const& charlie,
+                     auto,
+                     Vault& vault,
+                     PrettyAsset const& asset,
+                     auto&&...) {
+            testcase("IOU no trust line to depositor");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            // reset limit, so deposit of all funds will delete the trust line
+            env.trust(asset(0), owner);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
+            env.close();
+
+            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
+            BEAST_EXPECT(trustline == nullptr);
+
+            // Withdraw without trust line, will succeed
+            auto const tx1 = [&](xrpl::Keylet keylet) {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                return tx;
+            }(keylet);
+            env(tx1);
+        });
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                std::function issuanceId) {
+                testcase("IOU non-transferable");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                tx[sfScale] = 0;
+                env(tx);
+                env.close();
+
+                // Turn on noripple on the pseudo account's trust line.
+                // Charlie's is already set.
+                env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple));
+
+                {
+                    // Charlie cannot deposit
+                    auto tx = vault.deposit(
+                        {.depositor = charlie, .id = keylet.key, .amount = asset(100)});
+                    env(tx, Ter{terNO_RIPPLE});
+                    env.close();
+                }
+
+                {
+                    PrettyAsset const shares = issuanceId(keylet);
+                    auto tx1 =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                    env(tx1);
+                    env.close();
+
+                    // Charlie cannot receive funds
+                    auto tx2 = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = shares(100)});
+                    tx2[sfDestination] = charlie.human();
+                    env(tx2, Ter{terNO_RIPPLE});
+                    env.close();
+
+                    {
+                        // Create MPToken for shares held by Charlie
+                        json::Value tx{json::ValueType::Object};
+                        tx[sfAccount] = charlie.human();
+                        tx[sfMPTokenIssuanceID] =
+                            to_string(shares.raw().get().getMptID());
+                        tx[sfTransactionType] = jss::MPTokenAuthorize;
+                        env(tx);
+                        env.close();
+                    }
+                    // Behavioral shift introduced by share inheritance:
+                    // before fixCleanup3_2_0 this share Payment succeeded
+                    // and the underlying IOU's NoRipple restriction surfaced
+                    // only later on Charlie's withdrawal (terNO_RIPPLE).
+                    // Post-amendment, canTransfer reads the share's
+                    // sfReferenceHolding and dispatches to the underlying IOU;
+                    // rippling is disabled between owner and charlie so the
+                    // share payment itself is now blocked. tecPATH_DRY is
+                    // the path-find layer's translation of the underlying
+                    // terNO_RIPPLE under featureMPTokensV2.
+                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
+                    env.close();
+                }
+
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
+                env(tx);
+                env.close();
+
+                // Delete vault with zero balance
+                env(vault.del({.owner = owner, .id = keylet.key}));
+            },
+            {.charlieRipple = false});
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto const& vaultAccount,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU calculation rounding");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                tx[sfScale] = 1;
+                env(tx);
+                env.close();
+
+                auto const startingOwnerBalance = env.balance(owner, asset);
+                BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2}));
+
+                // This operation (first deposit 100, then 3.75 x 5) is known to
+                // have triggered calculation rounding errors in Number
+                // (addition and division), causing the last deposit to be
+                // blocked by Vault invariants.
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
+
+                auto const tx1 = vault.deposit(
+                    {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))});
+                for (auto i = 0; i < 5; ++i)
+                {
+                    env(tx1);
+                }
+                env.close();
+
+                {
+                    STAmount const xfer{asset, 1185, -1};
+                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer);
+                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer);
+
+                    auto const vault = env.le(keylet);
+                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer);
+                    BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer);
+                }
+
+                // Total vault balance should be 118.5 IOU. Withdraw and delete
+                // the vault to verify this exact amount was deposited and the
+                // owner has matching shares
+                env(vault.withdraw(
+                    {.depositor = owner,
+                     .id = keylet.key,
+                     .amount = asset(Number(1000 + (37 * 5), -1))}));
+
+                {
+                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value());
+                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero);
+                    auto const vault = env.le(keylet);
+                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero);
+                    BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero);
+                }
+
+                env(vault.del({.owner = owner, .id = keylet.key}));
+                env.close();
+            },
+            {.initialIOU = Number(11875, -2)});
+
+        auto const [acctReserve, incReserve] = [this]() -> std::pair {
+            Env const env{*this, testableAmendments()};
+            return {
+                env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(),
+                env.current()->fees().increment.drops() / kDropsPerXrp.drops()};
+        }();
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU no trust line to depositor no reserve");
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                // reset limit, so deposit of all funds will delete the trust
+                // line
+                env.trust(asset(0), owner);
+                env.close();
+
+                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
+                env.close();
+
+                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
+                BEAST_EXPECT(trustline == nullptr);
+
+                env(ticket::create(owner, 1));
+                env.close();
+
+                // Fail because not enough reserve to create trust line
+                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                env(tx, Ter{tecNO_LINE_INSUF_RESERVE});
+                env.close();
+
+                env(pay(charlie, owner, XRP(incReserve)));
+                env.close();
+
+                // Withdraw can now create trust line, will succeed
+                env(tx);
+                env.close();
+            },
+            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
+
+        testCase(
+            [&, this](
+                Env& env,
+                Account const& owner,
+                Account const& issuer,
+                Account const& charlie,
+                auto,
+                Vault& vault,
+                PrettyAsset const& asset,
+                auto&&...) {
+                testcase("IOU no reserve for share MPToken");
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+                env.close();
+
+                env(pay(owner, charlie, asset(100)));
+                env.close();
+
+                env(ticket::create(charlie, 3));
+                env.close();
+
+                // Fail because not enough reserve to create MPToken for shares
+                tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)});
+                env(tx, Ter{tecINSUFFICIENT_RESERVE});
+                env.close();
+
+                env(pay(issuer, charlie, XRP(incReserve)));
+                env.close();
+
+                // Deposit can now create MPToken, will succeed
+                env(tx);
+                env.close();
+            },
+            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
+    }
+
+public:
+    void
+    run() override
+    {
+        testSequences();
+        testWithMPT();
+        testWithIOU();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultLifecycle, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp
new file mode 100644
index 0000000000..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..94c594f674
--- /dev/null
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -0,0 +1,1228 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultScale_test : public VaultTestBase
+{
+private:
+    void
+    testScaleIOU()
+    {
+        using namespace test::jtx;
+
+        struct Data
+        {
+            Account const& owner;
+            Account const& issuer;
+            Account const& depositor;
+            Account const& vaultAccount;
+            MPTIssue shares;
+            PrettyAsset const& share;
+            Vault& vault;
+            xrpl::Keylet keylet;
+            Issue assets;
+            PrettyAsset const& asset;
+            std::function)> peek;
+        };
+
+        auto testCase = [&, this](
+                            std::uint8_t scale, std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1000), owner);
+            env.trust(asset(1000), depositor);
+            env(pay(issuer, owner, asset(200)));
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = scale;
+            env(tx);
+
+            auto const [vaultAccount, issuanceId] =
+                [&env](xrpl::Keylet keylet) -> std::tuple {
+                auto const vault = env.le(keylet);
+                return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)};
+            }(keylet);
+            MPTIssue const shares(issuanceId);
+            env.memoize(vaultAccount);
+
+            auto const peek = [keylet, &env, this](std::function fn) -> bool {
+                return env.app().getOpenLedger().modify(
+                    [&](OpenView& view, beast::Journal j) -> bool {
+                        Sandbox sb(&view, TapNone);
+                        auto vault = sb.peek(keylet::vault(keylet.key));
+                        if (!BEAST_EXPECT(vault))
+                            return false;
+                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
+                        if (!BEAST_EXPECT(shares))
+                            return false;
+                        if (fn(*vault, *shares))
+                        {
+                            sb.update(vault);
+                            sb.update(shares);
+                            sb.apply(view);
+                            return true;
+                        }
+                        return false;
+                    });
+            };
+
+            test(
+                env,
+                {.owner = owner,
+                 .issuer = issuer,
+                 .depositor = depositor,
+                 .vaultAccount = vaultAccount,
+                 .shares = shares,
+                 .share = PrettyAsset(shares),
+                 .vault = vault,
+                 .keylet = keylet,
+                 .assets = asset.raw().get(),
+                 .asset = asset,
+                 .peek = peek});
+        };
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on first deposit");
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
+            env(tx, Ter{tecPATH_DRY});
+            env.close();
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on second deposit");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale deposit overflow on total shares");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10));
+            BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1));
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit insignificant amount");
+
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(9, -2))});
+            env(tx, Ter{tecPRECISION_LOSS});
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, using full precision");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(15, -1))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1)));
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .5");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // Each of the cases below will transfer exactly 1.2 IOU to the
+            // vault and receive 12 shares in exchange
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(125, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(12, -1)));
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(1201, -3))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(24, -1)));
+            }
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(1299, -3))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(36, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .01");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // round to 12
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(1201, -3))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
+
+            {
+                // round to 6
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(69, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(18, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            testcase("Scale deposit exact, truncating from .99");
+
+            auto const start = env.balance(d.depositor, d.assets).number();
+            // round to 12
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(1299, -3))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
+
+            {
+                // round to 6
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(62, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start - Number(18, -1)));
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
+
+            {
+                testcase("Scale redeem exact");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, Number(100, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
+            }
+
+            {
+                testcase("Scale redeem with rounding");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(1);
+                    return true;
+                });
+
+                // Note, this transaction fails first (because of above change
+                // in the open ledger) but then succeeds when the ledger is
+                // closed (because a modification like above is not persistent),
+                // which is why the checks below are expected to pass.
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, Number(25, 0))});
+                env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                testcase("Scale redeem exact");
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, Number(21, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(21, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 21, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 21, 0)));
+            }
+
+            {
+                testcase("Scale redeem rest");
+                auto const rest = env.balance(d.depositor, d.shares).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.share, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale withdraw overflow");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
+
+            {
+                testcase("Scale withdraw exact");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
+            }
+
+            {
+                testcase("Scale withdraw insignificant amount");
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(4, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+            }
+
+            {
+                testcase("Scale withdraw with rounding assets");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(1);
+                    return true;
+                });
+
+                // Note, this transaction fails first (because of above change
+                // in the open ledger) but then succeeds when the ledger is
+                // closed (because a modification like above is not persistent),
+                // which is why the checks below are expected to pass.
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(25, -1))});
+                env(tx, Ter{tecINSUFFICIENT_FUNDS});
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                testcase("Scale withdraw with rounding shares up");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(375, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 38, 0)));
+            }
+
+            {
+                testcase("Scale withdraw with rounding shares down");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(372, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) ==
+                    STAmount(d.asset, start + Number(37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(837 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(837 - 37, 0)));
+            }
+
+            {
+                testcase("Scale withdraw tiny amount");
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, Number(9, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                BEAST_EXPECT(
+                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(800 - 1, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(800 - 1, 0)));
+            }
+
+            {
+                testcase("Scale withdraw rest");
+                auto const rest = env.balance(d.vaultAccount, d.assets).number();
+
+                tx = d.vault.withdraw(
+                    {.depositor = d.depositor,
+                     .id = d.keylet.key,
+                     .amount = STAmount(d.asset, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        testCase(18, [&, this](Env& env, Data d) {
+            testcase("Scale clawback overflow");
+
+            {
+                auto tx = d.vault.deposit(
+                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
+                env(tx);
+                env.close();
+            }
+
+            {
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx, Ter{tecPATH_DRY});
+                env.close();
+            }
+        });
+
+        testCase(1, [&, this](Env& env, Data d) {
+            // initial setup: deposit 100 IOU, receive 1000 shares
+            auto const start = env.balance(d.depositor, d.assets).number();
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
+            BEAST_EXPECT(
+                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0)));
+            {
+                testcase("Scale clawback exact");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(10, 0))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0)));
+            }
+
+            {
+                testcase("Scale clawback insignificant amount");
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(4, -2))});
+                env(tx, Ter{tecPRECISION_LOSS});
+            }
+
+            {
+                testcase("Scale clawback with rounding assets");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(25, -1))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(900 - 25, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(900 - 25, 0)));
+            }
+
+            {
+                testcase("Scale clawback with rounding shares up");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(375, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(875 - 38, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(875 - 38, 0)));
+            }
+
+            {
+                testcase("Scale clawback with rounding shares down");
+                // assetsToSharesWithdraw:
+                //  shares = sharesTotal * (assets / assetsTotal)
+                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
+                // sharesToAssetsWithdraw:
+                //  assets = assetsTotal * (shares / sharesTotal)
+                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(372, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(837 - 37, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(837 - 37, 0)));
+            }
+
+            {
+                testcase("Scale clawback tiny amount");
+
+                auto const start = env.balance(d.depositor, d.assets).number();
+                auto tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, Number(9, -2))});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.assets) ==
+                    STAmount(d.asset, Number(800 - 1, -1)));
+                BEAST_EXPECT(
+                    env.balance(d.vaultAccount, d.shares) ==
+                    STAmount(d.share, -Number(800 - 1, 0)));
+            }
+
+            {
+                testcase("Scale clawback rest");
+                auto const rest = env.balance(d.vaultAccount, d.assets).number();
+                d.peek([](SLE& vault, auto&) -> bool {
+                    vault[sfAssetsAvailable] = Number(5);
+                    return true;
+                });
+
+                // Note, this transaction yields two different results:
+                // * in the open ledger, with AssetsAvailable = 5
+                // * when the ledger is closed with unmodified AssetsAvailable
+                //   because a modification like above is not persistent.
+                tx = d.vault.clawback(
+                    {.issuer = d.issuer,
+                     .id = d.keylet.key,
+                     .holder = d.depositor,
+                     .amount = STAmount(d.asset, rest)});
+                env(tx);
+                env.close();
+                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
+                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
+            }
+        });
+
+        // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan.
+        // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60.
+        // Clawback 80 IOU → clamped to 60, then share math uses truncation.
+        testCase(1, [&, this](Env& env, Data d) {
+            using namespace loan_broker;
+            using namespace loan;
+
+            testcase("Scale clawback clamped with outstanding loan");
+
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
+
+            // Create a loan broker backed by this vault
+            auto const brokerKeylet =
+                keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner)));
+            env(set(d.owner, d.keylet.key));
+            env.close();
+
+            // Borrow 40: assetsAvailable=60, assetsTotal=100
+            env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))),
+                loan::kInterestRate(TenthBips32(0)),
+                kGracePeriod(60),
+                kPaymentInterval(120),
+                kPaymentTotal(10),
+                Sig(sfCounterpartySignature, d.owner),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(d.keylet);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0)));
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0)));
+            }
+
+            // Request 80 IOU clawback — clamped to assetsAvailable (60)
+            // With scale=1 (10:1), 60 assets = 600 shares destroyed
+            tx = d.vault.clawback(
+                {.issuer = d.issuer,
+                 .id = d.keylet.key,
+                 .holder = d.depositor,
+                 .amount = STAmount(d.asset, Number(80, 0))});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            {
+                auto const sle = env.le(d.keylet);
+                BEAST_EXPECT(sle != nullptr);
+                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0)));
+                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0)));
+
+                // 600 of 1000 shares destroyed, 400 remain
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
+            }
+        });
+    }
+
+    void
+    testAssetsMaximum()
+    {
+        testcase("Assets Maximum");
+
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Account const issuer{"issuer"};
+
+        Vault const vault{env};
+        env.fund(XRP(1'000'000), issuer, owner);
+        env.close();
+
+        auto const maxInt64 = std::to_string(std::numeric_limits::max());
+        BEAST_EXPECT(maxInt64 == "9223372036854775807");
+
+        auto const maxInt64Plus1 = std::to_string(
+            static_cast(std::numeric_limits::max()) + 1);
+        BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808");
+
+        // Naming things is hard
+        auto const maxInt64Plus2 = std::to_string(
+            static_cast(std::numeric_limits::max()) + 2);
+        BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809");
+
+        auto const initialXRP = to_string(kInitialXrp);
+        BEAST_EXPECT(initialXRP == "100000000000000000");
+
+        auto const initialXRPPlus1 = to_string(kInitialXrp + 1);
+        BEAST_EXPECT(initialXRPPlus1 == "100000000000000001");
+
+        {
+            testcase("Assets Maximum: XRP");
+
+            PrettyAsset const xrpAsset = xrpIssue();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            // There are several parse failures expected in this function, so just disable it once.
+            env.setParseFailureExpected(true);
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus1;
+                env(tx, Ter(tefEXCEPTION));
+                env.close();
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx, Ter(tefEXCEPTION));
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            try
+            {
+                auto const insertAt = maxInt64Plus2.size() - 3;
+                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 1000
+                BEAST_EXPECT(decimalTest == "9223372036854775.809");
+                tx[sfAssetsMaximum] = decimalTest;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const vaultSle = env.le(newKeylet);
+            BEAST_EXPECT(!vaultSle);
+        }
+
+        {
+            testcase("Assets Maximum: MPT");
+
+            PrettyAsset const mptAsset = [&]() {
+                MPTTester mptt{env, issuer, kMptInitNoFund};
+                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                env.close();
+                PrettyAsset const mptAsset = mptt["MPT"];
+                mptt.authorize({.account = owner});
+                env.close();
+                return mptAsset;
+            }();
+
+            env(pay(issuer, owner, mptAsset(100'000)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx, Ter(tefEXCEPTION));
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            try
+            {
+                auto const insertAt = maxInt64Plus2.size() - 1;
+                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
+                BEAST_EXPECT(decimalTest == "922337203685477580.9");
+                tx[sfAssetsMaximum] = decimalTest;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            auto const vaultSle = env.le(newKeylet);
+            BEAST_EXPECT(!vaultSle);
+        }
+
+        {
+            testcase("Assets Maximum: IOU");
+
+            // Almost anything goes with IOUs
+            PrettyAsset const iouAsset = issuer["IOU"];
+            env.trust(iouAsset(1000), owner);
+            env(pay(issuer, owner, iouAsset(200)));
+            env.close();
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset});
+            tx[sfData] = "4D65746144617461";
+
+            tx[sfAssetsMaximum] = maxInt64;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRPPlus1;
+            env(tx);
+            env.close();
+
+            tx[sfAssetsMaximum] = initialXRP;
+            env(tx);
+            env.close();
+
+            // Since several tests are expected to have parser failures, leave this flag set for the
+            // remainder of this function.
+            env.setParseFailureExpected(true);
+            try
+            {
+                tx[sfAssetsMaximum] = maxInt64Plus2;
+                env(tx);
+                // should throw in parser
+                fail();
+            }
+            catch (std::exception const& e)
+            {
+                BEAST_EXPECT(
+                    std::string(e.what()) ==
+                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+            }
+
+            tx[sfAssetsMaximum] = "1000000000000000e80";
+            env.close();
+
+            tx[sfAssetsMaximum] = "1000000000000000e-96";
+            env.close();
+
+            // These values will be rounded to 15 significant digits
+            {
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                try
+                {
+                    auto const insertAt = maxInt64Plus2.size() - 1;
+                    auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
+                        maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
+                    BEAST_EXPECT(decimalTest == "922337203685477580.9");
+                    tx[sfAssetsMaximum] = decimalTest;
+                    env(tx);
+                    // should throw in parser
+                    fail();
+                }
+                catch (std::exception const& e)
+                {
+                    BEAST_EXPECT(
+                        std::string(e.what()) ==
+                        "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
+                }
+
+                auto const vaultSle = env.le(newKeylet);
+                BEAST_EXPECT(!vaultSle);
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e40";  // max int64 * 10^40
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(
+                    (vaultSle->at(sfAssetsMaximum) ==
+                     Number{9223372036854776, 43, Number::Normalized{}}));
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e-40";  // max int64 * 10^-40
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(
+                    (vaultSle->at(sfAssetsMaximum) ==
+                     Number{9223372036854776, -37, Number::Normalized{}}));
+            }
+            {
+                tx[sfAssetsMaximum] = "9223372036854775807e-100";  // max int64 * 10^-100
+                auto const newKeylet =
+                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+                env(tx);
+                env.close();
+
+                // Field 'AssetsMaximum' may not be explicitly set to default.
+                auto const vaultSle = env.le(newKeylet);
+                if (!BEAST_EXPECT(vaultSle))
+                    return;
+
+                BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero);
+            }
+
+            // What _can't_ IOUs do?
+            // 1. Exceed maximum exponent / offset
+            tx[sfAssetsMaximum] = "1000000000000000e81";
+            env(tx, Ter(tefEXCEPTION));
+            env.close();
+
+            // 2. Mantissa larger than uint64 max
+            try
+            {
+                auto const g = env.getParseFailureGuard(true);
+                tx[sfAssetsMaximum] = "18446744073709551617e5";  // uint64 max + 1
+                env(tx);
+                BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max");
+            }
+            catch (ParseError const& e)
+            {
+                using namespace std::string_literals;
+                BEAST_EXPECT(
+                    e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s);
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testScaleIOU();
+        testAssetsMaximum();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE_PRIO(VaultScale, app, xrpl, 1);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultShares_test.cpp b/src/test/app/vault/VaultShares_test.cpp
new file mode 100644
index 0000000000..037ee3e057
--- /dev/null
+++ b/src/test/app/vault/VaultShares_test.cpp
@@ -0,0 +1,736 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultShares_test : public VaultTestBase
+{
+private:
+    void
+    testNonTransferableShares()
+    {
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        env.fund(XRP(1000), issuer, owner, depositor);
+        env.close();
+
+        Vault const vault{env};
+        PrettyAsset const asset = issuer["IOU"];
+        env.trust(asset(1000), owner);
+        env(pay(issuer, owner, asset(100)));
+        env.trust(asset(1000), depositor);
+        env(pay(issuer, depositor, asset(100)));
+        env.close();
+
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        tx[sfFlags] = tfVaultShareNonTransferable;
+        env(tx);
+        env.close();
+
+        {
+            testcase("nontransferable deposits");
+            auto tx1 =
+                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
+            env(tx1);
+
+            auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
+            env(tx2);
+            env.close();
+        }
+
+        auto const vaultAccount =  //
+            [&env, key = keylet.key, this]() -> AccountID {
+            auto jvVault = env.rpc("vault_info", strHex(key));
+
+            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
+            BEAST_EXPECT(
+                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
+
+            // Vault pseudo-account
+            return parseBase58(jvVault[jss::result][jss::vault][jss::Account].asString())
+                .value();
+        }();
+
+        auto const mptId = makeMptID(1, vaultAccount);
+        Asset const shares = mptId;
+
+        {
+            testcase("nontransferable shares cannot be moved");
+            env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
+            env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
+        }
+
+        {
+            testcase("nontransferable shares can be used to withdraw");
+            auto tx1 =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
+            env(tx1);
+
+            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
+            env(tx2);
+            env.close();
+        }
+
+        {
+            testcase("nontransferable shares balance check");
+            auto jvVault = env.rpc("vault_info", strHex(keylet.key));
+            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
+            BEAST_EXPECT(
+                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
+        }
+
+        {
+            testcase("nontransferable shares withdraw rest");
+            auto tx1 =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
+            env(tx1);
+
+            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
+            env(tx2);
+            env.close();
+        }
+
+        {
+            testcase("nontransferable shares delete empty vault");
+            auto tx = vault.del({.owner = owner, .id = keylet.key});
+            env(tx);
+            BEAST_EXPECT(!env.le(keylet));
+        }
+    }
+
+    void
+    testFailedPseudoAccount()
+    {
+        using namespace test::jtx;
+
+        testcase("fail pseudo-account allocation");
+        Env env{*this, testableAmendments()};
+        Account const owner{"owner"};
+        Vault const vault{env};
+        env.fund(XRP(1000), owner);
+
+        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        for (int i = 0; i < 256; ++i)
+        {
+            AccountID const accountId = xrpl::pseudoAccountAddress(*env.current(), keylet.key);
+
+            env(pay(env.master.id(), accountId, XRP(1000)),
+                Seq(kAutofill),
+                Fee(kAutofill),
+                Sig(kAutofill));
+        }
+
+        auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
+        BEAST_EXPECT(keylet.key == keylet1.key);
+        env(tx, Ter{terADDRESS_COLLISION});
+    }
+
+    void
+    testRemoveEmptyHoldingLockedAmount()
+    {
+        testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
+        using namespace test::jtx;
+        using namespace std::literals;
+
+        auto const amendments = testableAmendments();
+        auto runTest = [&](FeatureBitset f) {
+            Env env{*this, f};
+            auto const baseFee = env.current()->fees().base;
+
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100000), issuer, owner, depositor, bob);
+            env.close();
+
+            Vault const vault{env};
+
+            // Create an MPT asset for the vault
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1000)));
+            env.close();
+
+            // Create vault
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const vaultSle = env.le(keylet);
+            BEAST_EXPECT(vaultSle != nullptr);
+            auto const shareMptID = vaultSle->at(sfShareMPTID);
+            MPTIssue const shareIssue{shareMptID};
+
+            // Depositor deposits 1000 asset units into vault, receiving shares
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
+            env.close();
+
+            // Check depositor has shares
+            {
+                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
+                BEAST_EXPECT(sleMpt != nullptr);
+                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
+            }
+
+            // Escrow 500 of those shares
+            env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
+                escrow::kCondition(escrow::kCb1),
+                escrow::kFinishTime(env.now() + 1s),
+                Fee(baseFee * 150),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Verify: sfMPTAmount=500, sfLockedAmount=500
+            {
+                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
+                BEAST_EXPECT(sleMpt != nullptr);
+                BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
+                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
+            }
+
+            // Withdraw remaining spendable shares — triggers removeEmptyHolding
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
+                Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
+            if (!f[fixCleanup3_1_3])
+            {
+                // Without the fix, removeEmptyHolding deletes the MPToken
+                // even though sfLockedAmount > 0, leaving the escrow's locked
+                // amount untracked.
+                BEAST_EXPECT(sleMptAfter == nullptr);
+            }
+            else
+            {
+                // With the fix, MPToken must still exist with sfLockedAmount > 0
+                // and sfMPTAmount == 0 (all spendable shares withdrawn).
+                BEAST_EXPECT(sleMptAfter != nullptr);
+                if (sleMptAfter)
+                {
+                    BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
+                    BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
+                }
+            }
+        };
+
+        runTest(amendments - fixCleanup3_1_3);
+        runTest(amendments);
+    }
+
+    void
+    testRemoveEmptyHoldingConfidentialBalances()
+    {
+        testcase("removeEmptyHolding keeps MPToken with confidential balances");
+        using namespace test::jtx;
+
+        Env env{*this, testableAmendments()};
+
+        Account const issuer{"issuer"};
+        Account const holder{"holder"};
+        MPTTester mpt{env, issuer, {.holders = {holder}}};
+        mpt.create({.authorize = MPTCreate::allHolders});
+
+        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
+        auto const encryptedBalanceFields = {
+            &sfConfidentialBalanceInbox,
+            &sfConfidentialBalanceSpending,
+            &sfIssuerEncryptedBalance,
+            &sfAuditorEncryptedBalance};
+
+        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
+            for (auto const field : encryptedBalanceFields)
+            {
+                Sandbox sb(&view, TapNone);
+                auto const token = sb.peek(tokenKeylet);
+                if (!BEAST_EXPECT(token))
+                    return false;
+
+                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
+                sb.update(token);
+
+                auto const dummyTx = *env.jt(noop(holder)).stx;
+                BEAST_EXPECT(
+                    removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
+                    tecHAS_OBLIGATIONS);
+                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
+            }
+            return true;
+        });
+    }
+
+    void
+    testReferenceHolding()
+    {
+        using namespace test::jtx;
+
+        auto readReferenceHolding = [&](Env const& env,
+                                        Keylet const& vaultKeylet) -> std::optional {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return std::nullopt;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return std::nullopt;
+            return sleIssuance->getFieldH256(sfReferenceHolding);
+        };
+
+        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
+        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
+        // or RippleState (for IOU-backed vaults).
+        {
+            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to MPToken must actually exist.
+            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
+        }
+
+        {
+            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault != nullptr);
+            auto const pseudoId = sleVault->at(sfAccount);
+            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
+
+            auto const stored = readReferenceHolding(env, keylet);
+            BEAST_EXPECT(stored.has_value());
+            BEAST_EXPECT(stored && *stored == expected);
+            // The pointed-to RippleState must actually exist.
+            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
+        }
+
+        // XRP-backed vaults leave the field absent: XRP has no separate
+        // holding ledger entry and no transferability concept to inherit.
+        {
+            testcase("sfReferenceHolding: XRP-backed vault, field absent");
+            Env env{*this, testableAmendments()};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), owner);
+            env.close();
+
+            PrettyAsset const asset{xrpIssue(), 1'000'000};
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
+        // of underlying type.
+        {
+            testcase("sfReferenceHolding: vault share, pre-amendment");
+            Env env{*this, testableAmendments() - fixCleanup3_2_0};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
+        }
+
+        // Plain MPTokenIssuanceCreate (not a vault share) must never
+        // populate the field. Only the post-amendment case is
+        // interesting; pre-amendment nothing writes the field at all.
+        {
+            testcase("sfReferenceHolding: plain MPT issuance never set");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            env.fund(XRP(10'000), issuer);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            env.close();
+
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
+            if (BEAST_EXPECT(sleIssuance))
+                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
+        }
+    }
+
+    // Probe every transactor surface that might delete the vault pseudo-
+    // account's underlying holding (the MPToken or RippleState pointed to
+    // by sfReferenceHolding). Each scenario asserts either that the
+    // existing pseudo-account guards stop the deletion at preclaim, or
+    // that the ledger leaves the holding intact afterwards. This is a
+    // regression guard: if any of these guards regresses, the share's
+    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
+    // invariant would catch it - but we want to fail much earlier, at
+    // the transactor's preclaim / doApply, not at invariant time.
+    void
+    testHoldingDeletionBlocked()
+    {
+        using namespace test::jtx;
+
+        // Helper: read the share's referenced holding and confirm the
+        // pointed-to SLE still exists after the probe.
+        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
+            auto const sleVault = env.le(vaultKeylet);
+            if (!sleVault)
+                return false;
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                return false;
+            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
+            return env.le(keylet::unchecked(holdingKey)) != nullptr;
+        };
+
+        // ---- MPT-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL underlying balance
+            // (500) directly from the vault pseudo-account. With the
+            // full amount, the doApply path would drain the pseudo's
+            // MPToken to zero and removeEmptyHolding would erase it -
+            // if doApply ever ran. SAV's pseudo-account guard at
+            // Clawback.cpp:201 refuses at preclaim with
+            // tecPSEUDO_ACCOUNT before any state change.
+            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = issuer, .holder = owner});
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            // Issuer attempts MPTokenAuthorize against the pseudo with
+            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
+            // accounts via isPseudoAccount; the pseudo's MPToken is
+            // preserved. Construct the tx manually since the pseudo
+            // lacks a signing key, and the issuer-driven flavour is
+            // expressed via sfHolder.
+            json::Value jv;
+            jv[sfAccount] = issuer.human();
+            jv[sfHolder] = toBase58(pseudoId);
+            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
+            jv[sfFlags] = tfMPTUnauthorize;
+            jv[sfTransactionType] = jss::MPTokenAuthorize;
+            env(jv, Ter{tecNO_PERMISSION});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        {
+            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(10'000), issuer, owner, depositor);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+            mptt.authorize({.account = depositor});
+            env(pay(issuer, depositor, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // While the vault holds outstanding underlying, the issuer
+            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
+            // the protection - and as a side effect, the share's
+            // sfReferenceHolding pointer cannot be left pointing at a
+            // ghost issuance.
+            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- IOU-backed vault ----------------------------------------
+        {
+            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            // Issuer attempts to claw back the FULL IOU balance (500)
+            // directly from the vault pseudo. With the full amount, the
+            // doApply path would drain the trust line to zero and (if
+            // both reserve flags clear) trustDelete would erase it - if
+            // doApply ever ran. The same SAV pseudo-account guard
+            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
+            // STAmount issuer field is the holder, per IOU clawback
+            // convention.
+            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            // Sanity: pseudo's full balance is intact.
+            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
+        }
+
+        {
+            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000'000), owner);
+            env(pay(issuer, owner, asset(1'000)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+
+            // Issuer submits TrustSet with limit=0 against the vault
+            // pseudo. The pseudo's side of the line still has the
+            // original (non-zero) limit and a non-zero balance, so the
+            // line is preserved - even though the issuer cleared its
+            // own side. trustDelete only fires when both limits clear
+            // and the balance is zero.
+            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
+            env(trust(issuer, pseudoAccount["IOU"](0)));
+            env.close();
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+        }
+
+        // ---- Positive control: VaultDelete is the only legitimate path
+        {
+            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            env.fund(XRP(10'000), issuer, owner);
+            env.close();
+
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+            PrettyAsset const asset = mptt.issuanceID();
+            mptt.authorize({.account = owner});
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            BEAST_EXPECT(referencedHoldingExists(env, keylet));
+            auto const pseudoId = env.le(keylet)->at(sfAccount);
+            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
+            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
+
+            // VaultDelete tears down the vault pseudo's holding, the
+            // share issuance, and the pseudo-account itself. Invariant
+            // permits this because the tx is ttVAULT_DELETE.
+            env(vault.del({.owner = owner, .id = keylet.key}));
+            env.close();
+
+            BEAST_EXPECT(env.le(keylet) == nullptr);
+            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
+            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testNonTransferableShares();
+        testFailedPseudoAccount();
+        testRemoveEmptyHoldingLockedAmount();
+        testRemoveEmptyHoldingConfidentialBalances();
+        testReferenceHolding();
+        testHoldingDeletionBlocked();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultShares, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
new file mode 100644
index 0000000000..ffaad07112
--- /dev/null
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -0,0 +1,655 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultSoleShareholder_test : public VaultTestBase
+{
+private:
+    // design doc:
+    //     AssetsAvailable ≈ 3,333.50
+    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
+    //     LossUnrealized  =  3,333
+    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
+    struct StuckDepositorFixture
+    {
+        test::jtx::Account issuer{"issuer"};
+        test::jtx::Account lender{"lender"};
+        test::jtx::Account bob{"bob"};
+        test::jtx::Account borrower{"borrower"};
+        std::optional asset;
+        std::optional vaultKeylet;
+        uint256 brokerID;
+        std::optional loanKeylet;
+        MPTID shareAsset;
+        std::uint64_t sharesLender = 0;
+    };
+
+    static constexpr std::int64_t kStuckFunding = 1'000'000;
+    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
+    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
+    static constexpr std::int64_t kStuckDeposit = 5'000;
+    static constexpr std::int64_t kStuckPrincipal = 3'333;
+    static constexpr std::uint32_t kStuckPayInterval = 600;
+    static constexpr std::uint32_t kStuckPayTotal = 2;
+
+    [[nodiscard]] StuckDepositorFixture
+    setupStuckDepositor(test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+
+        StuckDepositorFixture f;
+        f.asset = f.issuer[iouCurrency_];
+
+        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
+        env.close();
+
+        env(trust(f.lender, (*f.asset)(10'000'000)));
+        env(trust(f.bob, (*f.asset)(10'000'000)));
+        env(trust(f.borrower, (*f.asset)(10'000'000)));
+        env.close();
+
+        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
+        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
+        env.close();
+
+        // Vault: Lender creates and seeds it; Bob matches the deposit for a
+        // clean 50/50 split.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
+        env(createTx);
+        env.close();
+        if (!BEAST_EXPECT(env.le(vaultKeylet)))
+            return f;
+        f.vaultKeylet = vaultKeylet;
+
+        env(v.deposit({
+                .depositor = f.lender,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env(v.deposit({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = (*f.asset)(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Loan broker: no cover, no management fee, debt cap 10x principal.
+        f.brokerID =
+            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
+        {
+            using namespace loan_broker;
+            env(set(f.lender, vaultKeylet.key),
+                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
+            env.close();
+        }
+
+        // Loan: 3,333 USD principal, impaired immediately.
+        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return f;
+        f.loanKeylet =
+            keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        {
+            using namespace loan;
+            env(set(f.borrower, f.brokerID, kStuckPrincipal),
+                Sig(sfCounterpartySignature, f.lender),
+                kPaymentTotal(kStuckPayTotal),
+                kPaymentInterval(kStuckPayInterval),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
+            env.close();
+        }
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return f;
+        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+
+        f.shareAsset = vaultSle->at(sfShareMPTID);
+
+        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
+        if (!BEAST_EXPECT(tokenBob))
+            return f;
+        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
+
+        // Bob (non-sole) exits at the discounted rate. Always succeeds.
+        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
+        env(v.withdraw({
+                .depositor = f.bob,
+                .id = vaultKeylet.key,
+                .amount = bobShareAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return f;
+        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(sleIssuance))
+            return f;
+        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+
+        auto const vaultAfterBob = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfterBob))
+            return f;
+        // After Bob's exit: loss is unchanged (3,333 receivable), and the
+        // gap between assetsTotal and assetsAvailable equals exactly that
+        // receivable.
+        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
+        BEAST_EXPECT(
+            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
+            vaultAfterBob->at(sfLossUnrealized));
+
+        return f;
+    }
+
+    // Reproduces the worked example from the XLS-0065 design doc. The sole
+    // remaining shareholder asks (via fixed-asset input) for the vault's
+    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
+    // invariant violation. Post-fix the full-price exchange rate burns
+    // only a portion of the shares, the depositor receives all of
+    // AssetsAvailable, and the residual shares remain backed by the
+    // impaired-loan receivable.
+    void
+    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder exits via "
+                        "fixed-asset amount with impaired loan"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        // The requested amount differs between feature regimes because
+        // the two regimes are testing different behaviors:
+        //
+        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
+        //   the discounted formula this would burn every outstanding
+        //   share, hitting the zero-sized-vault invariant. The
+        //   transaction is rejected with tecINVARIANT_FAILED — the
+        //   stuck-depositor bug.
+        //
+        // - Post-fix: request a strictly smaller amount (1,000 USD).
+        //   The full-price formula burns only ~30% of the outstanding
+        //   shares; the vault retains the rest, backed by the impaired
+        //   receivable. Requesting *exactly* AssetsAvailable post-fix
+        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
+        //   round-to-nearest used by assetsToSharesWithdraw (the
+        //   recomputed payout can overshoot the request by a few ULPs).
+        //   The "force payout to AssetsAvailable" branch in doApply
+        //   only triggers when every share is burned, which is covered
+        //   by the loan-repayment test.
+        STAmount const requestAssets =
+            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = requestAssets,
+            }),
+            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
+        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
+        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
+        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
+
+        if (!withFix)
+        {
+            // Pre-fix: rejected — vault state unchanged.
+            BEAST_EXPECT(sharesAfter == f.sharesLender);
+            BEAST_EXPECT(availableAfter == availableBefore);
+            BEAST_EXPECT(totalAfter == totalBefore);
+            BEAST_EXPECT(lossAfter == lossBefore);
+            return;
+        }
+
+        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
+        // totalBefore=6666.5, request=1000):
+        //   sharesRedeemed = round(sharesLender * request / totalBefore)
+        //                  = round(750,018,750.469) = 750,018,750
+        //   received       = totalBefore * sharesRedeemed / sharesLender
+        //                  = 999.999999375  (slightly under 1,000 due to
+        //                                    integer-share rounding)
+        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
+        Number const expectedReceived =
+            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
+
+        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
+
+        // LossUnrealized is unchanged: the loan-protocol side is untouched.
+        BEAST_EXPECT(lossAfter == lossBefore);
+
+        // The entire (total - available) gap is the impaired receivable,
+        // i.e. equal to lossUnrealized.
+        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
+
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        BEAST_EXPECT(received == expectedReceived);
+
+        // Conservation: assets removed from the vault equal what the
+        // depositor received.
+        BEAST_EXPECT(totalBefore - totalAfter == received);
+        BEAST_EXPECT(availableBefore - availableAfter == received);
+    }
+
+    // Sole shareholder attempts to burn ALL outstanding shares via
+    // fixed-shares input while the vault still holds an impaired
+    // receivable. Pre-fix this fails with the zero-sized-vault invariant
+    // violation. Post-fix the full-price rate causes assetsWithdrawn to
+    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
+    // is rejected with tecINSUFFICIENT_FUNDS.
+    void
+    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder full-shares "
+                        "burn is rejected while loss outstanding"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        std::string logs;
+        Env env(*this, features, std::make_unique(&logs));
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Fixed-shares input: ask for ALL outstanding shares.
+        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = shareAmt,
+            }),
+            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
+        env.close();
+
+        // Either way the transaction was rejected; vault state unchanged.
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+    }
+
+    // Clean-state regression: with no impaired loan, a sole shareholder
+    // burning all their shares fully empties the vault under both the
+    // pre-fix and post-fix code paths. Confirms the new logic doesn't
+    // break the existing happy-path close-out.
+    void
+    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_2_0];
+        testcase(
+            std::string{"Vault withdraw: sole shareholder clean-state "
+                        "close-out unchanged"} +
+            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
+
+        Env env(*this, features);
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+
+        env.fund(XRP(kStuckFunding), issuer, lender);
+        env.close();
+
+        PrettyAsset const asset = issuer[iouCurrency_];
+        env(trust(lender, asset(10'000'000)));
+        env.close();
+        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
+        env.close();
+
+        // Sole shareholder of a clean vault — no loan broker needed.
+        Vault const v{env};
+        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
+        env(createTx);
+        env.close();
+
+        env(v.deposit({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = asset(kStuckDeposit),
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultBefore = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        auto const shareAsset = vaultBefore->at(sfShareMPTID);
+        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
+        if (!BEAST_EXPECT(tokenLender))
+            return;
+        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
+
+        // Sole shareholder, no loans, no loss. Burn everything.
+        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
+        env(v.withdraw({
+                .depositor = lender,
+                .id = vaultKeylet.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // (Pre-fix path takes the regular code path; post-fix path enters
+        // the new final-withdrawal guard, which forces payout to exactly
+        // assetsAvailable. Either way the result is identical for a clean
+        // vault.)
+        (void)withFix;
+    }
+
+    // Sole shareholder in an impaired vault redeems a *partial* count of
+    // shares via fixed-shares input. Pre-fix the discounted formula is
+    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
+    // = Yes). The relative payout therefore differs, and post-fix the
+    // depositor recovers proportionally more of the residual cash for
+    // the shares burned. In both cases the vault is left in a valid
+    // (non-empty) state.
+    void
+    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
+    {
+        using namespace test::jtx;
+
+        testcase(
+            "Vault withdraw: sole-shareholder partial fixed-shares uses "
+            "full-price rate (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        auto const vaultBefore = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultBefore))
+            return;
+        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
+        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
+        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
+
+        // Burn exactly half of the outstanding shares.
+        std::uint64_t const halfShares = f.sharesLender / 2;
+        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
+
+        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
+
+        Vault const v{env};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = halfAmt,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Expected payout under the full-price formula:
+        //   assets = totalBefore * halfShares / sharesLender
+        // which (with halfShares == sharesLender/2) is roughly
+        //   totalBefore / 2.
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const received{lenderBalanceAfter - lenderBalanceBefore};
+        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received == expected);
+
+        // The full-price payout exceeds the discounted formula by exactly
+        // lossBefore * halfShares / sharesLender — that's the whole point
+        // of the waive.
+        Number const discounted =
+            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
+        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
+        BEAST_EXPECT(received - discounted == expectedDelta);
+
+        auto const vaultAfter = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfter))
+            return;
+        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceAfter))
+            return;
+
+        // Vault remains valid: half the shares remain, lossUnrealized
+        // is untouched, and the entire (total - available) gap is still
+        // the impaired receivable.
+        BEAST_EXPECT(
+            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
+        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
+        BEAST_EXPECT(
+            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
+            vaultAfter->at(sfLossUnrealized));
+
+        // Conservation: vault delta matches the depositor's gain.
+        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
+        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
+    }
+
+    // Post-fix end-to-end resolution: after the sole-shareholder partial
+    // exit, the loan is repaid in full. With unrealized loss cleared and
+    // all assets back as cash, the depositor can burn all remaining
+    // shares and fully exit the vault. The final withdrawal hits the
+    // "force payout to assetsAvailable" branch in doApply.
+    void
+    testWithdrawSoleShareholderLoanRepaymentExit()
+    {
+        using namespace test::jtx;
+        using namespace loan;
+
+        testcase(
+            "Vault withdraw: sole shareholder fully exits after impaired "
+            "loan is repaid (fixCleanup3_2_0)");
+
+        Env env(*this, all_ | fixCleanup3_2_0);
+        auto const f = setupStuckDepositor(env);
+        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
+        {
+            BEAST_EXPECT(false);
+            return;
+        }
+        Keylet const& vaultKey = *f.vaultKeylet;
+        Keylet const& loanKey = *f.loanKeylet;
+        PrettyAsset const& asset = *f.asset;
+
+        Vault const v{env};
+
+        // Sole-shareholder partial exit (see comment in
+        // testWithdrawSoleShareholderFixedAssetExit for why we request
+        // less than full AssetsAvailable).
+        {
+            STAmount const requestAssets = asset(1000).value();
+            env(v.withdraw({
+                    .depositor = f.lender,
+                    .id = vaultKey.key,
+                    .amount = requestAssets,
+                }),
+                Ter(tesSUCCESS));
+            env.close();
+        }
+
+        // Confirm the "dormant-but-alive" state from the design doc. The
+        // partial exit burned exactly 750,018,750 shares (see derivation
+        // in testWithdrawSoleShareholderFixedAssetExit).
+        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
+        if (!BEAST_EXPECT(tokenAfterExit))
+            return;
+        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
+        BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
+
+        // Borrower repays the loan in full (pays more than the outstanding
+        // total; the loan transactor caps the receivable).
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfterRepay = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultAfterRepay))
+            return;
+        // Repayment converts the 3,333 receivable back to cash; assetsTotal
+        // is unchanged but assetsAvailable jumps by exactly the same amount,
+        // and lossUnrealized clears to zero.
+        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
+        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
+
+        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
+        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
+
+        // Burn all remaining shares — the clean-state preconditions of
+        // the "final withdrawal" guard are now satisfied.
+        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
+        env(v.withdraw({
+                .depositor = f.lender,
+                .id = vaultKey.key,
+                .amount = allShares,
+            }),
+            Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultFinal = env.le(vaultKey);
+        if (!BEAST_EXPECT(vaultFinal))
+            return;
+        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
+        if (!BEAST_EXPECT(issuanceFinal))
+            return;
+
+        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
+        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
+        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
+
+        // The final payout equals exactly the AssetsAvailable that
+        // existed before the call (the "force payout" branch).
+        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
+        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
+        BEAST_EXPECT(finalReceived == availableBeforeFinal);
+    }
+
+public:
+    void
+    run() override
+    {
+        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(all_);
+        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(all_);
+        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
+        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
+        testWithdrawSoleShareholderLoanRepaymentExit();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultSoleShareholder, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultTestBase.h b/src/test/app/vault/VaultTestBase.h
new file mode 100644
index 0000000000..538f3b72d8
--- /dev/null
+++ b/src/test/app/vault/VaultTestBase.h
@@ -0,0 +1,120 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+/**
+ * Shared base for the Vault*_test family under src/test/app/vault/.
+ *
+ * Owns the class-level helpers (type aliases, closed-ended vault
+ * scaffolding, standard feature bitset, IOU currency string) that every
+ * topical Vault*_test suite depends on. Mirrors
+ * src/test/app/lending/LoanTestBase.h.
+ *
+ * Run all suites in this family with `xrpld -u Vault` (the "Vault" prefix
+ * is matched against every suite name via
+ * beast::unit_test::Selector::ModeT::Automatch).
+ */
+class VaultTestBase : public beast::unit_test::Suite
+{
+protected:
+    using PrettyAsset = test::jtx::PrettyAsset;
+    using PrettyAmount = test::jtx::PrettyAmount;
+
+    static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
+        return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
+    };
+
+    /**
+     * Get the current ledger's close time resolution.
+     * @param env The test environment.
+     */
+    static NetClock::duration
+    getLedgerTimeResolution(test::jtx::Env& env)
+    {
+        return env.current()->header().closeTimeResolution;
+    }
+
+    void
+    closeToTime(
+        test::jtx::Env& env,
+        NetClock::time_point time,
+        std::source_location const& loc = std::source_location::current())
+    {
+        using namespace std::chrono_literals;
+        env.close(time - env.closed()->header().closeTimeResolution + 1s);
+        expect(
+            env.closed()->header().closeTime == time,
+            std::format(
+                "current ledger time {} is not equal to the target ledger time {}",
+                env.closed()->header().closeTime.time_since_epoch(),
+                time.time_since_epoch()),
+            loc.file_name(),
+            loc.line());
+    }
+
+    using d = NetClock::duration;
+    using tp = NetClock::time_point;
+
+    // Vault holds an Env& so no default initializer is possible; the
+    // struct is always aggregate-initialized by makeClosedEndedVault.
+    // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init)
+    struct ClosedEndedSetup
+    {
+        test::jtx::Vault vault;
+        Keylet keylet;
+        std::uint32_t sub = 0;
+        std::uint32_t red = 0;
+    };
+    // NOLINTEND(cppcoreguidelines-pro-type-member-init)
+
+    // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at
+    // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then
+    // close the ledger. Returns the Vault helper, the vault's keylet and the
+    // resolved sub/red timestamps.
+    static ClosedEndedSetup
+    makeClosedEndedVault(
+        test::jtx::Env& env,
+        test::jtx::Account const& owner,
+        Asset const& asset,
+        std::uint32_t subOffset,
+        std::uint32_t gap)
+    {
+        auto const sub = env.now().time_since_epoch().count() + subOffset;
+        auto const red = sub + gap;
+        test::jtx::Vault const vault{env};
+        auto [tx, keylet] = vault.create(
+            {.owner = owner,
+             .asset = asset,
+             .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+             .subscriptionDate = sub,
+             .redemptionDate = red});
+        env(tx);
+        env.close();
+        return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
+    }
+
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+    std::string const iouCurrency_{"IOU"};
+};
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp
new file mode 100644
index 0000000000..4219ce4661
--- /dev/null
+++ b/src/test/app/vault/VaultValidation_test.cpp
@@ -0,0 +1,1086 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class VaultValidation_test : public VaultTestBase
+{
+private:
+    void
+    testPreflight()
+    {
+        using namespace test::jtx;
+
+        struct CaseArgs
+        {
+            FeatureBitset features = testableAmendments();
+        };
+
+        auto testCase = [&, this](
+                            std::function test,
+                            CaseArgs args = {}) {
+            Env env{*this, args.features};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Vault vault{env};
+            env.fund(XRP(1000), issuer, owner);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env(fset(issuer, asfRequireAuth));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env(trust(owner, asset(1000)));
+            env(trust(issuer, asset(0), owner, tfSetfAuth));
+            env(pay(issuer, owner, asset(1000)));
+            env.close();
+
+            test(env, issuer, owner, asset, vault);
+        };
+
+        auto testDisabled = [&](TER resultAfterCreate = temDISABLED) {
+            return [&, resultAfterCreate](
+                       Env& env,
+                       Account const& issuer,
+                       Account const& owner,
+                       Asset const& asset,
+                       Vault& vault) {
+                testcase("disabled single asset vault");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, kData("test"), Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx = vault.clawback(
+                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                    env(tx, Ter{resultAfterCreate});
+                }
+
+                {
+                    auto tx = vault.del({.owner = owner, .id = keylet.key});
+                    env(tx, Ter{resultAfterCreate});
+                }
+            };
+        };
+
+        testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault});
+
+        testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1});
+
+        testCase(
+            [&](Env& env,
+                Account const& issuer,
+                Account const& owner,
+                Asset const& asset,
+                Vault& vault) {
+                testcase("disabled permissioned domains");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx);
+
+                tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate;
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, kData("Test"));
+
+                    tx[sfDomainID] = to_string(BaseUInt<256>(13ul));
+                    env(tx, Ter{temDISABLED});
+                }
+            },
+            {.features = testableAmendments() - featurePermissionedDomains});
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid flags");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfFlags] = tfClearDeepFreeze;
+            env(tx, Ter{temINVALID_FLAG});
+
+            {
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+
+            {
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                tx[sfFlags] = tfClearDeepFreeze;
+                env(tx, Ter{temINVALID_FLAG});
+            }
+        });
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid fee");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[jss::Fee] = "-1";
+            env(tx, Ter{temBAD_FEE});
+
+            {
+                auto tx = vault.set({.owner = owner, .id = keylet.key});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+
+            {
+                auto tx = vault.del({.owner = owner, .id = keylet.key});
+                tx[jss::Fee] = "-1";
+                env(tx, Ter{temBAD_FEE});
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) {
+                testcase("disabled permissioned domain");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                env(tx, Ter{temDISABLED});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                    env(tx, Ter{temDISABLED});
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfDomainID] = "0";
+                    env(tx, Ter{temDISABLED});
+                }
+            },
+            {.features = (testableAmendments()) - featurePermissionedDomains});
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("use zero vault");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+
+            {
+                auto tx = vault.set({
+                    .owner = owner,
+                    .id = beast::kZero,
+                });
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx =
+                    vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
+                env(tx, Ter(temMALFORMED));
+            }
+
+            {
+                auto tx =
+                    vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)});
+                env(tx, Ter{temMALFORMED});
+            }
+
+            {
+                auto tx = vault.del({
+                    .owner = owner,
+                    .id = beast::kZero,
+                });
+                env(tx, Ter{temMALFORMED});
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("withdraw to bad destination");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
+                    tx[jss::Destination] = "0";
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create with Scale");
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 255;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 19;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                // accepted range from 0 to 18
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 18;
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 18);
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    tx[sfScale] = 0;
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 0);
+                }
+
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    env(tx);
+                    env.close();
+                    auto const sleVault = env.le(keylet);
+                    BEAST_EXPECT(sleVault);
+                    BEAST_EXPECT((*sleVault)[sfScale] == 6);
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create or set invalid data");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfData] = "";
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    // A hexadecimal string of 257 bytes.
+                    tx[sfData] = std::string(514, 'A');
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfData] = "";
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    // A hexadecimal string of 257 bytes.
+                    tx[sfData] = std::string(514, 'A');
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("set nothing updated");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("create with invalid metadata");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfMPTokenMetadata] = "";
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    // This metadata is for the share token.
+                    // A hexadecimal string of 1025 bytes.
+                    tx[sfMPTokenMetadata] = std::string(2050, 'B');
+                    env(tx, Ter(temMALFORMED));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("set negative maximum");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid deposit amount");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.deposit(
+                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+
+                {
+                    auto tx =
+                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid set immutable flag");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.set({.owner = owner, .id = keylet.key});
+                    tx[sfFlags] = tfVaultPrivate;
+                    env(tx, Ter(temINVALID_FLAG));
+                }
+            });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid withdraw amount");
+
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = vault.withdraw(
+                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+
+                {
+                    auto tx =
+                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)});
+                    env(tx, Ter(temBAD_AMOUNT));
+                }
+            });
+
+        testCase([&](Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("invalid clawback");
+
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+            // Preclaim only checks for native assets.
+            if (asset.native())
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
+                env(tx, Ter(temMALFORMED));
+            }
+
+            {
+                auto tx = vault.clawback(
+                    {.issuer = issuer,
+                     .id = keylet.key,
+                     .holder = owner,
+                     .amount = kNegativeAmount(asset)});
+                env(tx, Ter(temBAD_AMOUNT));
+            }
+        });
+
+        testCase(
+            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
+                testcase("invalid create");
+
+                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                {
+                    auto tx = tx1;
+                    tx[sfWithdrawalPolicy] = 0;
+                    env(tx, Ter(temMALFORMED));
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
+                    env(tx, Ter{temMALFORMED});
+                }
+
+                {
+                    auto tx = tx1;
+                    tx[sfFlags] = tfVaultPrivate;
+                    tx[sfDomainID] = "0";
+                    env(tx, Ter{temMALFORMED});
+                }
+            });
+    }
+
+    // Test for non-asset specific behaviors.
+    void
+    testCreateFailXRP()
+    {
+        using namespace test::jtx;
+
+        auto testCase = [this](
+                            std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+            Asset const asset = xrpIssue();
+
+            test(env, issuer, owner, depositor, asset, vault);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to set");
+            auto tx = vault.set({.owner = owner, .id = keylet::skip().key});
+            tx[sfAssetsMaximum] = asset(0).number();
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to deposit to");
+            auto tx = vault.deposit(
+                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     PrettyAsset const& asset,
+                     Vault& vault) {
+            testcase("nothing to withdraw from");
+            auto tx = vault.withdraw(
+                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("nothing to delete");
+            auto tx = vault.del({.owner = owner, .id = keylet::skip().key});
+            env(tx, Ter(tecNO_ENTRY));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("transaction is good");
+            env(tx);
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfWithdrawalPolicy] = 1;
+            testcase("explicitly select withdrawal policy");
+            env(tx);
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("insufficient fee");
+            env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            testcase("insufficient reserve");
+            // It is possible to construct a complicated mathematical
+            // expression for this amount, but it is sadly not easy.
+            env(pay(owner, issuer, XRP(775)));
+            env.close();
+            env(tx, Ter(tecINSUFFICIENT_RESERVE));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfFlags] = tfVaultPrivate;
+            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
+            testcase("non-existing domain");
+            env(tx, Ter{tecOBJECT_NOT_FOUND});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("cannot set Scale=0");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 0;
+            env(tx, Ter{temMALFORMED});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("cannot set Scale=1");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 1;
+            env(tx, Ter{temMALFORMED});
+        });
+    }
+
+    void
+    testCreateFailIOU()
+    {
+        using namespace test::jtx;
+        {
+            {
+                testcase("IOU fail because MPT is disabled");
+                Env env{*this, (testableAmendments() - featureMPTokensV1)};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                env(tx, Ter(temDISABLED));
+                env.close();
+            }
+
+            {
+                testcase("IOU fail create frozen");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+                env(fset(issuer, asfGlobalFreeze));
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+
+                env(tx, Ter(tecFROZEN));
+                env.close();
+            }
+
+            {
+                testcase("IOU fail create no ripling");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), issuer, owner);
+                env.close();
+                env(fclear(issuer, asfDefaultRipple));
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                env(tx, Ter(terNO_RIPPLE));
+                env.close();
+            }
+
+            {
+                testcase("IOU no issuer");
+                Env env{*this, testableAmendments()};
+                Account const issuer{"issuer"};
+                Account const owner{"owner"};
+                env.fund(XRP(1000), owner);
+                env.close();
+
+                Vault const vault{env};
+                Asset const asset = issuer["IOU"].asset();
+                {
+                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+                    env(tx, Ter(terNO_ACCOUNT));
+                    env.close();
+                }
+            }
+        }
+
+        {
+            testcase("IOU fail create vault for AMM LPToken");
+            Env env{*this, testableAmendments()};
+            Account const gw("gateway");
+            Account const alice("alice");
+            Account const carol("carol");
+            IOU const usd = gw["USD"];
+
+            auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000));
+            auto toFund = [&](STAmount const& a) -> STAmount {
+                if (a.native())
+                {
+                    auto const defXRP = XRP(30000);
+                    if (a <= defXRP)
+                        return defXRP;
+                    return a + XRP(1000);
+                }
+                auto defIOU = STAmount{a.asset(), 30000};
+                if (a <= defIOU)
+                    return defIOU;
+                return a + STAmount{a.asset(), 1000};
+            };
+            auto const toFund1 = toFund(asset1);
+            auto const toFund2 = toFund(asset2);
+            BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2);
+
+            if (!asset1.native() && !asset2.native())
+            {
+                fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All);
+            }
+            else if (asset1.native())
+            {
+                fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All);
+            }
+            else if (asset2.native())
+            {
+                fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All);
+            }
+
+            AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0});
+
+            Account const owner{"owner"};
+            env.fund(XRP(1000000), owner);
+
+            Vault const vault{env};
+            auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()});
+            env(tx, Ter{tecWRONG_ASSET});
+            env.close();
+        }
+    }
+
+    void
+    testCreateFailMPT()
+    {
+        using namespace test::jtx;
+
+        auto testCase = [this](
+                            std::function test) {
+            Env env{*this, testableAmendments()};
+            Account const issuer{"issuer"};
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(1000), issuer, owner, depositor);
+            env.close();
+            Vault vault{env};
+            MPTTester mptt{env, issuer, kMptInitNoFund};
+            // Locked because that is the default flag.
+            mptt.create();
+            Asset const asset = mptt.issuanceID();
+
+            test(env, issuer, owner, depositor, asset, vault);
+        };
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT no authorization");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx, Ter(tecNO_AUTH));
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT cannot set Scale=0");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 0;
+            env(tx, Ter{temMALFORMED});
+        });
+
+        testCase([this](
+                     Env& env,
+                     Account const& issuer,
+                     Account const& owner,
+                     Account const& depositor,
+                     Asset const& asset,
+                     Vault& vault) {
+            testcase("MPT cannot set Scale=1");
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            tx[sfScale] = 1;
+            env(tx, Ter{temMALFORMED});
+        });
+    }
+
+    void
+    testVaultDeleteMemoData()
+    {
+        using namespace test::jtx;
+
+        Env env{*this};
+
+        Account const owner{"owner"};
+        env.fund(XRP(1'000'000), owner);
+        env.close();
+
+        Vault const vault{env};
+
+        auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1));
+        auto delTx = vault.del({.owner = owner, .id = keylet.key});
+
+        // Test VaultDelete with featureLendingProtocolV1_1 disabled
+        // Transaction fails if the data field is provided
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled");
+            env.disableFeature(featureLendingProtocolV1_1);
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(temDISABLED));
+            env.enableFeature(featureLendingProtocolV1_1);
+            env.close();
+        }
+
+        // Transaction fails if the data field is too large
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large");
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A'));
+            env(delTx, Ter(temMALFORMED));
+            env.close();
+        }
+
+        // Transaction fails if the data field is set, but is empty
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty");
+            delTx[sfMemoData] = strHex(std::string());
+            env(delTx, Ter(temMALFORMED));
+            env.close();
+        }
+
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault");
+            auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+
+            // Recreate the transaction as the vault keylet changed
+            auto delTx = vault.del({.owner = owner, .id = keylet.key});
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(tecNO_ENTRY));
+            env.close();
+        }
+
+        {
+            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid");
+            PrettyAsset const xrpAsset = xrpIssue();
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+            // Recreate the transaction as the vault keylet changed
+            auto delTx = vault.del({.owner = owner, .id = keylet.key});
+            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
+            env(delTx, Ter(tesSUCCESS));
+            env.close();
+        }
+    }
+
+    void
+    testVaultCreateLEVersion()
+    {
+        using namespace test::jtx;
+
+        Account const owner{"owner"};
+        PrettyAsset const xrpAsset = xrpIssue();
+
+        {
+            testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
+            Env env{*this};
+            env.disableFeature(featureLendingProtocolV1_1);
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault);
+            BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
+        }
+
+        {
+            testcase(
+                "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
+                "VaultVersion::CashBasis");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(tx, Ter(tesSUCCESS));
+            env.close();
+
+            auto const sleVault = env.le(keylet);
+            BEAST_EXPECT(sleVault);
+            BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
+            BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
+        }
+
+        {
+            testcase("VaultCreate rejects LEVersion set in the transaction");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            tx[sfLEVersion] = 2;
+            env(tx, Ter(temMALFORMED));
+            env.close();
+
+            BEAST_EXPECT(!env.le(keylet));
+        }
+
+        {
+            testcase("VaultSet rejects LEVersion set in the transaction");
+            Env env{*this};
+            env.fund(XRP(1'000'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
+            env(createTx, Ter(tesSUCCESS));
+            env.close();
+
+            auto setTx = vault.set({.owner = owner, .id = keylet.key});
+            setTx[sfLEVersion] = 2;
+            env(setTx, Ter(temMALFORMED));
+            env.close();
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testPreflight();
+        testCreateFailXRP();
+        testCreateFailIOU();
+        testCreateFailMPT();
+        testVaultDeleteMemoData();
+        testVaultCreateLEVersion();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultValidation, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp
index 41b5f81f5d..f7679dc488 100644
--- a/src/test/basics/PerfLog_test.cpp
+++ b/src/test/basics/PerfLog_test.cpp
@@ -15,14 +15,10 @@
 #include 
 #include 
 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -31,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -43,7 +40,7 @@ class PerfLog_test : public beast::unit_test::Suite
 {
     enum class WithFile : bool { No = false, Yes = true };
 
-    using path = boost::filesystem::path;
+    using path = std::filesystem::path;
 
     // We're only using Env for its Journal.  That Journal gives better
     // coverage in unit tests.
@@ -66,14 +63,14 @@ class PerfLog_test : public beast::unit_test::Suite
             // The error code is intentionally ignored: if the path doesn't
             // exist (the common case on a clean runner) remove_all returns
             // an error, and that's fine — there's nothing to clean up.
-            using namespace boost::filesystem;
-            boost::system::error_code ec;
+            using namespace std::filesystem;
+            std::error_code ec;
             remove_all(logDir(), ec);
         }
 
         ~Fixture()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
 
             auto const dir{logDir()};
             auto const file{logFile()};
@@ -96,7 +93,7 @@ class PerfLog_test : public beast::unit_test::Suite
         static path
         logDir()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
             return temp_directory_path() / "perf_log_test_dir";
         }
 
@@ -129,7 +126,7 @@ class PerfLog_test : public beast::unit_test::Suite
         static void
         wait()
         {
-            using namespace boost::filesystem;
+            using namespace std::filesystem;
 
             auto const path = logFile();
             if (!exists(path))
@@ -201,7 +198,7 @@ public:
     void
     testFileCreation()
     {
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         {
             // Verify a PerfLog creates its file when constructed.
@@ -250,28 +247,30 @@ public:
             // Put a write protected file where PerfLog wants to write its
             // file.  Make sure that PerfLog tries to shutdown the server
             // since it can't open its file.
+            using std::filesystem::perms;
+
             Fixture fixture{env_.app(), j_};
             if (!BEAST_EXPECT(!exists(fixture.logDir())))
                 return;
 
             // Construct and write protect a file to prevent PerfLog
             // from creating its file.
-            boost::system::error_code ec;
-            boost::filesystem::create_directories(fixture.logDir(), ec);
+            std::error_code ec;
+            std::filesystem::create_directories(fixture.logDir(), ec);
             if (!BEAST_EXPECT(!ec))
                 return;
 
-            auto fileWriteable = [](boost::filesystem::path const& p) -> bool {
-                return std::ofstream{p.c_str(), std::ios::out | std::ios::app}.is_open();
+            auto fileWriteable = [](std::filesystem::path const& p) -> bool {
+                return std::ofstream{p, std::ios::out | std::ios::app}.is_open();
             };
 
             if (!BEAST_EXPECT(fileWriteable(fixture.logFile())))
                 return;
 
-            boost::filesystem::permissions(
+            std::filesystem::permissions(
                 fixture.logFile(),
-                perms::remove_perms | perms::owner_write | perms::others_write |
-                    perms::group_write);
+                perms::owner_write | perms::others_write | perms::group_write,
+                std::filesystem::perm_options::remove);
 
             // If the test is running as root, then the write protect may have
             // no effect.  Make sure write protect worked before proceeding.
@@ -295,9 +294,10 @@ public:
             perfLog->stop();
 
             // Fix file permissions so the file can be cleaned up.
-            boost::filesystem::permissions(
+            std::filesystem::permissions(
                 fixture.logFile(),
-                perms::add_perms | perms::owner_write | perms::others_write | perms::group_write);
+                perms::owner_write | perms::others_write | perms::group_write,
+                std::filesystem::perm_options::add);
         }
     }
 
@@ -312,7 +312,7 @@ public:
 
         // Get the all the labels we can use for RPC interfaces without
         // causing an assert.
-        std::vector labels = test::jtx::makeVector(xrpl::RPC::getHandlerNames());
+        std::vector labels = test::jtx::makeVector(xrpl::rpc::getHandlerNames());
         std::shuffle(labels.begin(), labels.end(), defaultPrng());
 
         // Get two IDs to associate with each label.  Errors tend to happen at
@@ -483,7 +483,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 +804,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 +944,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 +962,7 @@ public:
         // We can't fully test rotate because unit tests must run on Windows,
         // and Windows doesn't (may not?) support rotate.  But at least call
         // the interface and see that it doesn't crash.
-        using namespace boost::filesystem;
+        using namespace std::filesystem;
 
         Fixture fixture{env_.app(), j_};
         BEAST_EXPECT(!exists(fixture.logDir()));
diff --git a/src/test/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/consensus/ByzantineFailureSim_test.cpp b/src/test/consensus/ByzantineFailureSim_test.cpp
deleted file mode 100644
index c3c51125b5..0000000000
--- a/src/test/consensus/ByzantineFailureSim_test.cpp
+++ /dev/null
@@ -1,88 +0,0 @@
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class ByzantineFailureSim_test : public beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        using namespace csf;
-        using namespace std::chrono;
-
-        // This test simulates a specific topology with nodes generating
-        // different ledgers due to a simulated byzantine failure (injecting
-        // an extra non-consensus transaction).
-
-        Sim sim;
-        ConsensusParms const parms{};
-
-        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-        PeerGroup a = sim.createGroup(1);
-        PeerGroup b = sim.createGroup(1);
-        PeerGroup c = sim.createGroup(1);
-        PeerGroup d = sim.createGroup(1);
-        PeerGroup e = sim.createGroup(1);
-        PeerGroup f = sim.createGroup(1);
-        PeerGroup g = sim.createGroup(1);
-
-        a.trustAndConnect(a + b + c + g, delay);
-        b.trustAndConnect(b + a + c + d + e, delay);
-        c.trustAndConnect(c + a + b + d + e, delay);
-        d.trustAndConnect(d + b + c + e + f, delay);
-        e.trustAndConnect(e + b + c + d + f, delay);
-        f.trustAndConnect(f + d + e + g, delay);
-        g.trustAndConnect(g + a + f, delay);
-
-        PeerGroup const network = a + b + c + d + e + f + g;
-
-        StreamCollector sc{std::cout};
-
-        sim.collectors.add(sc);
-
-        for (TrustGraph::ForkInfo const& fi : sim.trustGraph.forkablePairs(0.8))
-        {
-            std::cout << "Can fork " << PeerGroup{fi.unlA} << " "
-                      << " " << PeerGroup{fi.unlB} << " overlap " << fi.overlap << " required "
-                      << fi.required << "\n";
-        };
-
-        // set prior state
-        sim.run(1);
-
-        PeerGroup byzantineNodes = a + b + c + g;
-        // All peers see some TX 0
-        for (Peer* peer : network)
-        {
-            peer->submit(Tx{0});
-            // Peers 0,1,2,6 will close the next ledger differently by injecting
-            // a non-consensus approved transaction
-            if (byzantineNodes.contains(peer))
-            {
-                peer->txInjections.emplace(peer->lastClosedLedger.seq(), Tx{42});
-            }
-        }
-        sim.run(4);
-        std::cout << "Branches: " << sim.branches() << "\n";
-        std::cout << "Fully synchronized: " << std::boolalpha << sim.synchronized() << "\n";
-        // Not tessting anything currently.
-        pass();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE_MANUAL(ByzantineFailureSim, consensus, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/consensus/Consensus_test.cpp b/src/test/consensus/Consensus_test.cpp
deleted file mode 100644
index 45f58d16ba..0000000000
--- a/src/test/consensus/Consensus_test.cpp
+++ /dev/null
@@ -1,1432 +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 
-
-namespace xrpl::test {
-
-class Consensus_test : public beast::unit_test::Suite
-{
-    SuiteJournal journal_;
-
-public:
-    Consensus_test() : journal_("Consensus_test", *this)
-    {
-    }
-
-    void
-    testShouldCloseLedger()
-    {
-        using namespace std::chrono_literals;
-        testcase("should close ledger");
-
-        // Use default parameters
-        ConsensusParms const p{};
-
-        // Bizarre times forcibly close
-        BEAST_EXPECT(shouldCloseLedger(true, 10, 10, 10, -10s, 10s, 1s, 1s, p, journal_));
-        BEAST_EXPECT(shouldCloseLedger(true, 10, 10, 10, 100h, 10s, 1s, 1s, p, journal_));
-        BEAST_EXPECT(shouldCloseLedger(true, 10, 10, 10, 10s, 100h, 1s, 1s, p, journal_));
-
-        // Rest of network has closed
-        BEAST_EXPECT(shouldCloseLedger(true, 10, 3, 5, 10s, 10s, 10s, 10s, p, journal_));
-
-        // No transactions means wait until end of internval
-        BEAST_EXPECT(!shouldCloseLedger(false, 10, 0, 0, 1s, 1s, 1s, 10s, p, journal_));
-        BEAST_EXPECT(shouldCloseLedger(false, 10, 0, 0, 1s, 10s, 1s, 10s, p, journal_));
-
-        // Enforce minimum ledger open time
-        BEAST_EXPECT(!shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 1s, 10s, p, journal_));
-
-        // Don't go too much faster than last time
-        BEAST_EXPECT(!shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 3s, 10s, p, journal_));
-
-        BEAST_EXPECT(shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 10s, 10s, p, journal_));
-    }
-
-    void
-    testCheckConsensus()
-    {
-        using namespace std::chrono_literals;
-        testcase("check consensus");
-
-        // Use default parameters
-        ConsensusParms const p{};
-
-        ///////////////
-        // Disputes still in doubt
-        //
-        // Not enough time has elapsed
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 2s, false, p, true, journal_));
-
-        // If not enough peers have proposed, ensure
-        // more time for proposals
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 4s, false, p, true, journal_));
-
-        // Enough time has elapsed and we all agree
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(10, 2, 2, 0, 3s, 10s, false, p, true, journal_));
-
-        // Enough time has elapsed and we don't yet agree
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(10, 2, 1, 0, 3s, 10s, false, p, true, journal_));
-
-        // Our peers have moved on
-        // Enough time has elapsed and we all agree
-        BEAST_EXPECT(
-            ConsensusState::MovedOn ==
-            checkConsensus(10, 2, 1, 8, 3s, 10s, false, p, true, journal_));
-
-        // If no peers, don't agree until time has passed.
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(0, 0, 0, 0, 3s, 10s, false, p, true, journal_));
-
-        // Agree if no peers and enough time has passed.
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(0, 0, 0, 0, 3s, 16s, false, p, true, journal_));
-
-        // Expire if too much time has passed without agreement
-        BEAST_EXPECT(
-            ConsensusState::Expired ==
-            checkConsensus(10, 8, 1, 0, 1s, 19s, false, p, true, journal_));
-
-        ///////////////
-        // Stalled
-        //
-        // Not enough time has elapsed
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 2s, true, p, true, journal_));
-
-        // If not enough peers have proposed, ensure
-        // more time for proposals
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 4s, true, p, true, journal_));
-
-        // Enough time has elapsed and we all agree
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(10, 2, 2, 0, 3s, 10s, true, p, true, journal_));
-
-        // Enough time has elapsed and we don't yet agree, but there's nothing
-        // left to dispute
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(10, 2, 1, 0, 3s, 10s, true, p, true, journal_));
-
-        // Our peers have moved on
-        // Enough time has elapsed and we all agree, nothing left to dispute
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(10, 2, 1, 8, 3s, 10s, true, p, true, journal_));
-
-        // If no peers, don't agree until time has passed.
-        BEAST_EXPECT(
-            ConsensusState::No == checkConsensus(0, 0, 0, 0, 3s, 10s, true, p, true, journal_));
-
-        // Agree if no peers and enough time has passed.
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(0, 0, 0, 0, 3s, 16s, true, p, true, journal_));
-
-        // We are done if there's nothing left to dispute, no matter how much
-        // time has passed
-        BEAST_EXPECT(
-            ConsensusState::Yes == checkConsensus(10, 8, 1, 0, 1s, 19s, true, p, true, journal_));
-    }
-
-    void
-    testStandalone()
-    {
-        using namespace std::chrono_literals;
-        using namespace csf;
-        testcase("standalone");
-
-        Sim s;
-        PeerGroup const peers = s.createGroup(1);
-        Peer* peer = peers[0];
-        peer->targetLedgers = 1;
-        peer->start();
-        peer->submit(Tx{1});
-
-        s.scheduler.step();
-
-        // Inspect that the proper ledger was created
-        auto const& lcl = peer->lastClosedLedger;
-        BEAST_EXPECT(peer->prevLedgerID() == lcl.id());
-        BEAST_EXPECT(lcl.seq() == Ledger::Seq{1});
-        BEAST_EXPECT(lcl.txs().size() == 1);
-        BEAST_EXPECT(lcl.txs().contains(Tx{1}));
-        BEAST_EXPECT(peer->prevProposers == 0);
-    }
-
-    void
-    testPeersAgree()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("peers agree");
-
-        ConsensusParms const parms{};
-        Sim sim;
-        PeerGroup peers = sim.createGroup(5);
-
-        // Connected trust and network graphs with single fixed delay
-        peers.trustAndConnect(peers, round(0.2 * parms.ledgerGRANULARITY));
-
-        // everyone submits their own ID as a TX
-        for (Peer* p : peers)
-            p->submit(Tx(static_cast(p->id)));
-
-        sim.run(1);
-
-        // All peers are in sync
-        if (BEAST_EXPECT(sim.synchronized()))
-        {
-            for (Peer const* peer : peers)
-            {
-                auto const& lcl = peer->lastClosedLedger;
-                BEAST_EXPECT(lcl.id() == peer->prevLedgerID());
-                BEAST_EXPECT(lcl.seq() == Ledger::Seq{1});
-                // All peers proposed
-                BEAST_EXPECT(peer->prevProposers == peers.size() - 1);
-                // All transactions were accepted
-                for (std::uint32_t i = 0; i < peers.size(); ++i)
-                    BEAST_EXPECT(lcl.txs().contains(Tx{i}));
-            }
-        }
-    }
-
-    void
-    testSlowPeers()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("slow peers");
-
-        // Several tests of a complete trust graph with a subset of peers
-        // that have significantly longer network delays to the rest of the
-        // network
-
-        // Test when a slow peer doesn't delay a consensus quorum (4/5 agree)
-        {
-            ConsensusParms const parms{};
-            Sim sim;
-            PeerGroup slow = sim.createGroup(1);
-            PeerGroup fast = sim.createGroup(4);
-            PeerGroup network = fast + slow;
-
-            // Fully connected trust graph
-            network.trust(network);
-
-            // Fast and slow network connections
-            fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
-
-            slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
-
-            // All peers submit their own ID as a transaction
-            for (Peer* peer : network)
-                peer->submit(Tx{static_cast(peer->id)});
-
-            sim.run(1);
-
-            // Verify all peers have same LCL but are missing transaction 0
-            // All peers are in sync even with a slower peer 0
-            if (BEAST_EXPECT(sim.synchronized()))
-            {
-                for (Peer const* peer : network)
-                {
-                    auto const& lcl = peer->lastClosedLedger;
-                    BEAST_EXPECT(lcl.id() == peer->prevLedgerID());
-                    BEAST_EXPECT(lcl.seq() == Ledger::Seq{1});
-
-                    BEAST_EXPECT(peer->prevProposers == network.size() - 1);
-                    BEAST_EXPECT(peer->prevRoundTime == network[0]->prevRoundTime);
-
-                    BEAST_EXPECT(not lcl.txs().contains(Tx{0}));
-                    for (std::uint32_t i = 2; i < network.size(); ++i)
-                        BEAST_EXPECT(lcl.txs().contains(Tx{i}));
-
-                    // Tx 0 didn't make it
-                    BEAST_EXPECT(peer->openTxs.contains(Tx{0}));
-                }
-            }
-        }
-
-        // Test when the slow peers delay a consensus quorum (4/6 agree)
-        {
-            // Run two tests
-            //  1. The slow peers are participating in consensus
-            //  2. The slow peers are just observing
-
-            for (auto isParticipant : {true, false})
-            {
-                ConsensusParms const parms{};
-
-                Sim sim;
-                PeerGroup slow = sim.createGroup(2);
-                PeerGroup fast = sim.createGroup(4);
-                PeerGroup network = fast + slow;
-
-                // Connected trust graph
-                network.trust(network);
-
-                // Fast and slow network connections
-                fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
-
-                slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
-
-                for (Peer* peer : slow)
-                    peer->runAsValidator = isParticipant;
-
-                // All peers submit their own ID as a transaction and relay it
-                // to peers
-                for (Peer* peer : network)
-                    peer->submit(Tx{static_cast(peer->id)});
-
-                sim.run(1);
-
-                if (BEAST_EXPECT(sim.synchronized()))
-                {
-                    // Verify all peers have same LCL but are missing
-                    // transaction 0,1 which was not received by all peers
-                    // before the ledger closed
-                    for (Peer const* peer : network)
-                    {
-                        // Closed ledger has all but transaction 0,1
-                        auto const& lcl = peer->lastClosedLedger;
-                        BEAST_EXPECT(lcl.seq() == Ledger::Seq{1});
-                        BEAST_EXPECT(not lcl.txs().contains(Tx{0}));
-                        BEAST_EXPECT(not lcl.txs().contains(Tx{1}));
-                        for (std::uint32_t i = slow.size(); i < network.size(); ++i)
-                            BEAST_EXPECT(lcl.txs().contains(Tx{i}));
-
-                        // Tx 0-1 didn't make it
-                        BEAST_EXPECT(peer->openTxs.contains(Tx{0}));
-                        BEAST_EXPECT(peer->openTxs.contains(Tx{1}));
-                    }
-
-                    Peer const* slowPeer = slow[0];
-                    if (isParticipant)
-                    {
-                        BEAST_EXPECT(slowPeer->prevProposers == network.size() - 1);
-                    }
-                    else
-                    {
-                        BEAST_EXPECT(slowPeer->prevProposers == fast.size());
-                    }
-
-                    for (Peer const* peer : fast)
-                    {
-                        // Due to the network link delay settings
-                        //    Peer 0 initially proposes {0}
-                        //    Peer 1 initially proposes {1}
-                        //    Peers 2-5 initially propose {2,3,4,5}
-                        // Since peers 2-5 agree, 4/6 > the initial 50% needed
-                        // to include a disputed transaction, so Peer 0/1 switch
-                        // to agree with those peers. Peer 0/1 then closes with
-                        // an 80% quorum of agreeing positions (5/6) match.
-                        //
-                        // Peers 2-5 do not change position, since tx 0 or tx 1
-                        // have less than the 50% initial threshold. They also
-                        // cannot declare consensus, since 4/6 agreeing
-                        // positions are < 80% threshold. They therefore need an
-                        // additional timerEntry call to see the updated
-                        // positions from Peer 0 & 1.
-
-                        if (isParticipant)
-                        {
-                            BEAST_EXPECT(peer->prevProposers == network.size() - 1);
-                            BEAST_EXPECT(peer->prevRoundTime > slowPeer->prevRoundTime);
-                        }
-                        else
-                        {
-                            BEAST_EXPECT(peer->prevProposers == fast.size() - 1);
-                            // so all peers should have closed together
-                            BEAST_EXPECT(peer->prevRoundTime == slowPeer->prevRoundTime);
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    void
-    testCloseTimeDisagree()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("close time disagree");
-
-        // This is a very specialized test to get ledgers to disagree on
-        // the close time. It unfortunately assumes knowledge about current
-        // timing constants. This is a necessary evil to get coverage up
-        // pending more extensive refactorings of timing constants.
-
-        // In order to agree-to-disagree on the close time, there must be no
-        // clear majority of nodes agreeing on a close time. This test
-        // sets a relative offset to the peers internal clocks so that they
-        // send proposals with differing times.
-
-        // However, agreement is on the effective close time, not the
-        // exact close time. The minimum closeTimeResolution is given by
-        // ledgerPossibleTimeResolutions[0], which is currently 10s. This means
-        // the skews need to be at least 10 seconds to have different effective
-        // close times.
-
-        // Complicating this matter is that nodes will ignore proposals
-        // with times more than proposeFRESHNESS =20s in the past. So at
-        // the minimum granularity, we have at most 3 types of skews
-        // (0s,10s,20s).
-
-        // This test therefore has 6 nodes, with 2 nodes having each type of
-        // skew. Then no majority (1/3 < 1/2) of nodes will agree on an
-        // actual close time.
-
-        ConsensusParms const parms{};
-        Sim sim;
-
-        PeerGroup groupA = sim.createGroup(2);
-        PeerGroup const groupB = sim.createGroup(2);
-        PeerGroup const groupC = sim.createGroup(2);
-        PeerGroup network = groupA + groupB + groupC;
-
-        network.trust(network);
-        network.connect(network, round(0.2 * parms.ledgerGRANULARITY));
-
-        // Run consensus without skew until we have a short close time
-        // resolution
-        Peer const* firstPeer = *groupA.begin();
-        while (firstPeer->lastClosedLedger.closeTimeResolution() >= parms.proposeFRESHNESS)
-            sim.run(1);
-
-        // Introduce a shift on the time of 2/3 of peers
-        for (Peer* peer : groupA)
-            peer->clockSkew = parms.proposeFRESHNESS / 2;
-        for (Peer* peer : groupB)
-            peer->clockSkew = parms.proposeFRESHNESS;
-
-        sim.run(1);
-
-        // All nodes agreed to disagree on the close time
-        if (BEAST_EXPECT(sim.synchronized()))
-        {
-            for (Peer const* peer : network)
-                BEAST_EXPECT(!peer->lastClosedLedger.closeAgree());
-        }
-    }
-
-    void
-    testWrongLCL()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("wrong LCL");
-
-        // Specialized test to exercise a temporary fork in which some peers
-        // are working on an incorrect prior ledger.
-
-        ConsensusParms const parms{};
-
-        // Vary the time it takes to process validations to exercise detecting
-        // the wrong LCL at different phases of consensus
-        for (auto validationDelay : {0ms, parms.ledgerMinClose})
-        {
-            // Consider 10 peers:
-            // 0 1         2 3 4       5 6 7 8 9
-            // minority   majorityA   majorityB
-            //
-            // Nodes 0-1 trust nodes 0-4
-            // Nodes 2-9 trust nodes 2-9
-            //
-            // By submitting tx 0 to nodes 0-4 and tx 1 to nodes 5-9,
-            // nodes 0-1 will generate the wrong LCL (with tx 0). The remaining
-            // nodes will instead accept the ledger with tx 1.
-
-            // Nodes 0-1 will detect this mismatch during a subsequent round
-            // since nodes 2-4 will validate a different ledger.
-
-            // Nodes 0-1 will acquire the proper ledger from the network and
-            // resume consensus and eventually generate the dominant network
-            // ledger.
-
-            // This topology can potentially fork with the above trust relations
-            // but that is intended for this test.
-
-            Sim sim;
-
-            PeerGroup minority = sim.createGroup(2);
-            PeerGroup const majorityA = sim.createGroup(3);
-            PeerGroup const majorityB = sim.createGroup(5);
-
-            PeerGroup majority = majorityA + majorityB;
-            PeerGroup const network = minority + majority;
-
-            SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-            minority.trustAndConnect(minority + majorityA, delay);
-            majority.trustAndConnect(majority, delay);
-
-            CollectByNode jumps;
-            sim.collectors.add(jumps);
-
-            BEAST_EXPECT(sim.trustGraph.canFork(parms.minConsensusPct / 100.));
-
-            // initial round to set prior state
-            sim.run(1);
-
-            // Nodes in smaller UNL have seen tx 0, nodes in other unl have seen
-            // tx 1
-            for (Peer* peer : network)
-                peer->delays.recvValidation = validationDelay;
-            for (Peer* peer : (minority + majorityA))
-                peer->openTxs.insert(Tx{0});
-            for (Peer* peer : majorityB)
-                peer->openTxs.insert(Tx{1});
-
-            // Run for additional rounds
-            // With no validation delay, only 2 more rounds are needed.
-            //  1. Round to generate different ledgers
-            //  2. Round to detect different prior ledgers (but still generate
-            //    wrong ones) and recover within that round since wrong LCL
-            //    is detected before we close
-            //
-            // With a validation delay of ledgerMIN_CLOSE, we need 3 more
-            // rounds.
-            //  1. Round to generate different ledgers
-            //  2. Round to detect different prior ledgers (but still generate
-            //     wrong ones) but end up declaring consensus on wrong LCL (but
-            //     with the right transaction set!). This is because we detect
-            //     the wrong LCL after we have closed the ledger, so we declare
-            //     consensus based solely on our peer proposals. But we haven't
-            //     had time to acquire the right ledger.
-            //  3. Round to correct
-            sim.run(3);
-
-            // The network never actually forks, since node 0-1 never see a
-            // quorum of validations to fully validate the incorrect chain.
-
-            // However, for a non zero-validation delay, the network is not
-            // synchronized because nodes 0 and 1 are running one ledger behind
-            if (BEAST_EXPECT(sim.branches() == 1))
-            {
-                for (Peer const* peer : majority)
-                {
-                    // No jumps for majority nodes
-                    BEAST_EXPECT(jumps[peer->id].closeJumps.empty());
-                    BEAST_EXPECT(jumps[peer->id].fullyValidatedJumps.empty());
-                }
-                for (Peer const* peer : minority)
-                {
-                    auto& peerJumps = jumps[peer->id];
-                    // last closed ledger jump between chains
-                    {
-                        if (BEAST_EXPECT(peerJumps.closeJumps.size() == 1))
-                        {
-                            JumpCollector::Jump const& jump = peerJumps.closeJumps.front();
-                            // Jump is to a different chain
-                            BEAST_EXPECT(jump.from.seq() <= jump.to.seq());
-                            BEAST_EXPECT(!jump.to.isAncestor(jump.from));
-                        }
-                    }
-                    // fully validated jump forward in same chain
-                    {
-                        if (BEAST_EXPECT(peerJumps.fullyValidatedJumps.size() == 1))
-                        {
-                            JumpCollector::Jump const& jump = peerJumps.fullyValidatedJumps.front();
-                            // Jump is to a different chain with same seq
-                            BEAST_EXPECT(jump.from.seq() < jump.to.seq());
-                            BEAST_EXPECT(jump.to.isAncestor(jump.from));
-                        }
-                    }
-                }
-            }
-        }
-
-        {
-            // Additional test engineered to switch LCL during the establish
-            // phase. This was added to trigger a scenario that previously
-            // crashed, in which switchLCL switched from establish to open
-            // phase, but still processed the establish phase logic.
-
-            // Loner node will accept an initial ledger A, but all other nodes
-            // accept ledger B a bit later. By delaying the time it takes
-            // to process a validation, loner node will detect the wrongLCL
-            // after it is already in the establish phase of the next round.
-
-            Sim sim;
-            PeerGroup loner = sim.createGroup(1);
-            PeerGroup const friends = sim.createGroup(3);
-            loner.trust(loner + friends);
-
-            PeerGroup const others = sim.createGroup(6);
-            PeerGroup clique = friends + others;
-            clique.trust(clique);
-
-            PeerGroup network = loner + clique;
-            network.connect(network, round(0.2 * parms.ledgerGRANULARITY));
-
-            // initial round to set prior state
-            sim.run(1);
-            for (Peer* peer : (loner + friends))
-                peer->openTxs.insert(Tx(0));
-            for (Peer* peer : others)
-                peer->openTxs.insert(Tx(1));
-
-            // Delay validation processing
-            for (Peer* peer : network)
-                peer->delays.recvValidation = parms.ledgerGRANULARITY;
-
-            // additional rounds to generate wrongLCL and recover
-            sim.run(2);
-
-            // Check all peers recovered
-            for (Peer const* p : network)
-                BEAST_EXPECT(p->prevLedgerID() == network[0]->prevLedgerID());
-        }
-    }
-
-    void
-    testConsensusCloseTimeRounding()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("consensus close time rounding");
-
-        // This is a specialized test engineered to yield ledgers with different
-        // close times even though the peers believe they had close time
-        // consensus on the ledger.
-        ConsensusParms const parms;
-
-        Sim sim;
-
-        // This requires a group of 4 fast and 2 slow peers to create a
-        // situation in which a subset of peers requires seeing additional
-        // proposals to declare consensus.
-        PeerGroup slow = sim.createGroup(2);
-        PeerGroup fast = sim.createGroup(4);
-        PeerGroup network = fast + slow;
-
-        // Connected trust graph
-        network.trust(network);
-
-        // Fast and slow network connections
-        fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
-        slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
-
-        // Run to the ledger *prior* to decreasing the resolution
-        sim.run(kIncreaseLedgerTimeResolutionEvery - 2);
-
-        // In order to create the discrepancy, we want a case where if
-        //   X = effCloseTime(closeTime, resolution, parentCloseTime)
-        //   X != effCloseTime(X, resolution, parentCloseTime)
-        //
-        // That is, the effective close time is not a fixed point. This can
-        // happen if X = parentCloseTime + 1, but a subsequent rounding goes
-        // to the next highest multiple of resolution.
-
-        // So we want to find an offset  (now + offset) % 30s = 15
-        //                               (now + offset) % 20s = 15
-        // This way, the next ledger will close and round up   Due to the
-        // network delay settings, the round of consensus will take 5s, so
-        // the next ledger's close time will
-
-        NetClock::duration when = network[0]->now().time_since_epoch();
-
-        // Check we are before the 30s to 20s transition
-        NetClock::duration const resolution = network[0]->lastClosedLedger.closeTimeResolution();
-        BEAST_EXPECT(resolution == NetClock::duration{30s});
-
-        while (((when % NetClock::duration{30s}) != NetClock::duration{15s}) ||
-               ((when % NetClock::duration{20s}) != NetClock::duration{15s}))
-            when += 1s;
-        // Advance the clock without consensus running (IS THIS WHAT
-        // PREVENTS IT IN PRACTICE?)
-        sim.scheduler.stepFor(NetClock::time_point{when} - network[0]->now());
-
-        // Run one more ledger with 30s resolution
-        sim.run(1);
-        if (BEAST_EXPECT(sim.synchronized()))
-        {
-            // close time should be ahead of clock time since we engineered
-            // the close time to round up
-            for (Peer const* peer : network)
-            {
-                BEAST_EXPECT(peer->lastClosedLedger.closeTime() > peer->now());
-                BEAST_EXPECT(peer->lastClosedLedger.closeAgree());
-            }
-        }
-
-        // All peers submit their own ID as a transaction
-        for (Peer* peer : network)
-            peer->submit(Tx{static_cast(peer->id)});
-
-        // Run 1 more round, this time it will have a decreased
-        // resolution of 20 seconds.
-
-        // The network delays are engineered so that the slow peers
-        // initially have the wrong tx hash, but they see a majority
-        // of agreement from their peers and declare consensus
-        //
-        // The trick is that everyone starts with a raw close time of
-        //  84681s
-        // Which has
-        //   effCloseTime(86481s, 20s,  86490s) = 86491s
-        // However, when the slow peers update their position, they change
-        // the close time to 86451s. The fast peers declare consensus with
-        // the 86481s as their position still.
-        //
-        // When accepted the ledger
-        // - fast peers use eff(86481s) -> 86491s as the close time
-        // - slow peers use eff(eff(86481s)) -> eff(86491s) -> 86500s!
-
-        sim.run(1);
-
-        BEAST_EXPECT(sim.synchronized());
-    }
-
-    void
-    testFork()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("fork");
-
-        std::uint32_t const numPeers = 10;
-        // Vary overlap between two UNLs
-        for (std::uint32_t overlap = 0; overlap <= numPeers; ++overlap)
-        {
-            ConsensusParms const parms{};
-            Sim sim;
-
-            std::uint32_t const numA = (numPeers - overlap) / 2;
-            std::uint32_t const numB = numPeers - numA - overlap;
-
-            PeerGroup const aOnly = sim.createGroup(numA);
-            PeerGroup const bOnly = sim.createGroup(numB);
-            PeerGroup const commonOnly = sim.createGroup(overlap);
-
-            PeerGroup a = aOnly + commonOnly;
-            PeerGroup b = bOnly + commonOnly;
-
-            PeerGroup const network = a + b;
-
-            SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-            a.trustAndConnect(a, delay);
-            b.trustAndConnect(b, delay);
-
-            // Initial round to set prior state
-            sim.run(1);
-            for (Peer* peer : network)
-            {
-                // Nodes have only seen transactions from their neighbors
-                peer->openTxs.insert(Tx{static_cast(peer->id)});
-                for (Peer const* to : sim.trustGraph.trustedPeers(peer))
-                    peer->openTxs.insert(Tx{static_cast(to->id)});
-            }
-            sim.run(1);
-
-            // Fork should not happen for 40% or greater overlap
-            // Since the overlapped nodes have a UNL that is the union of the
-            // two cliques, the maximum sized UNL list is the number of peers
-            if (overlap > 0.4 * numPeers)
-            {
-                BEAST_EXPECT(sim.synchronized());
-            }
-            else
-            {
-                // Even if we do fork, there shouldn't be more than 3 ledgers
-                // One for cliqueA, one for cliqueB and one for nodes in both
-                BEAST_EXPECT(sim.branches() <= 3);
-            }
-        }
-    }
-
-    void
-    testHubNetwork()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("hub network");
-
-        // Simulate a set of 5 validators that aren't directly connected but
-        // rely on a single hub node for communication
-
-        ConsensusParms const parms{};
-        Sim sim;
-        PeerGroup validators = sim.createGroup(5);
-        PeerGroup center = sim.createGroup(1);
-        validators.trust(validators);
-        center.trust(validators);
-
-        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-        validators.connect(center, delay);
-
-        center[0]->runAsValidator = false;
-
-        // prep round to set initial state.
-        sim.run(1);
-
-        // everyone submits their own ID as a TX and relay it to peers
-        for (Peer* p : validators)
-            p->submit(Tx(static_cast(p->id)));
-
-        sim.run(1);
-
-        // All peers are in sync
-        BEAST_EXPECT(sim.synchronized());
-    }
-
-    // Helper collector for testPreferredByBranch
-    // Invasively disconnects network at bad times to cause splits
-    struct Disruptor
-    {
-        csf::PeerGroup& network;
-        csf::PeerGroup& groupCfast;
-        csf::PeerGroup& groupCsplit;
-        csf::SimDuration delay;
-        bool reconnected = false;
-
-        Disruptor(csf::PeerGroup& net, csf::PeerGroup& c, csf::PeerGroup& split, csf::SimDuration d)
-            : network(net), groupCfast(c), groupCsplit(split), delay(d)
-        {
-        }
-
-        template 
-        void
-        on(csf::PeerID, csf::SimTime, E const&)
-        {
-        }
-
-        void
-        on(csf::PeerID who, csf::SimTime, csf::FullyValidateLedger const& e)
-        {
-            using namespace std::chrono;
-            // As soon as the fastC node fully validates C, disconnect
-            // ALL c nodes from the network. The fast C node needs to disconnect
-            // as well to prevent it from relaying the validations it did see
-            if (who == groupCfast[0]->id && e.ledger.seq() == csf::Ledger::Seq{2})
-            {
-                network.disconnect(groupCsplit);
-                network.disconnect(groupCfast);
-            }
-        }
-
-        void
-        on(csf::PeerID who, csf::SimTime, csf::AcceptLedger const& e)
-        {
-            // As soon as anyone generates a child of B or C, reconnect the
-            // network so those validations make it through
-            if (!reconnected && e.ledger.seq() == csf::Ledger::Seq{3})
-            {
-                reconnected = true;
-                network.connect(groupCsplit, delay);
-            }
-        }
-    };
-
-    void
-    testPreferredByBranch()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("preferred by branch");
-
-        // Simulate network splits that are prevented from forking when using
-        // preferred ledger by trie.  This is a contrived example that involves
-        // excessive network splits, but demonstrates the safety improvement
-        // from the preferred ledger by trie approach.
-
-        // Consider 10 validating nodes that comprise a single common UNL
-        // Ledger history:
-        // 1:           A
-        //            _/ \_
-        // 2:         B    C
-        //          _/  _/  \_
-        // 3:       D   C'  |||||||| (8 different ledgers)
-
-        // - All nodes generate the common ledger A
-        // - 2 nodes generate B and 8 nodes generate C
-        // - Only 1 of the C nodes sees all the C validations and fully
-        //   validates C. The rest of the C nodes split at just the right time
-        //   such that they never see any C validations but their own.
-        // - The C nodes continue and generate 8 different child ledgers.
-        // - Meanwhile, the D nodes only saw 1 validation for C and 2
-        // validations
-        //   for B.
-        // - The network reconnects and the validations for generation 3 ledgers
-        //   are observed (D and the 8 C's)
-        // - In the old approach, 2 votes for D outweighs 1 vote for each C'
-        //   so the network would avalanche towards D and fully validate it
-        //   EVEN though C was fully validated by one node
-        // - In the new approach, 2 votes for D are not enough to outweight the
-        //   8 implicit votes for C, so nodes will avalanche to C instead
-
-        ConsensusParms const parms{};
-        Sim sim;
-
-        // Goes A->B->D
-        PeerGroup const groupABD = sim.createGroup(2);
-        // Single node that initially fully validates C before the split
-        PeerGroup groupCfast = sim.createGroup(1);
-        // Generates C, but fails to fully validate before the split
-        PeerGroup groupCsplit = sim.createGroup(7);
-
-        PeerGroup groupNotFastC = groupABD + groupCsplit;
-        PeerGroup network = groupABD + groupCsplit + groupCfast;
-
-        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-        SimDuration const fDelay = round(0.1 * parms.ledgerGRANULARITY);
-
-        network.trust(network);
-        // C must have a shorter delay to see all the validations before the
-        // other nodes
-        network.connect(groupCfast, fDelay);
-        // The rest of the network is connected at the same speed
-        groupNotFastC.connect(groupNotFastC, delay);
-
-        Disruptor dc(network, groupCfast, groupCsplit, delay);
-        sim.collectors.add(dc);
-
-        // Consensus round to generate ledger A
-        sim.run(1);
-        BEAST_EXPECT(sim.synchronized());
-
-        // Next round generates B and C
-        // To force B, we inject an extra transaction in to those nodes
-        for (Peer* peer : groupABD)
-        {
-            peer->txInjections.emplace(peer->lastClosedLedger.seq(), Tx{42});
-        }
-        // The Disruptor will ensure that nodes disconnect before the C
-        // validations make it to all but the fastC node
-        sim.run(1);
-
-        // We are no longer in sync, but have not yet forked:
-        // 9 nodes consider A the last fully validated ledger and fastC sees C
-        BEAST_EXPECT(!sim.synchronized());
-        BEAST_EXPECT(sim.branches() == 1);
-
-        //  Run another round to generate the 8 different C' ledgers
-        for (Peer* p : network)
-            p->submit(Tx(static_cast(p->id)));
-        sim.run(1);
-
-        // Still not forked
-        BEAST_EXPECT(!sim.synchronized());
-        BEAST_EXPECT(sim.branches() == 1);
-
-        // Disruptor will reconnect all but the fastC node
-        sim.run(1);
-
-        if (BEAST_EXPECT(sim.branches() == 1))
-        {
-            BEAST_EXPECT(sim.synchronized());
-        }
-        else  // old approach caused a fork
-        {
-            BEAST_EXPECT(sim.branches(groupNotFastC) == 1);
-            BEAST_EXPECT(sim.synchronized(groupNotFastC) == 1);
-        }
-    }
-
-    // Helper collector for testPauseForLaggards
-    // This will remove the ledgerAccept delay used to
-    // initially create the slow vs. fast validator groups.
-    struct UndoDelay
-    {
-        csf::PeerGroup& g;
-
-        UndoDelay(csf::PeerGroup& a) : g(a)
-        {
-        }
-
-        template 
-        void
-        on(csf::PeerID, csf::SimTime, E const&)
-        {
-        }
-
-        void
-        on(csf::PeerID who, csf::SimTime, csf::AcceptLedger const& e)
-        {
-            for (csf::Peer* p : g)
-            {
-                if (p->id == who)
-                    p->delays.ledgerAccept = std::chrono::seconds{0};
-            }
-        }
-    };
-
-    void
-    testPauseForLaggards()
-    {
-        using namespace csf;
-        using namespace std::chrono;
-        testcase("pause for laggards");
-
-        // Test that validators that jump ahead of the network slow
-        // down.
-
-        // We engineer the following validated ledger history scenario:
-        //
-        //  / --> B1 --> C1 --> ... -> G1  "ahead"
-        // A
-        //  \ --> B2 --> C2 "behind"
-        //
-        // After validating a common ledger A, a set of "behind" validators
-        // briefly run slower and validate the lower chain of ledgers.
-        // The "ahead" validators run normal speed and run ahead validating the
-        // upper chain of ledgers.
-        //
-        // Due to the uncommitted support definition of the preferred branch
-        // protocol, even if the "behind" validators are a majority, the "ahead"
-        // validators cannot jump to the proper branch until the "behind"
-        // validators catch up to the same sequence number. For this test to
-        // succeed, the ahead validators need to briefly slow down consensus.
-
-        ConsensusParms const parms{};
-        Sim sim;
-        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
-
-        PeerGroup behind = sim.createGroup(3);
-        PeerGroup const ahead = sim.createGroup(2);
-        PeerGroup network = ahead + behind;
-
-        hash_set trustedKeys;
-        for (Peer const* p : network)
-            trustedKeys.insert(p->key);
-        for (Peer* p : network)
-            p->trustedKeys = trustedKeys;
-
-        network.trustAndConnect(network, delay);
-
-        // Initial seed round to set prior state
-        sim.run(1);
-
-        // Have the "behind" group initially take a really long time to
-        // accept a ledger after ending deliberation
-        for (Peer* p : behind)
-            p->delays.ledgerAccept = 20s;
-
-        // Use the collector to revert the delay after the single
-        // slow ledger is generated
-        UndoDelay undoDelay{behind};
-        sim.collectors.add(undoDelay);
-        // Run the simulation for 100 seconds of simulation time with
-        std::chrono::nanoseconds const simDuration = 100s;
-
-        // Simulate clients submitting 1 tx every 5 seconds to a random
-        // validator
-        Rate const rate{.count = 1, .duration = 5s};
-        auto peerSelector = makeSelector(
-            network.begin(), network.end(), std::vector(network.size(), 1.), sim.rng);
-        auto txSubmitter = makeSubmitter(
-            ConstantDistribution{rate.inv()},
-            sim.scheduler.now(),
-            sim.scheduler.now() + simDuration,
-            peerSelector,
-            sim.scheduler,
-            sim.rng);
-
-        // Run simulation
-        sim.run(simDuration);
-
-        // Verify that the network recovered
-        BEAST_EXPECT(sim.synchronized());
-    }
-
-    void
-    testDisputes()
-    {
-        testcase("disputes");
-
-        using namespace csf;
-
-        // Test dispute objects directly
-        using Dispute = DisputedTx;
-
-        Tx const txTrue{99};
-        Tx const txFalse{98};
-        Tx const txFollowingTrue{97};
-        Tx const txFollowingFalse{96};
-        int const numPeers = 100;
-        ConsensusParms const p;
-        std::size_t peersUnchanged = 0;
-
-        auto logs = std::make_unique(beast::Severity::Error);
-        auto j = logs->journal("Test");
-        auto clog = std::make_unique();
-
-        // Three cases:
-        // 1 proposing, initial vote yes
-        // 2 proposing, initial vote no
-        // 3 not proposing, initial vote doesn't matter after the first update,
-        // use yes
-        {
-            Dispute proposingTrue{txTrue.id(), true, numPeers, journal_};
-            Dispute proposingFalse{txFalse.id(), false, numPeers, journal_};
-            Dispute followingTrue{txFollowingTrue.id(), true, numPeers, journal_};
-            Dispute followingFalse{txFollowingFalse.id(), false, numPeers, journal_};
-            BEAST_EXPECT(proposingTrue.id() == 99);
-            BEAST_EXPECT(proposingFalse.id() == 98);
-            BEAST_EXPECT(followingTrue.id() == 97);
-            BEAST_EXPECT(followingFalse.id() == 96);
-
-            // Create an even split in the peer votes
-            for (int i = 0; i < numPeers; ++i)
-            {
-                BEAST_EXPECT(proposingTrue.setVote(PeerID(i), i < 50));
-                BEAST_EXPECT(proposingFalse.setVote(PeerID(i), i < 50));
-                BEAST_EXPECT(followingTrue.setVote(PeerID(i), i < 50));
-                BEAST_EXPECT(followingFalse.setVote(PeerID(i), i < 50));
-            }
-            // Switch the middle vote to match mine
-            BEAST_EXPECT(proposingTrue.setVote(PeerID(50), true));
-            BEAST_EXPECT(proposingFalse.setVote(PeerID(49), false));
-            BEAST_EXPECT(followingTrue.setVote(PeerID(50), true));
-            BEAST_EXPECT(followingFalse.setVote(PeerID(49), false));
-
-            // no changes yet
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-            BEAST_EXPECT(!proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingTrue.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingFalse.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(clog->str().empty());
-
-            // I'm in the majority, my vote should not change
-            BEAST_EXPECT(!proposingTrue.updateVote(5, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(5, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(5, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(5, false, p));
-
-            BEAST_EXPECT(!proposingTrue.updateVote(10, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(10, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(10, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(10, false, p));
-
-            peersUnchanged = 2;
-            BEAST_EXPECT(!proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingTrue.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingFalse.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(clog->str().empty());
-
-            // Right now, the vote is 51%. The requirement is about to jump to
-            // 65%
-            BEAST_EXPECT(proposingTrue.updateVote(55, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(55, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(55, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(55, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == false);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-            // 16 validators change their vote to match my original vote
-            for (int i = 0; i < 16; ++i)
-            {
-                auto pTrue = PeerID(numPeers - i - 1);
-                auto pFalse = PeerID(i);
-                BEAST_EXPECT(proposingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(proposingFalse.setVote(pFalse, false));
-                BEAST_EXPECT(followingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(followingFalse.setVote(pFalse, false));
-            }
-            // The vote should now be 66%, threshold is 65%
-            BEAST_EXPECT(proposingTrue.updateVote(60, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(60, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(60, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(60, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            // Threshold jumps to 70%
-            BEAST_EXPECT(proposingTrue.updateVote(86, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(86, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(86, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(86, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == false);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            // 5 more validators change their vote to match my original vote
-            for (int i = 16; i < 21; ++i)
-            {
-                auto pTrue = PeerID(numPeers - i - 1);
-                auto pFalse = PeerID(i);
-                BEAST_EXPECT(proposingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(proposingFalse.setVote(pFalse, false));
-                BEAST_EXPECT(followingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(followingFalse.setVote(pFalse, false));
-            }
-
-            // The vote should now be 71%, threshold is 70%
-            BEAST_EXPECT(proposingTrue.updateVote(90, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(90, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(90, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(90, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            // The vote should now be 71%, threshold is 70%
-            BEAST_EXPECT(!proposingTrue.updateVote(150, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(150, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(150, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(150, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            // The vote should now be 71%, threshold is 70%
-            BEAST_EXPECT(!proposingTrue.updateVote(190, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(190, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(190, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(190, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            peersUnchanged = 3;
-            BEAST_EXPECT(!proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingTrue.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(!followingFalse.stalled(p, false, peersUnchanged, j, clog));
-            BEAST_EXPECT(clog->str().empty());
-
-            // Threshold jumps to 95%
-            BEAST_EXPECT(proposingTrue.updateVote(220, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(220, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(220, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(220, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == false);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            // 25 more validators change their vote to match my original vote
-            for (int i = 21; i < 46; ++i)
-            {
-                auto pTrue = PeerID(numPeers - i - 1);
-                auto pFalse = PeerID(i);
-                BEAST_EXPECT(proposingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(proposingFalse.setVote(pFalse, false));
-                BEAST_EXPECT(followingTrue.setVote(pTrue, true));
-                BEAST_EXPECT(followingFalse.setVote(pFalse, false));
-            }
-
-            // The vote should now be 96%, threshold is 95%
-            BEAST_EXPECT(proposingTrue.updateVote(250, true, p));
-            BEAST_EXPECT(!proposingFalse.updateVote(250, true, p));
-            BEAST_EXPECT(!followingTrue.updateVote(250, false, p));
-            BEAST_EXPECT(!followingFalse.updateVote(250, false, p));
-
-            BEAST_EXPECT(proposingTrue.getOurVote() == true);
-            BEAST_EXPECT(proposingFalse.getOurVote() == false);
-            BEAST_EXPECT(followingTrue.getOurVote() == true);
-            BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-            for (peersUnchanged = 0; peersUnchanged < 6; ++peersUnchanged)
-            {
-                BEAST_EXPECT(!proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-                BEAST_EXPECT(!proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-                BEAST_EXPECT(!followingTrue.stalled(p, false, peersUnchanged, j, clog));
-                BEAST_EXPECT(!followingFalse.stalled(p, false, peersUnchanged, j, clog));
-                BEAST_EXPECT(clog->str().empty());
-            }
-
-            auto expectStalled = [this, &clog](
-                                     int txid,
-                                     bool ourVote,
-                                     int ourTime,
-                                     int peerTime,
-                                     int support,
-                                     std::uint32_t line) {
-                using namespace std::string_literals;
-
-                auto const s = clog->str();
-                expect(s.find("stalled"), s, __FILE__, line);
-                expect(s.starts_with("Transaction "s + std::to_string(txid)), s, __FILE__, line);
-                expect(s.contains("voting "s + (ourVote ? "YES" : "NO")), s, __FILE__, line);
-                expect(
-                    s.contains("for "s + std::to_string(ourTime) + " rounds."s), s, __FILE__, line);
-                expect(
-                    s.contains("votes in "s + std::to_string(peerTime) + " rounds."),
-                    s,
-                    __FILE__,
-                    line);
-                expect(
-                    s.ends_with("has "s + std::to_string(support) + "% support. "s),
-                    s,
-                    __FILE__,
-                    line);
-                clog = std::make_unique();
-            };
-
-            for (int i = 0; i < 1; ++i)
-            {
-                BEAST_EXPECT(!proposingTrue.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!proposingFalse.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!followingTrue.updateVote(250 + (10 * i), false, p));
-                BEAST_EXPECT(!followingFalse.updateVote(250 + (10 * i), false, p));
-
-                BEAST_EXPECT(proposingTrue.getOurVote() == true);
-                BEAST_EXPECT(proposingFalse.getOurVote() == false);
-                BEAST_EXPECT(followingTrue.getOurVote() == true);
-                BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-                // true vote has changed recently, so not stalled
-                BEAST_EXPECT(!proposingTrue.stalled(p, true, 0, j, clog));
-                BEAST_EXPECT(clog->str().empty());
-                // remaining votes have been unchanged in so long that we only
-                // need to hit the second round at 95% to be stalled, regardless
-                // of peers
-                BEAST_EXPECT(proposingFalse.stalled(p, true, 0, j, clog));
-                expectStalled(98, false, 11, 0, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, 0, j, clog));
-                expectStalled(97, true, 11, 0, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, 0, j, clog));
-                expectStalled(96, false, 11, 0, 3, __LINE__);
-
-                // true vote has changed recently, so not stalled
-                BEAST_EXPECT(!proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-                BEAST_EXPECTS(clog->str().empty(), clog->str());
-                // remaining votes have been unchanged in so long that we only
-                // need to hit the second round at 95% to be stalled, regardless
-                // of peers
-                BEAST_EXPECT(proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-                expectStalled(98, false, 11, 6, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(97, true, 11, 6, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(96, false, 11, 6, 3, __LINE__);
-            }
-            for (int i = 1; i < 3; ++i)
-            {
-                BEAST_EXPECT(!proposingTrue.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!proposingFalse.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!followingTrue.updateVote(250 + (10 * i), false, p));
-                BEAST_EXPECT(!followingFalse.updateVote(250 + (10 * i), false, p));
-
-                BEAST_EXPECT(proposingTrue.getOurVote() == true);
-                BEAST_EXPECT(proposingFalse.getOurVote() == false);
-                BEAST_EXPECT(followingTrue.getOurVote() == true);
-                BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-                // true vote changed 2 rounds ago, and peers are changing, so
-                // not stalled
-                BEAST_EXPECT(!proposingTrue.stalled(p, true, 0, j, clog));
-                BEAST_EXPECTS(clog->str().empty(), clog->str());
-                // still stalled
-                BEAST_EXPECT(proposingFalse.stalled(p, true, 0, j, clog));
-                expectStalled(98, false, 11 + i, 0, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, 0, j, clog));
-                expectStalled(97, true, 11 + i, 0, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, 0, j, clog));
-                expectStalled(96, false, 11 + i, 0, 3, __LINE__);
-
-                // true vote changed 2 rounds ago, and peers are NOT changing,
-                // so stalled
-                BEAST_EXPECT(proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-                expectStalled(99, true, 1 + i, 6, 97, __LINE__);
-                // still stalled
-                BEAST_EXPECT(proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-                expectStalled(98, false, 11 + i, 6, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(97, true, 11 + i, 6, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(96, false, 11 + i, 6, 3, __LINE__);
-            }
-            for (int i = 3; i < 5; ++i)
-            {
-                BEAST_EXPECT(!proposingTrue.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!proposingFalse.updateVote(250 + (10 * i), true, p));
-                BEAST_EXPECT(!followingTrue.updateVote(250 + (10 * i), false, p));
-                BEAST_EXPECT(!followingFalse.updateVote(250 + (10 * i), false, p));
-
-                BEAST_EXPECT(proposingTrue.getOurVote() == true);
-                BEAST_EXPECT(proposingFalse.getOurVote() == false);
-                BEAST_EXPECT(followingTrue.getOurVote() == true);
-                BEAST_EXPECT(followingFalse.getOurVote() == false);
-
-                BEAST_EXPECT(proposingTrue.stalled(p, true, 0, j, clog));
-                expectStalled(99, true, 1 + i, 0, 97, __LINE__);
-                BEAST_EXPECT(proposingFalse.stalled(p, true, 0, j, clog));
-                expectStalled(98, false, 11 + i, 0, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, 0, j, clog));
-                expectStalled(97, true, 11 + i, 0, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, 0, j, clog));
-                expectStalled(96, false, 11 + i, 0, 3, __LINE__);
-
-                BEAST_EXPECT(proposingTrue.stalled(p, true, peersUnchanged, j, clog));
-                expectStalled(99, true, 1 + i, 6, 97, __LINE__);
-                BEAST_EXPECT(proposingFalse.stalled(p, true, peersUnchanged, j, clog));
-                expectStalled(98, false, 11 + i, 6, 2, __LINE__);
-                BEAST_EXPECT(followingTrue.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(97, true, 11 + i, 6, 97, __LINE__);
-                BEAST_EXPECT(followingFalse.stalled(p, false, peersUnchanged, j, clog));
-                expectStalled(96, false, 11 + i, 6, 3, __LINE__);
-            }
-        }
-    }
-
-    void
-    run() override
-    {
-        testShouldCloseLedger();
-        testCheckConsensus();
-
-        testStandalone();
-        testPeersAgree();
-        testSlowPeers();
-        testCloseTimeDisagree();
-        testWrongLCL();
-        testConsensusCloseTimeRounding();
-        testFork();
-        testHubNetwork();
-        testPreferredByBranch();
-        testPauseForLaggards();
-        testDisputes();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Consensus, consensus, xrpl);
-}  // namespace xrpl::test
diff --git a/src/test/consensus/DistributedValidatorsSim_test.cpp b/src/test/consensus/DistributedValidatorsSim_test.cpp
deleted file mode 100644
index 437ad81ee0..0000000000
--- a/src/test/consensus/DistributedValidatorsSim_test.cpp
+++ /dev/null
@@ -1,253 +0,0 @@
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-/**
- * In progress simulations for diversifying and distributing validators
- */
-class DistributedValidators_test : public beast::unit_test::Suite
-{
-    void
-    completeTrustCompleteConnectFixedDelay(
-        std::size_t numPeers,
-        std::chrono::milliseconds delay = std::chrono::milliseconds(200),
-        bool printHeaders = false)
-    {
-        using namespace csf;
-        using namespace std::chrono;
-
-        // Initialize persistent collector logs specific to this method
-        std::string const prefix =
-            "DistributedValidators_"
-            "completeTrustCompleteConnectFixedDelay";
-        std::fstream txLog(prefix + "_tx.csv", std::ofstream::app),
-            ledgerLog(prefix + "_ledger.csv", std::ofstream::app);
-
-        // title
-        log << prefix << "(" << numPeers << "," << delay.count() << ")" << std::endl;
-
-        // number of peers, UNLs, connections
-        BEAST_EXPECT(numPeers >= 1);
-
-        Sim sim;
-        PeerGroup peers = sim.createGroup(numPeers);
-
-        // complete trust graph
-        peers.trust(peers);
-
-        // complete connect graph with fixed delay
-        peers.connect(peers, delay);
-
-        // Initialize collectors to track statistics to report
-        TxCollector txCollector;
-        LedgerCollector ledgerCollector;
-        auto colls = makeCollectors(txCollector, ledgerCollector);
-        sim.collectors.add(colls);
-
-        // Initial round to set prior state
-        sim.run(1);
-
-        // Run for 10 minutes, submitting 100 tx/second
-        std::chrono::nanoseconds const simDuration = 10min;
-        std::chrono::nanoseconds const quiet = 10s;
-        Rate const rate{.count = 100, .duration = 1000ms};
-
-        // Initialize timers
-        HeartbeatTimer heart(sim.scheduler);
-
-        // txs, start/stop/step, target
-        auto peerSelector =
-            makeSelector(peers.begin(), peers.end(), std::vector(numPeers, 1.), sim.rng);
-        auto txSubmitter = makeSubmitter(
-            ConstantDistribution{rate.inv()},
-            sim.scheduler.now() + quiet,
-            sim.scheduler.now() + simDuration - quiet,
-            peerSelector,
-            sim.scheduler,
-            sim.rng);
-
-        // run simulation for given duration
-        heart.start();
-        sim.run(simDuration);
-
-        // BEAST_EXPECT(sim.branches() == 1);
-        // BEAST_EXPECT(sim.synchronized());
-
-        log << std::right;
-        log << "| Peers: " << std::setw(2) << peers.size();
-        log << " | Duration: " << std::setw(6) << duration_cast(simDuration).count()
-            << " ms";
-        log << " | Branches: " << std::setw(1) << sim.branches();
-        log << " | Synchronized: " << std::setw(1) << (sim.synchronized() ? "Y" : "N");
-        log << " |" << std::endl;
-
-        txCollector.report(simDuration, log, true);
-        ledgerCollector.report(simDuration, log, false);
-
-        std::string const tag = std::to_string(numPeers);
-        txCollector.csv(simDuration, txLog, tag, printHeaders);
-        ledgerCollector.csv(simDuration, ledgerLog, tag, printHeaders);
-
-        log << std::endl;
-    }
-
-    void
-    completeTrustScaleFreeConnectFixedDelay(
-        std::size_t numPeers,
-        std::chrono::milliseconds delay = std::chrono::milliseconds(200),
-        bool printHeaders = false)
-    {
-        using namespace csf;
-        using namespace std::chrono;
-
-        // Initialize persistent collector logs specific to this method
-        std::string const prefix =
-            "DistributedValidators__"
-            "completeTrustScaleFreeConnectFixedDelay";
-        std::fstream txLog(prefix + "_tx.csv", std::ofstream::app),
-            ledgerLog(prefix + "_ledger.csv", std::ofstream::app);
-
-        // title
-        log << prefix << "(" << numPeers << "," << delay.count() << ")" << std::endl;
-
-        // number of peers, UNLs, connections
-        int const numCNLs = std::max(int(1.00 * numPeers), 1);
-        int const minCNLSize = std::max(int(0.25 * numCNLs), 1);
-        int const maxCNLSize = std::max(int(0.50 * numCNLs), 1);
-        BEAST_EXPECT(numPeers >= 1);
-        BEAST_EXPECT(numCNLs >= 1);
-        BEAST_EXPECT(1 <= minCNLSize && minCNLSize <= maxCNLSize && maxCNLSize <= numPeers);
-
-        Sim sim;
-        PeerGroup peers = sim.createGroup(numPeers);
-
-        // complete trust graph
-        peers.trust(peers);
-
-        // scale-free connect graph with fixed delay
-        std::vector const ranks = sample(peers.size(), PowerLawDistribution{1, 3}, sim.rng);
-        randomRankedConnect(
-            peers,
-            ranks,
-            numCNLs,
-            std::uniform_int_distribution<>{minCNLSize, maxCNLSize},
-            sim.rng,
-            delay);
-
-        // Initialize collectors to track statistics to report
-        TxCollector txCollector;
-        LedgerCollector ledgerCollector;
-        auto colls = makeCollectors(txCollector, ledgerCollector);
-        sim.collectors.add(colls);
-
-        // Initial round to set prior state
-        sim.run(1);
-
-        // Run for 10 minutes, submitting 100 tx/second
-        std::chrono::nanoseconds const simDuration = 10min;
-        std::chrono::nanoseconds const quiet = 10s;
-        Rate const rate{.count = 100, .duration = 1000ms};
-
-        // Initialize timers
-        HeartbeatTimer heart(sim.scheduler);
-
-        // txs, start/stop/step, target
-        auto peerSelector =
-            makeSelector(peers.begin(), peers.end(), std::vector(numPeers, 1.), sim.rng);
-        auto txSubmitter = makeSubmitter(
-            ConstantDistribution{rate.inv()},
-            sim.scheduler.now() + quiet,
-            sim.scheduler.now() + simDuration - quiet,
-            peerSelector,
-            sim.scheduler,
-            sim.rng);
-
-        // run simulation for given duration
-        heart.start();
-        sim.run(simDuration);
-
-        // BEAST_EXPECT(sim.branches() == 1);
-        // BEAST_EXPECT(sim.synchronized());
-
-        log << std::right;
-        log << "| Peers: " << std::setw(2) << peers.size();
-        log << " | Duration: " << std::setw(6) << duration_cast(simDuration).count()
-            << " ms";
-        log << " | Branches: " << std::setw(1) << sim.branches();
-        log << " | Synchronized: " << std::setw(1) << (sim.synchronized() ? "Y" : "N");
-        log << " |" << std::endl;
-
-        txCollector.report(simDuration, log, true);
-        ledgerCollector.report(simDuration, log, false);
-
-        std::string const tag = std::to_string(numPeers);
-        txCollector.csv(simDuration, txLog, tag, printHeaders);
-        ledgerCollector.csv(simDuration, ledgerLog, tag, printHeaders);
-
-        log << std::endl;
-    }
-
-    void
-    run() override
-    {
-        std::string const defaultArgs = "5 200";
-        std::string const args = arg().empty() ? defaultArgs : arg();
-        std::stringstream argStream(args);
-
-        int maxNumValidators = 0;
-        int delayCount(200);
-        argStream >> maxNumValidators;
-        argStream >> delayCount;
-
-        std::chrono::milliseconds const delay(delayCount);
-
-        log << "DistributedValidators: 1 to " << maxNumValidators << " Peers" << std::endl;
-
-        /**
-         * Simulate with N = 1 to N
-         * - complete trust graph is complete
-         * - complete network connectivity
-         * - fixed delay for network links
-         */
-        completeTrustCompleteConnectFixedDelay(1, delay, true);
-        for (int i = 2; i <= maxNumValidators; i++)
-        {
-            completeTrustCompleteConnectFixedDelay(i, delay);
-        }
-
-        /**
-         * Simulate with N = 1 to N
-         * - complete trust graph is complete
-         * - scale-free network connectivity
-         * - fixed delay for network links
-         */
-        completeTrustScaleFreeConnectFixedDelay(1, delay, true);
-        for (int i = 2; i <= maxNumValidators; i++)
-        {
-            completeTrustScaleFreeConnectFixedDelay(i, delay);
-        }
-    }
-};
-
-BEAST_DEFINE_TESTSUITE_MANUAL_PRIO(DistributedValidators, consensus, xrpl, 2);
-
-}  // namespace xrpl::test
diff --git a/src/test/consensus/LedgerTiming_test.cpp b/src/test/consensus/LedgerTiming_test.cpp
deleted file mode 100644
index 632361c799..0000000000
--- a/src/test/consensus/LedgerTiming_test.cpp
+++ /dev/null
@@ -1,118 +0,0 @@
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class LedgerTiming_test : public beast::unit_test::Suite
-{
-    void
-    testGetNextLedgerTimeResolution()
-    {
-        // helper to iteratively call into getNextLedgerTimeResolution
-        struct TestRes
-        {
-            std::uint32_t decrease = 0;
-            std::uint32_t equal = 0;
-            std::uint32_t increase = 0;
-
-            static TestRes
-            run(bool previousAgree, std::uint32_t rounds)
-            {
-                TestRes res;
-                auto closeResolution = kLedgerDefaultTimeResolution;
-                auto nextCloseResolution = closeResolution;
-                std::uint32_t round = 0;
-                do
-                {
-                    nextCloseResolution =
-                        getNextLedgerTimeResolution(closeResolution, previousAgree, ++round);
-                    if (nextCloseResolution < closeResolution)
-                    {
-                        ++res.decrease;
-                    }
-                    else if (nextCloseResolution > closeResolution)
-                    {
-                        ++res.increase;
-                    }
-                    else
-                    {
-                        ++res.equal;
-                    }
-                    std::swap(nextCloseResolution, closeResolution);
-                } while (round < rounds);
-                return res;
-            }
-        };
-
-        // If we never agree on close time, only can increase resolution
-        // until hit the max
-        auto decreases = TestRes::run(false, 10);
-        BEAST_EXPECT(decreases.increase == 3);
-        BEAST_EXPECT(decreases.decrease == 0);
-        BEAST_EXPECT(decreases.equal == 7);
-
-        // If we always agree on close time, only can decrease resolution
-        // until hit the min
-        auto increases = TestRes::run(false, 100);
-        BEAST_EXPECT(increases.increase == 3);
-        BEAST_EXPECT(increases.decrease == 0);
-        BEAST_EXPECT(increases.equal == 97);
-    }
-
-    void
-    testRoundCloseTime()
-    {
-        using namespace std::chrono_literals;
-        // A closeTime equal to the epoch is not modified
-        using tp = NetClock::time_point;
-        tp const def;
-        BEAST_EXPECT(def == roundCloseTime(def, 30s));
-
-        // Otherwise, the closeTime is rounded to the nearest
-        // rounding up on ties
-        BEAST_EXPECT(tp{0s} == roundCloseTime(tp{29s}, 60s));
-        BEAST_EXPECT(tp{30s} == roundCloseTime(tp{30s}, 1s));
-        BEAST_EXPECT(tp{60s} == roundCloseTime(tp{31s}, 60s));
-        BEAST_EXPECT(tp{60s} == roundCloseTime(tp{30s}, 60s));
-        BEAST_EXPECT(tp{60s} == roundCloseTime(tp{59s}, 60s));
-        BEAST_EXPECT(tp{60s} == roundCloseTime(tp{60s}, 60s));
-        BEAST_EXPECT(tp{60s} == roundCloseTime(tp{61s}, 60s));
-    }
-
-    void
-    testEffCloseTime()
-    {
-        using namespace std::chrono_literals;
-        using tp = NetClock::time_point;
-        tp close = effCloseTime(tp{10s}, 30s, tp{0s});
-        BEAST_EXPECT(close == tp{1s});
-
-        close = effCloseTime(tp{16s}, 30s, tp{0s});
-        BEAST_EXPECT(close == tp{30s});
-
-        close = effCloseTime(tp{16s}, 30s, tp{30s});
-        BEAST_EXPECT(close == tp{31s});
-
-        close = effCloseTime(tp{16s}, 30s, tp{60s});
-        BEAST_EXPECT(close == tp{61s});
-
-        close = effCloseTime(tp{31s}, 30s, tp{0s});
-        BEAST_EXPECT(close == tp{30s});
-    }
-
-    void
-    run() override
-    {
-        testGetNextLedgerTimeResolution();
-        testRoundCloseTime();
-        testEffCloseTime();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(LedgerTiming, consensus, xrpl);
-}  // namespace xrpl::test
diff --git a/src/test/consensus/LedgerTrie_test.cpp b/src/test/consensus/LedgerTrie_test.cpp
deleted file mode 100644
index a4eb7bc087..0000000000
--- a/src/test/consensus/LedgerTrie_test.cpp
+++ /dev/null
@@ -1,716 +0,0 @@
-#include 
-
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class LedgerTrie_test : public beast::unit_test::Suite
-{
-    void
-    testInsert()
-    {
-        using namespace csf;
-        // Single entry by itself
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 1);
-
-            t.insert(h["abc"]);
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-        }
-        // Suffix of existing (extending tree)
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            BEAST_EXPECT(t.checkInvariants());
-            // extend with no siblings
-            t.insert(h["abcd"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 1);
-
-            // extend with existing sibling
-            t.insert(h["abce"]);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 3);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abce"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abce"]) == 1);
-        }
-        // uncommitted of existing node
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abcd"]);
-            BEAST_EXPECT(t.checkInvariants());
-            // uncommitted with no siblings
-            t.insert(h["abcdf"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcdf"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcdf"]) == 1);
-
-            // uncommitted with existing child
-            t.insert(h["abc"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 3);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcdf"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcdf"]) == 1);
-        }
-        // Suffix + uncommitted of existing node
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abcd"]);
-            BEAST_EXPECT(t.checkInvariants());
-            t.insert(h["abce"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abce"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abce"]) == 1);
-        }
-        // Suffix + uncommitted with existing child
-        {
-            //  abcd : abcde, abcf
-
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abcd"]);
-            BEAST_EXPECT(t.checkInvariants());
-            t.insert(h["abcde"]);
-            BEAST_EXPECT(t.checkInvariants());
-            t.insert(h["abcf"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 3);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcf"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcf"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abcde"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcde"]) == 1);
-        }
-
-        // Multiple counts
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"], 4);
-            BEAST_EXPECT(t.tipSupport(h["ab"]) == 4);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 4);
-            BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["a"]) == 4);
-
-            t.insert(h["abc"], 2);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["ab"]) == 4);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 6);
-            BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["a"]) == 6);
-        }
-    }
-
-    void
-    testRemove()
-    {
-        using namespace csf;
-        // Not in trie
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-
-            BEAST_EXPECT(!t.remove(h["ab"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(!t.remove(h["a"]));
-            BEAST_EXPECT(t.checkInvariants());
-        }
-        // In trie but with 0 tip support
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abcd"]);
-            t.insert(h["abce"]);
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-            BEAST_EXPECT(!t.remove(h["abc"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-        }
-        // In trie with > 1 tip support
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"], 2);
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.remove(h["abc"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-
-            t.insert(h["abc"], 1);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.remove(h["abc"], 2));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-
-            t.insert(h["abc"], 3);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 3);
-            BEAST_EXPECT(t.remove(h["abc"], 300));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-        }
-        // In trie with = 1 tip support, no children
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"]);
-            t.insert(h["abc"]);
-
-            BEAST_EXPECT(t.tipSupport(h["ab"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 1);
-
-            BEAST_EXPECT(t.remove(h["abc"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["ab"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 0);
-        }
-        // In trie with = 1 tip support, 1 child
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"]);
-            t.insert(h["abc"]);
-            t.insert(h["abcd"]);
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 1);
-
-            BEAST_EXPECT(t.remove(h["abc"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abcd"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abcd"]) == 1);
-        }
-        // In trie with = 1 tip support, > 1 children
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"]);
-            t.insert(h["abc"]);
-            t.insert(h["abcd"]);
-            t.insert(h["abce"]);
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 3);
-
-            BEAST_EXPECT(t.remove(h["abc"]));
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["abc"]) == 2);
-        }
-
-        // In trie with = 1 tip support, parent compaction
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"]);
-            t.insert(h["abc"]);
-            t.insert(h["abd"]);
-            BEAST_EXPECT(t.checkInvariants());
-            t.remove(h["ab"]);
-            BEAST_EXPECT(t.checkInvariants());
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["abd"]) == 1);
-            BEAST_EXPECT(t.tipSupport(h["ab"]) == 0);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 2);
-
-            t.remove(h["abd"]);
-            BEAST_EXPECT(t.checkInvariants());
-
-            BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-            BEAST_EXPECT(t.branchSupport(h["ab"]) == 1);
-        }
-    }
-
-    void
-    testEmpty()
-    {
-        using namespace csf;
-        LedgerTrie t;
-        LedgerHistoryHelper h;
-        BEAST_EXPECT(t.empty());
-
-        Ledger const genesis = h[""];
-        t.insert(genesis);
-        BEAST_EXPECT(!t.empty());
-        t.remove(genesis);
-        BEAST_EXPECT(t.empty());
-
-        t.insert(h["abc"]);
-        BEAST_EXPECT(!t.empty());
-        t.remove(h["abc"]);
-        BEAST_EXPECT(t.empty());
-    }
-
-    void
-    testSupport()
-    {
-        using namespace csf;
-
-        LedgerTrie t;
-        LedgerHistoryHelper h;
-        BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["axy"]) == 0);
-
-        BEAST_EXPECT(t.branchSupport(h["a"]) == 0);
-        BEAST_EXPECT(t.branchSupport(h["axy"]) == 0);
-
-        t.insert(h["abc"]);
-        BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["ab"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-        BEAST_EXPECT(t.tipSupport(h["abcd"]) == 0);
-
-        BEAST_EXPECT(t.branchSupport(h["a"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["ab"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["abc"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["abcd"]) == 0);
-
-        t.insert(h["abe"]);
-        BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["ab"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["abc"]) == 1);
-        BEAST_EXPECT(t.tipSupport(h["abe"]) == 1);
-
-        BEAST_EXPECT(t.branchSupport(h["a"]) == 2);
-        BEAST_EXPECT(t.branchSupport(h["ab"]) == 2);
-        BEAST_EXPECT(t.branchSupport(h["abc"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["abe"]) == 1);
-
-        t.remove(h["abc"]);
-        BEAST_EXPECT(t.tipSupport(h["a"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["ab"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["abc"]) == 0);
-        BEAST_EXPECT(t.tipSupport(h["abe"]) == 1);
-
-        BEAST_EXPECT(t.branchSupport(h["a"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["ab"]) == 1);
-        BEAST_EXPECT(t.branchSupport(h["abc"]) == 0);
-        BEAST_EXPECT(t.branchSupport(h["abe"]) == 1);
-    }
-
-    void
-    testGetPreferred()
-    {
-        using namespace csf;
-        using Seq = Ledger::Seq;
-        // Empty
-        {
-            LedgerTrie const t;
-            BEAST_EXPECT(t.getPreferred(Seq{0}) == std::nullopt);
-            BEAST_EXPECT(t.getPreferred(Seq{2}) == std::nullopt);
-        }
-        // Genesis support is NOT empty
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            Ledger const genesis = h[""];
-            t.insert(genesis);
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{0})->id == genesis.id());
-            BEAST_EXPECT(t.remove(genesis));
-            BEAST_EXPECT(t.getPreferred(Seq{0}) == std::nullopt);
-            BEAST_EXPECT(!t.remove(genesis));
-        }
-        // Single node no children
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-        }
-        // Single node smaller child support
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"]);
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-        }
-        // Single node larger child
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"], 2);
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abcd"].id());
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abcd"].id());
-        }
-        // Single node smaller children support
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"]);
-            t.insert(h["abce"]);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-
-            t.insert(h["abc"]);
-
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-        // Single node larger children
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"], 2);
-            t.insert(h["abce"]);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-
-            t.insert(h["abcd"]);
-
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abcd"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abcd"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-        // Tie-breaker by id
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abcd"], 2);
-            t.insert(h["abce"], 2);
-
-            BEAST_EXPECT(h["abce"].id() > h["abcd"].id());
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abce"].id());
-
-            t.insert(h["abcd"]);
-            BEAST_EXPECT(h["abce"].id() > h["abcd"].id());
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abcd"].id());
-        }
-
-        // Tie-breaker not needed
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"]);
-            t.insert(h["abce"], 2);
-            // abce only has a margin of 1, but it owns the tie-breaker
-            BEAST_EXPECT(h["abce"].id() > h["abcd"].id());
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abce"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abce"].id());
-
-            // Switch support from abce to abcd, tie-breaker now needed
-            t.remove(h["abce"]);
-            t.insert(h["abcd"]);
-
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-
-        // Single node larger grand child
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcd"], 2);
-            t.insert(h["abcde"], 4);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abcde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abcde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["abcde"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-
-        // Too much uncommitted support from competing branches
-        {
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["abc"]);
-            t.insert(h["abcde"], 2);
-            t.insert(h["abcfg"], 2);
-            // 'de' and 'fg' are tied without 'abc' vote
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abc"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["abc"].id());
-
-            t.remove(h["abc"]);
-            t.insert(h["abcd"]);
-
-            // 'de' branch has 3 votes to 2, so earlier sequences see it as preferred
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abcde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["abcde"].id());
-
-            // However, if you validated a ledger with Seq 5, potentially on
-            // a different branch, you do not yet know if they chose abcd
-            // or abcf because of you, so abc remains preferred
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["abc"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-
-        // Changing largestSeq perspective changes preferred branch
-        {
-            /**
-             * Build the tree below with initial tip support annotated
-             *       A
-             *      / \
-             *   B(1)  C(1)
-             *  /  |   |
-             * H   D   F(1)
-             *     |
-             *     E(2)
-             *     |
-             *     G
-             */
-            LedgerTrie t;
-            LedgerHistoryHelper h;
-            t.insert(h["ab"]);
-            t.insert(h["ac"]);
-            t.insert(h["acf"]);
-            t.insert(h["abde"], 2);
-
-            // B has more branch support
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{1})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{2})->id == h["ab"].id());
-
-            // But if you last validated D,F or E, you do not yet know
-            // if someone used that validation to commit to B or C
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["a"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["a"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-
-            /**
-             * One of E advancing to G doesn't change anything
-             *       A
-             *      / \
-             *   B(1)  C(1)
-             *  /  |   |
-             * H   D   F(1)
-             *     |
-             *     E(1)
-             *     |
-             *     G(1)
-             */
-            t.remove(h["abde"]);
-            t.insert(h["abdeg"]);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{1})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{2})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["a"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["a"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["a"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-
-            /**
-             * C advancing to H does advance the seq 3 preferred ledger
-             *       A
-             *      / \
-             *   B(1)  C
-             *  /  |   |
-             * H(1)D   F(1)
-             *     |
-             *     E(1)
-             *     |
-             *     G(1)
-             */
-            t.remove(h["ac"]);
-            t.insert(h["abh"]);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{1})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{2})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["a"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["a"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-
-            /**
-             * F advancing to E also moves the preferred ledger forward
-             *       A
-             *      / \
-             *   B(1)  C
-             *  /  |   |
-             * H(1)D   F
-             *     |
-             *     E(2)
-             *     |
-             *     G(1)
-             */
-            t.remove(h["acf"]);
-            t.insert(h["abde"]);
-
-            // NOLINTBEGIN(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(t.getPreferred(Seq{1})->id == h["abde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{2})->id == h["abde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{3})->id == h["abde"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{4})->id == h["ab"].id());
-            BEAST_EXPECT(t.getPreferred(Seq{5})->id == h["ab"].id());
-            // NOLINTEND(bugprone-unchecked-optional-access)
-        }
-    }
-
-    void
-    testRootRelated()
-    {
-        using namespace csf;
-        // Since the root is a special node that breaks the no-single child
-        // invariant, do some tests that exercise it.
-
-        LedgerTrie t;
-        LedgerHistoryHelper h;
-        BEAST_EXPECT(!t.remove(h[""]));
-        BEAST_EXPECT(t.branchSupport(h[""]) == 0);
-        BEAST_EXPECT(t.tipSupport(h[""]) == 0);
-
-        t.insert(h["a"]);
-        BEAST_EXPECT(t.checkInvariants());
-        BEAST_EXPECT(t.branchSupport(h[""]) == 1);
-        BEAST_EXPECT(t.tipSupport(h[""]) == 0);
-
-        t.insert(h["e"]);
-        BEAST_EXPECT(t.checkInvariants());
-        BEAST_EXPECT(t.branchSupport(h[""]) == 2);
-        BEAST_EXPECT(t.tipSupport(h[""]) == 0);
-
-        BEAST_EXPECT(t.remove(h["e"]));
-        BEAST_EXPECT(t.checkInvariants());
-        BEAST_EXPECT(t.branchSupport(h[""]) == 1);
-        BEAST_EXPECT(t.tipSupport(h[""]) == 0);
-    }
-
-    void
-    testStress()
-    {
-        using namespace csf;
-        LedgerTrie t;
-        LedgerHistoryHelper h;
-
-        // Test quasi-randomly add/remove supporting for different ledgers
-        // from a branching history.
-
-        // Ledgers have sequence 1,2,3,4
-        std::uint32_t const depthConst = 4;
-        // Each ledger has 4 possible children
-        std::uint32_t const width = 4;
-
-        std::uint32_t const iterations = 10000;
-
-        // Use explicit seed to have same results for CI
-        // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
-        std::mt19937 gen{42};
-        std::uniform_int_distribution<> depthDist(0, depthConst - 1);
-        std::uniform_int_distribution<> widthDist(0, width - 1);
-        std::uniform_int_distribution<> flip(0, 1);
-        for (std::uint32_t i = 0; i < iterations; ++i)
-        {
-            // pick a random ledger history
-            std::string curr;
-            char const depth = depthDist(gen);
-            char offset = 0;
-            for (char d = 0; d < depth; ++d)
-            {
-                char const a = offset + widthDist(gen);
-                curr += a;
-                offset = (a + 1) * width;
-            }
-
-            // 50-50 to add remove
-            if (flip(gen) == 0)
-            {
-                t.insert(h[curr]);
-            }
-            else
-            {
-                t.remove(h[curr]);
-            }
-            if (!BEAST_EXPECT(t.checkInvariants()))
-                return;
-        }
-    }
-
-    void
-    run() override
-    {
-        testInsert();
-        testRemove();
-        testEmpty();
-        testSupport();
-        testGetPreferred();
-        testRootRelated();
-        testStress();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(LedgerTrie, consensus, xrpl);
-}  // namespace xrpl::test
diff --git a/src/test/consensus/RCLCensorshipDetector_test.cpp b/src/test/consensus/RCLCensorshipDetector_test.cpp
deleted file mode 100644
index 722a34f937..0000000000
--- a/src/test/consensus/RCLCensorshipDetector_test.cpp
+++ /dev/null
@@ -1,83 +0,0 @@
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class RCLCensorshipDetector_test : public beast::unit_test::Suite
-{
-    void
-    test(
-        RCLCensorshipDetector& cdet,
-        int round,
-        std::vector proposed,
-        std::vector accepted,
-        std::vector remain,
-        std::vector remove)
-    {
-        // Begin tracking what we're proposing this round
-        RCLCensorshipDetector::TxIDSeqVec proposal;
-        for (auto const& i : proposed)
-            proposal.emplace_back(i, round);
-        cdet.propose(std::move(proposal));
-
-        // Finalize the round, by processing what we accepted; then
-        // remove anything that needs to be removed and ensure that
-        // what remains is correct.
-        cdet.check(std::move(accepted), [&remove, &remain](auto id, auto seq) {
-            // If the item is supposed to be removed from the censorship
-            // detector internal tracker manually, do it now:
-            if (std::ranges::find(remove, id) != remove.end())
-                return true;
-
-            // If the item is supposed to still remain in the censorship
-            // detector internal tracker; remove it from the vector.
-            auto it = std::ranges::find(remain, id);
-            if (it != remain.end())
-                remain.erase(it);
-            return false;
-        });
-
-        // On entry, this set contained all the elements that should be tracked
-        // by the detector after we process this round. We removed all the items
-        // that actually were in the tracker, so this should now be empty:
-        BEAST_EXPECT(remain.empty());
-    }
-
-public:
-    void
-    run() override
-    {
-        testcase("Censorship Detector");
-
-        RCLCensorshipDetector cdet;
-        int round = 0;
-        // proposed            accepted    remain          remove
-        test(cdet, ++round, {}, {}, {}, {});
-        test(cdet, ++round, {10, 11, 12, 13}, {11, 2}, {10, 13}, {});
-        test(cdet, ++round, {10, 13, 14, 15}, {14}, {10, 13, 15}, {});
-        test(cdet, ++round, {10, 13, 15, 16}, {15, 16}, {10, 13}, {});
-        test(cdet, ++round, {10, 13}, {17, 18}, {10, 13}, {});
-        test(cdet, ++round, {10, 19}, {}, {10, 19}, {});
-        test(cdet, ++round, {10, 19, 20}, {20}, {10}, {19});
-        test(cdet, ++round, {21}, {21}, {}, {});
-        test(cdet, ++round, {}, {22}, {}, {});
-        test(cdet, ++round, {23, 24, 25, 26}, {25, 27}, {23, 26}, {24});
-        test(cdet, ++round, {23, 26, 28}, {26, 28}, {23}, {});
-
-        for (int i = 0; i != 10; ++i)
-            test(cdet, ++round, {23}, {}, {23}, {});
-
-        test(cdet, ++round, {23, 29}, {29}, {23}, {});
-        test(cdet, ++round, {30, 31}, {31}, {30}, {});
-        test(cdet, ++round, {30}, {30}, {}, {});
-        test(cdet, ++round, {}, {}, {}, {});
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(RCLCensorshipDetector, consensus, xrpl);
-}  // namespace xrpl::test
diff --git a/src/test/consensus/ScaleFreeSim_test.cpp b/src/test/consensus/ScaleFreeSim_test.cpp
deleted file mode 100644
index e533e09eb0..0000000000
--- a/src/test/consensus/ScaleFreeSim_test.cpp
+++ /dev/null
@@ -1,109 +0,0 @@
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class ScaleFreeSim_test : public beast::unit_test::Suite
-{
-    void
-    run() override
-    {
-        using namespace std::chrono;
-        using namespace csf;
-
-        // Generate a quasi-random scale free network and simulate consensus
-        // as we vary transaction submission rates
-
-        int const n = 100;  // Peers
-
-        int const numUNLs = 15;  //  UNL lists
-        int const minUNLSize = n / 4, maxUNLSize = n / 2;
-
-        ConsensusParms const parms{};
-        Sim sim;
-        PeerGroup network = sim.createGroup(n);
-
-        // generate trust ranks
-        std::vector const ranks =
-            sample(network.size(), PowerLawDistribution{1, 3}, sim.rng);
-
-        // generate scale-free trust graph
-        randomRankedTrust(
-            network,
-            ranks,
-            numUNLs,
-            std::uniform_int_distribution<>{minUNLSize, maxUNLSize},
-            sim.rng);
-
-        // nodes with a trust line in either direction are network-connected
-        network.connectFromTrust(round(0.2 * parms.ledgerGRANULARITY));
-
-        // Initialize collectors to track statistics to report
-        TxCollector txCollector;
-        LedgerCollector ledgerCollector;
-        auto colls = makeCollectors(txCollector, ledgerCollector);
-        sim.collectors.add(colls);
-
-        // Initial round to set prior state
-        sim.run(1);
-
-        // Initialize timers
-        HeartbeatTimer heart(sim.scheduler, seconds(10s));
-
-        // Run for 10 minutes, submitting 100 tx/second
-        std::chrono::nanoseconds const simDuration = 10min;
-        std::chrono::nanoseconds const quiet = 10s;
-        Rate const rate{.count = 100, .duration = 1000ms};
-
-        // txs, start/stop/step, target
-        auto peerSelector = makeSelector(network.begin(), network.end(), ranks, sim.rng);
-        auto txSubmitter = makeSubmitter(
-            ConstantDistribution{rate.inv()},
-            sim.scheduler.now() + quiet,
-            sim.scheduler.now() + (simDuration - quiet),
-            peerSelector,
-            sim.scheduler,
-            sim.rng);
-
-        // run simulation for given duration
-        heart.start();
-        sim.run(simDuration);
-
-        BEAST_EXPECT(sim.branches() == 1);
-        BEAST_EXPECT(sim.synchronized());
-
-        // TODO: Clean up this formatting mess!!
-
-        log << "Peers: " << network.size() << std::endl;
-        log << "Simulated Duration: " << duration_cast(simDuration).count() << " ms"
-            << std::endl;
-        log << "Branches: " << sim.branches() << std::endl;
-        log << "Synchronized: " << (sim.synchronized() ? "Y" : "N") << std::endl;
-        log << std::endl;
-
-        txCollector.report(simDuration, log);
-        ledgerCollector.report(simDuration, log);
-        // Print summary?
-        // # forks?  # of LCLs?
-        // # peers
-        // # tx submitted
-        // # ledgers/sec etc.?
-    }
-};
-
-BEAST_DEFINE_TESTSUITE_MANUAL_PRIO(ScaleFreeSim, consensus, xrpl, 80);
-
-}  // namespace xrpl::test
diff --git a/src/test/consensus/Validations_test.cpp b/src/test/consensus/Validations_test.cpp
deleted file mode 100644
index 606c6f2824..0000000000
--- a/src/test/consensus/Validations_test.cpp
+++ /dev/null
@@ -1,1059 +0,0 @@
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test::csf {
-class Validations_test : public beast::unit_test::Suite
-{
-    using clock_type = beast::AbstractClock const;
-
-    // Helper to convert steady_clock to a reasonable NetClock
-    // This allows a single manual clock in the unit tests
-    static NetClock::time_point
-    toNetClock(clock_type const& c)
-    {
-        // We don't care about the actual epochs, but do want the
-        // generated NetClock time to be well past its epoch to ensure
-        // any subtractions are positive
-        using namespace std::chrono;
-        return NetClock::time_point(
-            duration_cast(c.now().time_since_epoch() + 86400s));
-    }
-
-    // Represents a node that can issue validations
-    class Node
-    {
-        clock_type const& c_;
-        PeerID nodeID_;
-        bool trusted_ = true;
-        std::size_t signIdx_{1};
-        std::optional loadFee_;
-
-    public:
-        Node(PeerID nodeID, clock_type const& c) : c_(c), nodeID_(nodeID)
-        {
-        }
-
-        void
-        untrust()
-        {
-            trusted_ = false;
-        }
-
-        void
-        trust()
-        {
-            trusted_ = true;
-        }
-
-        void
-        setLoadFee(std::uint32_t fee)
-        {
-            loadFee_ = fee;
-        }
-
-        [[nodiscard]] PeerID
-        nodeID() const
-        {
-            return nodeID_;
-        }
-
-        void
-        advanceKey()
-        {
-            signIdx_++;
-        }
-
-        [[nodiscard]] PeerKey
-        currKey() const
-        {
-            return std::make_pair(nodeID_, signIdx_);
-        }
-
-        [[nodiscard]] PeerKey
-        masterKey() const
-        {
-            return std::make_pair(nodeID_, 0);
-        }
-        [[nodiscard]] NetClock::time_point
-        now() const
-        {
-            return toNetClock(c_);
-        }
-
-        // Issue a new validation with given sequence number and id and
-        // with signing and seen times offset from the common clock
-        [[nodiscard]] Validation
-        validate(
-            Ledger::ID id,
-            Ledger::Seq seq,
-            NetClock::duration signOffset,
-            NetClock::duration seenOffset,
-            bool full) const
-        {
-            Validation v{
-                id,
-                seq,
-                now() + signOffset,
-                now() + seenOffset,
-                currKey(),
-                nodeID_,
-                full,
-                loadFee_};
-            if (trusted_)
-                v.setTrusted();
-            return v;
-        }
-
-        [[nodiscard]] Validation
-        validate(Ledger ledger, NetClock::duration signOffset, NetClock::duration seenOffset) const
-        {
-            return validate(ledger.id(), ledger.seq(), signOffset, seenOffset, true);
-        }
-
-        [[nodiscard]] Validation
-        validate(Ledger ledger) const
-        {
-            return validate(
-                ledger.id(), ledger.seq(), NetClock::duration{0}, NetClock::duration{0}, true);
-        }
-
-        [[nodiscard]] Validation
-        partial(Ledger ledger) const
-        {
-            return validate(
-                ledger.id(), ledger.seq(), NetClock::duration{0}, NetClock::duration{0}, false);
-        }
-    };
-
-    // Generic Validations adaptor
-    class Adaptor
-    {
-        clock_type& c_;
-        LedgerOracle& oracle_;
-
-    public:
-        // Non-locking mutex to avoid locks in generic Validations
-        struct Mutex
-        {
-            void
-            lock()
-            {
-            }
-
-            void
-            unlock()
-            {
-            }
-        };
-
-        using Validation = csf::Validation;
-        using Ledger = csf::Ledger;
-
-        Adaptor(clock_type& c, LedgerOracle& o) : c_{c}, oracle_{o}
-        {
-        }
-
-        [[nodiscard]] NetClock::time_point
-        now() const
-        {
-            return toNetClock(c_);
-        }
-
-        std::optional
-        acquire(Ledger::ID const& id)
-        {
-            return oracle_.lookup(id);
-        }
-    };
-
-    // Specialize generic Validations using the above types
-    using TestValidations = Validations;
-
-    // Gather the dependencies of TestValidations in a single class and provide
-    // accessors for simplifying test logic
-    class TestHarness
-    {
-        ValidationParms p_;
-        beast::ManualClock clock_;
-        TestValidations tv_;
-        PeerID nextNodeId_{0};
-
-    public:
-        explicit TestHarness(LedgerOracle& o) : tv_(p_, clock_, clock_, o)
-        {
-        }
-
-        ValStatus
-        add(Validation const& v)
-        {
-            return tv_.add(v.nodeID(), v);
-        }
-
-        TestValidations&
-        vals()
-        {
-            return tv_;
-        }
-
-        Node
-        makeNode()
-        {
-            return Node(nextNodeId_++, clock_);
-        }
-
-        ValidationParms
-        parms() const
-        {
-            return p_;
-        }
-
-        auto&
-        clock()
-        {
-            return clock_;
-        }
-    };
-
-    Ledger const genesisLedger_{Ledger::MakeGenesis{}};
-
-    void
-    testAddValidation()
-    {
-        using namespace std::chrono_literals;
-
-        testcase("Add validation");
-        LedgerHistoryHelper h;
-        Ledger const ledgerA = h["a"];
-        Ledger ledgerAB = h["ab"];
-        Ledger ledgerAZ = h["az"];
-        Ledger ledgerABC = h["abc"];
-        Ledger const ledgerABCD = h["abcd"];
-        Ledger const ledgerABCDE = h["abcde"];
-
-        {
-            TestHarness harness(h.oracle);
-            Node n = harness.makeNode();
-
-            auto const v = n.validate(ledgerA);
-
-            // Add a current validation
-            BEAST_EXPECT(ValStatus::Current == harness.add(v));
-
-            // Re-adding violates the increasing seq requirement for full
-            // validations
-            BEAST_EXPECT(ValStatus::BadSeq == harness.add(v));
-
-            harness.clock().advance(1s);
-
-            BEAST_EXPECT(ValStatus::Current == harness.add(n.validate(ledgerAB)));
-
-            // Test the node changing signing key
-
-            // Confirm old ledger on hand, but not new ledger
-            BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerAB.id()) == 1);
-            BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerABC.id()) == 0);
-
-            // Rotate signing keys
-            n.advanceKey();
-
-            harness.clock().advance(1s);
-
-            // Cannot re-do the same full validation sequence
-            BEAST_EXPECT(ValStatus::Conflicting == harness.add(n.validate(ledgerAB)));
-            // Cannot send the same partial validation sequence
-            BEAST_EXPECT(ValStatus::Conflicting == harness.add(n.partial(ledgerAB)));
-
-            // Now trusts the newest ledger too
-            harness.clock().advance(1s);
-            BEAST_EXPECT(ValStatus::Current == harness.add(n.validate(ledgerABC)));
-            BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerAB.id()) == 1);
-            BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerABC.id()) == 1);
-
-            // Processing validations out of order should ignore the older
-            // validation
-            harness.clock().advance(2s);
-            auto const valABCDE = n.validate(ledgerABCDE);
-
-            harness.clock().advance(4s);
-            auto const valABCD = n.validate(ledgerABCD);
-
-            BEAST_EXPECT(ValStatus::Current == harness.add(valABCD));
-
-            BEAST_EXPECT(ValStatus::Stale == harness.add(valABCDE));
-        }
-
-        {
-            // Process validations out of order with shifted times
-
-            TestHarness harness(h.oracle);
-            Node const n = harness.makeNode();
-
-            // Establish a new current validation
-            BEAST_EXPECT(ValStatus::Current == harness.add(n.validate(ledgerA)));
-
-            // Process a validation that has "later" seq but early sign time
-            BEAST_EXPECT(ValStatus::Stale == harness.add(n.validate(ledgerAB, -1s, -1s)));
-
-            // Process a validation that has a later seq and later sign
-            // time
-            BEAST_EXPECT(ValStatus::Current == harness.add(n.validate(ledgerABC, 1s, 1s)));
-        }
-
-        {
-            // Test stale on arrival validations
-            TestHarness harness(h.oracle);
-            Node const n = harness.makeNode();
-
-            BEAST_EXPECT(
-                ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, -harness.parms().validationCurrentEarly, 0s)));
-
-            BEAST_EXPECT(
-                ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, harness.parms().validationCurrentWall, 0s)));
-
-            BEAST_EXPECT(
-                ValStatus::Stale ==
-                harness.add(n.validate(ledgerA, 0s, harness.parms().validationCurrentLocal)));
-        }
-
-        {
-            // Test that full or partials cannot be sent for older sequence
-            // numbers, unless time-out has happened
-            for (bool doFull : {true, false})
-            {
-                TestHarness harness(h.oracle);
-                Node n = harness.makeNode();
-
-                auto process = [&](Ledger& lgr) {
-                    if (doFull)
-                        return harness.add(n.validate(lgr));
-                    return harness.add(n.partial(lgr));
-                };
-
-                BEAST_EXPECT(ValStatus::Current == process(ledgerABC));
-                harness.clock().advance(1s);
-                BEAST_EXPECT(ledgerAB.seq() < ledgerABC.seq());
-                BEAST_EXPECT(ValStatus::BadSeq == process(ledgerAB));
-
-                // If we advance far enough for AB to expire, we can fully
-                // validate or partially validate that sequence number again
-                BEAST_EXPECT(ValStatus::Conflicting == process(ledgerAZ));
-                harness.clock().advance(harness.parms().validationSetExpires + 1ms);
-                BEAST_EXPECT(ValStatus::Current == process(ledgerAZ));
-            }
-        }
-    }
-
-    void
-    testOnStale()
-    {
-        testcase("Stale validation");
-        // Verify validation becomes stale based solely on time passing, but
-        // use different functions to trigger the check for staleness
-
-        LedgerHistoryHelper h;
-        Ledger ledgerA = h["a"];
-        Ledger const ledgerAB = h["ab"];
-
-        using Trigger = std::function;
-
-        std::vector const triggers = {
-            [&](TestValidations& vals) { vals.currentTrusted(); },
-            [&](TestValidations& vals) { vals.getCurrentNodeIDs(); },
-            [&](TestValidations& vals) { vals.getPreferred(genesisLedger_); },
-            [&](TestValidations& vals) { vals.getNodesAfter(ledgerA, ledgerA.id()); }};
-        for (Trigger const& trigger : triggers)
-        {
-            TestHarness harness(h.oracle);
-            Node const n = harness.makeNode();
-
-            BEAST_EXPECT(ValStatus::Current == harness.add(n.validate(ledgerAB)));
-            trigger(harness.vals());
-            BEAST_EXPECT(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 1);
-            BEAST_EXPECT(
-                harness.vals().getPreferred(genesisLedger_) ==
-                std::make_pair(ledgerAB.seq(), ledgerAB.id()));
-            harness.clock().advance(harness.parms().validationCurrentLocal);
-
-            // trigger check for stale
-            trigger(harness.vals());
-
-            BEAST_EXPECT(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 0);
-            BEAST_EXPECT(harness.vals().getPreferred(genesisLedger_) == std::nullopt);
-        }
-    }
-
-    void
-    testGetNodesAfter()
-    {
-        // Test getting number of nodes working on a validation descending
-        // a prescribed one. This count should only be for trusted nodes, but
-        // includes partial and full validations
-
-        using namespace std::chrono_literals;
-        testcase("Get nodes after");
-
-        LedgerHistoryHelper h;
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerAB = h["ab"];
-        Ledger const ledgerABC = h["abc"];
-        Ledger const ledgerAD = h["ad"];
-
-        TestHarness harness(h.oracle);
-        Node const trustedNode1 = harness.makeNode();
-        Node const trustedNode2 = harness.makeNode();
-        Node const trustedNode3 = harness.makeNode();
-
-        Node notTrustedNode = harness.makeNode();
-        notTrustedNode.untrust();
-
-        // first round a,b,c agree, d has is partial
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode1.validate(ledgerA)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode2.validate(ledgerA)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerA)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode3.partial(ledgerA)));
-
-        for (Ledger const& ledger : {ledgerA, ledgerAB, ledgerABC, ledgerAD})
-            BEAST_EXPECT(harness.vals().getNodesAfter(ledger, ledger.id()) == 0);
-
-        harness.clock().advance(5s);
-
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode1.validate(ledgerAB)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode2.validate(ledgerABC)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerAB)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode3.partial(ledgerABC)));
-
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 3);
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerAB, ledgerAB.id()) == 2);
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerABC, ledgerABC.id()) == 0);
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerAD, ledgerAD.id()) == 0);
-
-        // If given a ledger inconsistent with the id, is still able to check using slower method
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerAD, ledgerA.id()) == 1);
-        BEAST_EXPECT(harness.vals().getNodesAfter(ledgerAD, ledgerAB.id()) == 2);
-    }
-
-    void
-    testCurrentTrusted()
-    {
-        using namespace std::chrono_literals;
-        testcase("Current trusted validations");
-
-        LedgerHistoryHelper h;
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerB = h["b"];
-        Ledger const ledgerAC = h["ac"];
-
-        TestHarness harness(h.oracle);
-        Node const a = harness.makeNode();
-        Node b = harness.makeNode();
-        b.untrust();
-
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerA)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(b.validate(ledgerB)));
-
-        // Only a is trusted
-        BEAST_EXPECT(harness.vals().currentTrusted().size() == 1);
-        BEAST_EXPECT(harness.vals().currentTrusted()[0].ledgerID() == ledgerA.id());
-        BEAST_EXPECT(harness.vals().currentTrusted()[0].seq() == ledgerA.seq());
-
-        harness.clock().advance(3s);
-
-        for (auto const& node : {a, b})
-            BEAST_EXPECT(ValStatus::Current == harness.add(node.validate(ledgerAC)));
-
-        // New validation for a
-        BEAST_EXPECT(harness.vals().currentTrusted().size() == 1);
-        BEAST_EXPECT(harness.vals().currentTrusted()[0].ledgerID() == ledgerAC.id());
-        BEAST_EXPECT(harness.vals().currentTrusted()[0].seq() == ledgerAC.seq());
-
-        // Pass enough time for it to go stale
-        harness.clock().advance(harness.parms().validationCurrentLocal);
-        BEAST_EXPECT(harness.vals().currentTrusted().empty());
-    }
-
-    void
-    testGetCurrentPublicKeys()
-    {
-        using namespace std::chrono_literals;
-        testcase("Current public keys");
-
-        LedgerHistoryHelper h;
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerAC = h["ac"];
-
-        TestHarness harness(h.oracle);
-        Node a = harness.makeNode(), b = harness.makeNode();
-        b.untrust();
-
-        for (auto const& node : {a, b})
-            BEAST_EXPECT(ValStatus::Current == harness.add(node.validate(ledgerA)));
-
-        {
-            hash_set const expectedKeys = {a.nodeID(), b.nodeID()};
-            BEAST_EXPECT(harness.vals().getCurrentNodeIDs() == expectedKeys);
-        }
-
-        harness.clock().advance(3s);
-
-        // Change keys and issue partials
-        a.advanceKey();
-        b.advanceKey();
-
-        for (auto const& node : {a, b})
-            BEAST_EXPECT(ValStatus::Current == harness.add(node.partial(ledgerAC)));
-
-        {
-            hash_set const expectedKeys = {a.nodeID(), b.nodeID()};
-            BEAST_EXPECT(harness.vals().getCurrentNodeIDs() == expectedKeys);
-        }
-
-        // Pass enough time for them to go stale
-        harness.clock().advance(harness.parms().validationCurrentLocal);
-        BEAST_EXPECT(harness.vals().getCurrentNodeIDs().empty());
-    }
-
-    void
-    testTrustedByLedgerFunctions()
-    {
-        // Test the Validations functions that calculate a value by ledger ID
-        using namespace std::chrono_literals;
-        testcase("By ledger functions");
-
-        // Several Validations functions return a set of values associated
-        // with trusted ledgers sharing the same ledger ID.  The tests below
-        // exercise this logic by saving the set of trusted Validations, and
-        // verifying that the Validations member functions all calculate the
-        // proper transformation of the available ledgers.
-
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-
-        Node a = harness.makeNode(), b = harness.makeNode(), c = harness.makeNode(),
-             d = harness.makeNode(), e = harness.makeNode();
-
-        c.untrust();
-        // Mix of load fees
-        a.setLoadFee(12);
-        b.setLoadFee(1);
-        c.setLoadFee(12);
-        e.setLoadFee(12);
-
-        hash_map, std::vector> trustedValidations;
-
-        //----------------------------------------------------------------------
-        // checkers
-        auto sorted = [](auto vec) {
-            std::sort(vec.begin(), vec.end());
-            return vec;
-        };
-        auto compare = [&]() {
-            for (auto& it : trustedValidations)
-            {
-                auto const& id = it.first.first;
-                auto const& seq = it.first.second;
-                auto const& expectedValidations = it.second;
-
-                BEAST_EXPECT(harness.vals().numTrustedForLedger(id) == expectedValidations.size());
-                BEAST_EXPECT(
-                    sorted(harness.vals().getTrustedForLedger(id, seq)) ==
-                    sorted(expectedValidations));
-
-                std::uint32_t const baseFee = 0;
-                std::vector expectedFees;
-                expectedFees.reserve(expectedValidations.size());
-                for (auto const& val : expectedValidations)
-                {
-                    expectedFees.push_back(val.loadFee().value_or(baseFee));
-                }
-
-                BEAST_EXPECT(sorted(harness.vals().fees(id, baseFee)) == sorted(expectedFees));
-            }
-        };
-
-        //----------------------------------------------------------------------
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerB = h["b"];
-        Ledger const ledgerAC = h["ac"];
-
-        // Add a dummy ID to cover unknown ledger identifiers
-        trustedValidations[{Ledger::ID{100}, Ledger::Seq{100}}] = {};
-
-        // first round a,b,c agree
-        for (auto const& node : {a, b, c})
-        {
-            auto const val = node.validate(ledgerA);
-            BEAST_EXPECT(ValStatus::Current == harness.add(val));
-            if (val.trusted())
-                trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
-        }
-        // d disagrees
-        {
-            auto const val = d.validate(ledgerB);
-            BEAST_EXPECT(ValStatus::Current == harness.add(val));
-            trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
-        }
-        // e only issues partials
-        {
-            BEAST_EXPECT(ValStatus::Current == harness.add(e.partial(ledgerA)));
-        }
-
-        harness.clock().advance(5s);
-        // second round, a,b,c move to ledger 2
-        for (auto const& node : {a, b, c})
-        {
-            auto const val = node.validate(ledgerAC);
-            BEAST_EXPECT(ValStatus::Current == harness.add(val));
-            if (val.trusted())
-                trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
-        }
-        // d now thinks ledger 1, but cannot re-issue a previously used seq
-        // and attempting it should generate a conflict.
-        {
-            BEAST_EXPECT(ValStatus::Conflicting == harness.add(d.partial(ledgerA)));
-        }
-        // e only issues partials
-        {
-            BEAST_EXPECT(ValStatus::Current == harness.add(e.partial(ledgerAC)));
-        }
-
-        compare();
-    }
-
-    void
-    testExpire()
-    {
-        // Verify expiring clears out validations stored by ledger
-        testcase("Expire validations");
-        SuiteJournal j("Validations_test", *this);
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const a = harness.makeNode();
-        constexpr Ledger::Seq kOne(1);
-        constexpr Ledger::Seq kTwo(2);
-
-        // simple cases
-        Ledger const ledgerA = h["a"];
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerA)));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
-        harness.vals().expire(j);
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
-        harness.clock().advance(harness.parms().validationSetExpires);
-        harness.vals().expire(j);
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 0);
-
-        // use setSeqToKeep to keep the validation from expire
-        Ledger const ledgerB = h["ab"];
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerB)));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
-        harness.vals().setSeqToKeep(ledgerB.seq(), ledgerB.seq() + kOne);
-        harness.clock().advance(harness.parms().validationSetExpires);
-        harness.vals().expire(j);
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
-        // change toKeep
-        harness.vals().setSeqToKeep(ledgerB.seq() + kOne, ledgerB.seq() + kTwo);
-        // advance clock slowly
-        int const loops =
-            harness.parms().validationSetExpires / harness.parms().validationFRESHNESS + 1;
-        for (int i = 0; i < loops; ++i)
-        {
-            harness.clock().advance(harness.parms().validationFRESHNESS);
-            harness.vals().expire(j);
-        }
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerB.id()) == 0);
-
-        // Allow the validation with high seq to expire
-        Ledger const ledgerC = h["abc"];
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerC)));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerC.id()) == 1);
-        harness.vals().setSeqToKeep(ledgerC.seq() - kOne, ledgerC.seq());
-        harness.clock().advance(harness.parms().validationSetExpires);
-        harness.vals().expire(j);
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerC.id()) == 0);
-    }
-
-    void
-    testFlush()
-    {
-        // Test final flush of validations
-        using namespace std::chrono_literals;
-        testcase("Flush validations");
-
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const trustedNode1 = harness.makeNode();
-        Node const trustedNode2 = harness.makeNode();
-        Node notTrustedNode = harness.makeNode();
-        notTrustedNode.untrust();
-
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerAB = h["ab"];
-
-        hash_map expected;
-        for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode})
-        {
-            auto const val = node.validate(ledgerA);
-            BEAST_EXPECT(ValStatus::Current == harness.add(val));
-            expected.emplace(node.nodeID(), val);
-        }
-
-        // Send in a new validation for a, saving the new one into the expected
-        // map after setting the proper prior ledger ID it replaced
-        harness.clock().advance(1s);
-        auto newVal = trustedNode1.validate(ledgerAB);
-        BEAST_EXPECT(ValStatus::Current == harness.add(newVal));
-        expected.find(trustedNode1.nodeID())->second = newVal;
-    }
-
-    void
-    testGetPreferredLedger()
-    {
-        using namespace std::chrono_literals;
-        testcase("Preferred Ledger");
-
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const trustedNode1 = harness.makeNode();
-        Node const trustedNode2 = harness.makeNode();
-        Node const trustedNode3 = harness.makeNode();
-
-        Node notTrustedNode = harness.makeNode();
-        notTrustedNode.untrust();
-
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerB = h["b"];
-        Ledger const ledgerAC = h["ac"];
-        Ledger const ledgerACD = h["acd"];
-
-        using Seq = Ledger::Seq;
-
-        auto pref = [](Ledger ledger) { return std::make_pair(ledger.seq(), ledger.id()); };
-
-        // Empty (no ledgers)
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA) == std::nullopt);
-
-        // Single ledger
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode1.validate(ledgerB)));
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA) == pref(ledgerB));
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerB) == pref(ledgerB));
-
-        // Minimum valid sequence
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA, Seq{10}) == ledgerA.id());
-
-        // Untrusted doesn't impact preferred ledger
-        // (ledgerB has tie-break over ledgerA)
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode2.validate(ledgerA)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerA)));
-        BEAST_EXPECT(ledgerB.id() > ledgerA.id());
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA) == pref(ledgerB));
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerB) == pref(ledgerB));
-
-        // Partial does break ties
-        BEAST_EXPECT(ValStatus::Current == harness.add(trustedNode3.partial(ledgerA)));
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA) == pref(ledgerA));
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerB) == pref(ledgerA));
-
-        harness.clock().advance(5s);
-
-        // Parent of preferred-> stick with ledger
-        for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode, trustedNode3})
-            BEAST_EXPECT(ValStatus::Current == harness.add(node.validate(ledgerAC)));
-        // Parent of preferred stays put
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerA) == pref(ledgerA));
-        // Earlier different chain, switch
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerB) == pref(ledgerAC));
-        // Later on chain, stays where it is
-        BEAST_EXPECT(harness.vals().getPreferred(ledgerACD) == pref(ledgerACD));
-
-        // Any later grandchild or different chain is preferred
-        harness.clock().advance(5s);
-        for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode, trustedNode3})
-            BEAST_EXPECT(ValStatus::Current == harness.add(node.validate(ledgerACD)));
-        for (auto const& ledger : {ledgerA, ledgerB, ledgerACD})
-            BEAST_EXPECT(harness.vals().getPreferred(ledger) == pref(ledgerACD));
-    }
-
-    void
-    testGetPreferredLCL()
-    {
-        using namespace std::chrono_literals;
-        testcase("Get preferred LCL");
-
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const a = harness.makeNode();
-
-        Ledger const ledgerA = h["a"];
-        Ledger const ledgerB = h["b"];
-        Ledger const ledgerC = h["c"];
-
-        using ID = Ledger::ID;
-        using Seq = Ledger::Seq;
-
-        hash_map peerCounts;
-
-        // No trusted validations or counts sticks with current ledger
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerA.id());
-
-        ++peerCounts[ledgerB.id()];
-
-        // No trusted validations, rely on peer counts
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerB.id());
-
-        ++peerCounts[ledgerC.id()];
-        // No trusted validations, tied peers goes with larger ID
-        BEAST_EXPECT(ledgerC.id() > ledgerB.id());
-
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerC.id());
-
-        peerCounts[ledgerC.id()] += 1000;
-
-        // Single trusted always wins over peer counts
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.validate(ledgerA)));
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerA.id());
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerB, Seq{0}, peerCounts) == ledgerA.id());
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerC, Seq{0}, peerCounts) == ledgerA.id());
-
-        // Stick with current ledger if trusted validation ledger has too old
-        // of a sequence
-        BEAST_EXPECT(harness.vals().getPreferredLCL(ledgerB, Seq{2}, peerCounts) == ledgerB.id());
-    }
-
-    void
-    testAcquireValidatedLedger()
-    {
-        using namespace std::chrono_literals;
-        testcase("Acquire validated ledger");
-
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const a = harness.makeNode();
-        Node const b = harness.makeNode();
-
-        using ID = Ledger::ID;
-        using Seq = Ledger::Seq;
-
-        // Validate the ledger before it is actually available
-        Validation const val = a.validate(ID{2}, Seq{2}, 0s, 0s, true);
-
-        BEAST_EXPECT(ValStatus::Current == harness.add(val));
-        // Validation is available
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ID{2}) == 1);
-        // but ledger based data is not
-        BEAST_EXPECT(harness.vals().getNodesAfter(genesisLedger_, ID{0}) == 0);
-        // Initial preferred branch falls back to the ledger we are trying to
-        // acquire
-        BEAST_EXPECT(harness.vals().getPreferred(genesisLedger_) == std::make_pair(Seq{2}, ID{2}));
-
-        // After adding another unavailable validation, the preferred ledger
-        // breaks ties via higher ID
-        BEAST_EXPECT(ValStatus::Current == harness.add(b.validate(ID{3}, Seq{2}, 0s, 0s, true)));
-        BEAST_EXPECT(harness.vals().getPreferred(genesisLedger_) == std::make_pair(Seq{2}, ID{3}));
-
-        // Create the ledger
-        Ledger const ledgerAB = h["ab"];
-        // Now it should be available
-        BEAST_EXPECT(harness.vals().getNodesAfter(genesisLedger_, ID{0}) == 1);
-
-        // Create a validation that is not available
-        harness.clock().advance(5s);
-        Validation const val2 = a.validate(ID{4}, Seq{4}, 0s, 0s, true);
-        BEAST_EXPECT(ValStatus::Current == harness.add(val2));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ID{4}) == 1);
-        BEAST_EXPECT(
-            harness.vals().getPreferred(genesisLedger_) ==
-            std::make_pair(ledgerAB.seq(), ledgerAB.id()));
-
-        // Another node requesting that ledger still doesn't change things
-        Validation const val3 = b.validate(ID{4}, Seq{4}, 0s, 0s, true);
-        BEAST_EXPECT(ValStatus::Current == harness.add(val3));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ID{4}) == 2);
-        BEAST_EXPECT(
-            harness.vals().getPreferred(genesisLedger_) ==
-            std::make_pair(ledgerAB.seq(), ledgerAB.id()));
-
-        // Switch to validation that is available
-        harness.clock().advance(5s);
-        Ledger const ledgerABCDE = h["abcde"];
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.partial(ledgerABCDE)));
-        BEAST_EXPECT(ValStatus::Current == harness.add(b.partial(ledgerABCDE)));
-        BEAST_EXPECT(
-            harness.vals().getPreferred(genesisLedger_) ==
-            std::make_pair(ledgerABCDE.seq(), ledgerABCDE.id()));
-    }
-
-    void
-    testNumTrustedForLedger()
-    {
-        testcase("NumTrustedForLedger");
-        LedgerHistoryHelper h;
-        TestHarness harness(h.oracle);
-        Node const a = harness.makeNode();
-        Node const b = harness.makeNode();
-        Ledger const ledgerA = h["a"];
-
-        BEAST_EXPECT(ValStatus::Current == harness.add(a.partial(ledgerA)));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 0);
-
-        BEAST_EXPECT(ValStatus::Current == harness.add(b.validate(ledgerA)));
-        BEAST_EXPECT(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
-    }
-
-    void
-    testSeqEnforcer()
-    {
-        testcase("SeqEnforcer");
-        using Seq = Ledger::Seq;
-        using namespace std::chrono;
-
-        beast::ManualClock clock;
-        SeqEnforcer enforcer;
-
-        ValidationParms const p;
-
-        BEAST_EXPECT(enforcer(clock.now(), Seq{1}, p));
-        BEAST_EXPECT(enforcer(clock.now(), Seq{10}, p));
-        BEAST_EXPECT(!enforcer(clock.now(), Seq{5}, p));
-        BEAST_EXPECT(!enforcer(clock.now(), Seq{9}, p));
-        clock.advance(p.validationSetExpires - 1ms);
-        BEAST_EXPECT(!enforcer(clock.now(), Seq{1}, p));
-        clock.advance(2ms);
-        BEAST_EXPECT(enforcer(clock.now(), Seq{1}, p));
-    }
-
-    void
-    testTrustChanged()
-    {
-        testcase("TrustChanged");
-        using namespace std::chrono;
-
-        auto checker = [this](
-                           TestValidations& vals,
-                           hash_set const& listed,
-                           std::vector const& trustedVals) {
-            Ledger::ID const testID =
-                trustedVals.empty() ? this->genesisLedger_.id() : trustedVals[0].ledgerID();
-            Ledger::Seq const testSeq =
-                trustedVals.empty() ? this->genesisLedger_.seq() : trustedVals[0].seq();
-            BEAST_EXPECT(vals.currentTrusted() == trustedVals);
-            BEAST_EXPECT(vals.getCurrentNodeIDs() == listed);
-            BEAST_EXPECT(
-                vals.getNodesAfter(this->genesisLedger_, genesisLedger_.id()) ==
-                trustedVals.size());
-            if (trustedVals.empty())
-            {
-                BEAST_EXPECT(vals.getPreferred(this->genesisLedger_) == std::nullopt);
-            }
-            else
-            {
-                BEAST_EXPECT(vals.getPreferred(this->genesisLedger_)->second == testID);
-            }
-            BEAST_EXPECT(vals.getTrustedForLedger(testID, testSeq) == trustedVals);
-            BEAST_EXPECT(vals.numTrustedForLedger(testID) == trustedVals.size());
-        };
-
-        {
-            // Trusted to untrusted
-            LedgerHistoryHelper h;
-            TestHarness harness(h.oracle);
-            Node const a = harness.makeNode();
-            Ledger const ledgerAB = h["ab"];
-            Validation const v = a.validate(ledgerAB);
-            BEAST_EXPECT(ValStatus::Current == harness.add(v));
-
-            hash_set const listed({a.nodeID()});
-            std::vector trustedVals({v});
-            checker(harness.vals(), listed, trustedVals);
-
-            trustedVals.clear();
-            harness.vals().trustChanged({}, {a.nodeID()});
-            checker(harness.vals(), listed, trustedVals);
-        }
-
-        {
-            // Untrusted to trusted
-            LedgerHistoryHelper h;
-            TestHarness harness(h.oracle);
-            Node a = harness.makeNode();
-            a.untrust();
-            Ledger const ledgerAB = h["ab"];
-            Validation const v = a.validate(ledgerAB);
-            BEAST_EXPECT(ValStatus::Current == harness.add(v));
-
-            hash_set const listed({a.nodeID()});
-            std::vector trustedVals;
-            checker(harness.vals(), listed, trustedVals);
-
-            trustedVals.push_back(v);
-            harness.vals().trustChanged({a.nodeID()}, {});
-            checker(harness.vals(), listed, trustedVals);
-        }
-
-        {
-            // Trusted but not acquired -> untrusted
-            LedgerHistoryHelper h;
-            TestHarness harness(h.oracle);
-            Node const a = harness.makeNode();
-            Validation const v = a.validate(Ledger::ID{2}, Ledger::Seq{2}, 0s, 0s, true);
-            BEAST_EXPECT(ValStatus::Current == harness.add(v));
-
-            hash_set const listed({a.nodeID()});
-            std::vector trustedVals({v});
-            auto& vals = harness.vals();
-            BEAST_EXPECT(vals.currentTrusted() == trustedVals);
-
-            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
-            BEAST_EXPECT(vals.getPreferred(genesisLedger_)->second == v.ledgerID());
-            BEAST_EXPECT(vals.getNodesAfter(genesisLedger_, genesisLedger_.id()) == 0);
-
-            trustedVals.clear();
-            harness.vals().trustChanged({}, {a.nodeID()});
-            // make acquiring ledger available
-            h["ab"];
-            BEAST_EXPECT(vals.currentTrusted() == trustedVals);
-            BEAST_EXPECT(vals.getPreferred(genesisLedger_) == std::nullopt);
-            BEAST_EXPECT(vals.getNodesAfter(genesisLedger_, genesisLedger_.id()) == 0);
-        }
-    }
-
-    void
-    run() override
-    {
-        testAddValidation();
-        testOnStale();
-        testGetNodesAfter();
-        testCurrentTrusted();
-        testGetCurrentPublicKeys();
-        testTrustedByLedgerFunctions();
-        testExpire();
-        testFlush();
-        testGetPreferredLedger();
-        testGetPreferredLCL();
-        testAcquireValidatedLedger();
-        testNumTrustedForLedger();
-        testSeqEnforcer();
-        testTrustChanged();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Validations, consensus, xrpl);
-}  // namespace xrpl::test::csf
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/csf/BasicNetwork_test.cpp b/src/test/csf/BasicNetwork_test.cpp
deleted file mode 100644
index 22f52a1b63..0000000000
--- a/src/test/csf/BasicNetwork_test.cpp
+++ /dev/null
@@ -1,134 +0,0 @@
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-
-namespace xrpl::test {
-
-class BasicNetwork_test : public beast::unit_test::Suite
-{
-public:
-    struct Peer
-    {
-        int id;
-        std::set set;
-
-        Peer(Peer const&) = default;
-        Peer(Peer&&) = default;
-
-        explicit Peer(int id) : id(id)
-        {
-        }
-
-        template 
-        void
-        start(csf::Scheduler& scheduler, Net& net)
-        {
-            using namespace std::chrono_literals;
-            auto t = scheduler.in(1s, [&] { set.insert(0); });
-            if (id == 0)
-            {
-                for (auto const link : net.links(this))
-                {
-                    net.send(
-                        this, link.target, [&, to = link.target] { to->receive(net, this, 1); });
-                }
-            }
-            else
-            {
-                scheduler.cancel(t);
-            }
-        }
-
-        template 
-        void
-        receive(Net& net, Peer* from, int m)
-        {
-            set.insert(m);
-            ++m;
-            if (m < 5)
-            {
-                for (auto const link : net.links(this))
-                {
-                    net.send(this, link.target, [&, mm = m, to = link.target] {
-                        to->receive(net, this, mm);
-                    });
-                }
-            }
-        }
-    };
-
-    void
-    testNetwork()
-    {
-        using namespace std::chrono_literals;
-        std::vector pv;
-        pv.emplace_back(0);
-        pv.emplace_back(1);
-        pv.emplace_back(2);
-        csf::Scheduler scheduler;
-        csf::BasicNetwork net(scheduler);
-        BEAST_EXPECT(!net.connect(&pv[0], &pv[0]));
-        BEAST_EXPECT(net.connect(&pv[0], &pv[1], 1s));
-        BEAST_EXPECT(net.connect(&pv[1], &pv[2], 1s));
-        BEAST_EXPECT(!net.connect(&pv[0], &pv[1]));
-        for (auto& peer : pv)
-            peer.start(scheduler, net);
-        BEAST_EXPECT(scheduler.stepFor(0s));
-        BEAST_EXPECT(scheduler.stepFor(1s));
-        BEAST_EXPECT(scheduler.step());
-        BEAST_EXPECT(!scheduler.step());
-        BEAST_EXPECT(!scheduler.stepFor(1s));
-        net.send(&pv[0], &pv[1], [] {});
-        net.send(&pv[1], &pv[0], [] {});
-        BEAST_EXPECT(net.disconnect(&pv[0], &pv[1]));
-        BEAST_EXPECT(!net.disconnect(&pv[0], &pv[1]));
-        for (;;)
-        {
-            auto const links = net.links(&pv[1]);
-            if (links.empty())
-                break;
-            BEAST_EXPECT(net.disconnect(&pv[1], links[0].target));
-        }
-        BEAST_EXPECT(pv[0].set == std::set({0, 2, 4}));
-        BEAST_EXPECT(pv[1].set == std::set({1, 3}));
-        BEAST_EXPECT(pv[2].set == std::set({2, 4}));
-    }
-
-    void
-    testDisconnect()
-    {
-        using namespace std::chrono_literals;
-        csf::Scheduler scheduler;
-        csf::BasicNetwork net(scheduler);
-        BEAST_EXPECT(net.connect(0, 1, 1s));
-        BEAST_EXPECT(net.connect(0, 2, 2s));
-
-        std::set delivered;
-        net.send(0, 1, [&]() { delivered.insert(1); });
-        net.send(0, 2, [&]() { delivered.insert(2); });
-
-        scheduler.in(1000ms, [&]() { BEAST_EXPECT(net.disconnect(0, 2)); });
-        scheduler.in(1100ms, [&]() { BEAST_EXPECT(net.connect(0, 2)); });
-
-        scheduler.step();
-
-        // only the first message is delivered because the disconnect at 1 s
-        // purges all pending messages from 0 to 2
-        BEAST_EXPECT(delivered == std::set({1}));
-    }
-
-    void
-    run() override
-    {
-        testNetwork();
-        testDisconnect();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(BasicNetwork, csf, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/csf/Digraph_test.cpp b/src/test/csf/Digraph_test.cpp
deleted file mode 100644
index 40bfafde9c..0000000000
--- a/src/test/csf/Digraph_test.cpp
+++ /dev/null
@@ -1,81 +0,0 @@
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-class Digraph_test : public beast::unit_test::Suite
-{
-public:
-    void
-    run() override
-    {
-        using namespace csf;
-        using Graph = Digraph;
-        Graph graph;
-
-        BEAST_EXPECT(!graph.connected('a', 'b'));
-        BEAST_EXPECT(!graph.edge('a', 'b'));
-        BEAST_EXPECT(!graph.disconnect('a', 'b'));
-
-        BEAST_EXPECT(graph.connect('a', 'b', "foobar"));
-        BEAST_EXPECT(graph.connected('a', 'b'));
-        BEAST_EXPECT(
-            *graph.edge('a', 'b') == "foobar");  // NOLINT(bugprone-unchecked-optional-access)
-
-        BEAST_EXPECT(!graph.connect('a', 'b', "repeat"));
-        BEAST_EXPECT(graph.disconnect('a', 'b'));
-        BEAST_EXPECT(graph.connect('a', 'b', "repeat"));
-        BEAST_EXPECT(graph.connected('a', 'b'));
-        BEAST_EXPECT(
-            *graph.edge('a', 'b') == "repeat");  // NOLINT(bugprone-unchecked-optional-access)
-
-        BEAST_EXPECT(graph.connect('a', 'c', "tree"));
-
-        {
-            std::vector> edges;
-
-            for (auto const& edge : graph.outEdges('a'))
-            {
-                edges.emplace_back(edge.source, edge.target, edge.data);
-            }
-
-            std::vector> expected;
-            expected.emplace_back('a', 'b', "repeat");
-            expected.emplace_back('a', 'c', "tree");
-            BEAST_EXPECT(edges == expected);
-            BEAST_EXPECT(graph.outDegree('a') == expected.size());
-        }
-
-        BEAST_EXPECT(graph.outEdges('r').size() == 0);
-        BEAST_EXPECT(graph.outDegree('r') == 0);
-        BEAST_EXPECT(graph.outDegree('c') == 0);
-
-        // only 'a' has out edges
-        BEAST_EXPECT(graph.outVertices().size() == 1);
-        std::vector const expected = {'b', 'c'};
-
-        BEAST_EXPECT((graph.outVertices('a') == expected));
-        BEAST_EXPECT(graph.outVertices('b').size() == 0);
-        BEAST_EXPECT(graph.outVertices('c').size() == 0);
-        BEAST_EXPECT(graph.outVertices('r').size() == 0);
-
-        std::stringstream ss;
-        graph.saveDot(ss, [](char v) { return v; });
-        std::string const expectedDot =
-            "digraph {\n"
-            "a -> b;\n"
-            "a -> c;\n"
-            "}\n";
-        BEAST_EXPECT(ss.str() == expectedDot);
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Digraph, csf, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/csf/Histogram_test.cpp b/src/test/csf/Histogram_test.cpp
deleted file mode 100644
index 65edb14e5d..0000000000
--- a/src/test/csf/Histogram_test.cpp
+++ /dev/null
@@ -1,66 +0,0 @@
-#include 
-
-#include 
-
-namespace xrpl::test {
-
-class Histogram_test : public beast::unit_test::Suite
-{
-public:
-    void
-    run() override
-    {
-        using namespace csf;
-        Histogram hist;
-
-        BEAST_EXPECT(hist.size() == 0);
-        BEAST_EXPECT(hist.numBins() == 0);
-        BEAST_EXPECT(hist.minValue() == 0);
-        BEAST_EXPECT(hist.maxValue() == 0);
-        BEAST_EXPECT(hist.avg() == 0);
-        BEAST_EXPECT(hist.percentile(0.0f) == hist.minValue());
-        BEAST_EXPECT(hist.percentile(0.5f) == 0);
-        BEAST_EXPECT(hist.percentile(0.9f) == 0);
-        BEAST_EXPECT(hist.percentile(1.0f) == hist.maxValue());
-
-        hist.insert(1);
-
-        BEAST_EXPECT(hist.size() == 1);
-        BEAST_EXPECT(hist.numBins() == 1);
-        BEAST_EXPECT(hist.minValue() == 1);
-        BEAST_EXPECT(hist.maxValue() == 1);
-        BEAST_EXPECT(hist.avg() == 1);
-        BEAST_EXPECT(hist.percentile(0.0f) == hist.minValue());
-        BEAST_EXPECT(hist.percentile(0.5f) == 1);
-        BEAST_EXPECT(hist.percentile(0.9f) == 1);
-        BEAST_EXPECT(hist.percentile(1.0f) == hist.maxValue());
-
-        hist.insert(9);
-
-        BEAST_EXPECT(hist.size() == 2);
-        BEAST_EXPECT(hist.numBins() == 2);
-        BEAST_EXPECT(hist.minValue() == 1);
-        BEAST_EXPECT(hist.maxValue() == 9);
-        BEAST_EXPECT(hist.avg() == 5);
-        BEAST_EXPECT(hist.percentile(0.0f) == hist.minValue());
-        BEAST_EXPECT(hist.percentile(0.5f) == 1);
-        BEAST_EXPECT(hist.percentile(0.9f) == 9);
-        BEAST_EXPECT(hist.percentile(1.0f) == hist.maxValue());
-
-        hist.insert(1);
-
-        BEAST_EXPECT(hist.size() == 3);
-        BEAST_EXPECT(hist.numBins() == 2);
-        BEAST_EXPECT(hist.minValue() == 1);
-        BEAST_EXPECT(hist.maxValue() == 9);
-        BEAST_EXPECT(hist.avg() == 11 / 3);
-        BEAST_EXPECT(hist.percentile(0.0f) == hist.minValue());
-        BEAST_EXPECT(hist.percentile(0.5f) == 1);
-        BEAST_EXPECT(hist.percentile(0.9f) == 9);
-        BEAST_EXPECT(hist.percentile(1.0f) == hist.maxValue());
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Histogram, csf, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/csf/Scheduler_test.cpp b/src/test/csf/Scheduler_test.cpp
deleted file mode 100644
index 6f4ac051c4..0000000000
--- a/src/test/csf/Scheduler_test.cpp
+++ /dev/null
@@ -1,68 +0,0 @@
-#include 
-
-#include 
-
-#include 
-
-namespace xrpl::test {
-
-class Scheduler_test : public beast::unit_test::Suite
-{
-public:
-    void
-    run() override
-    {
-        using namespace std::chrono_literals;
-        csf::Scheduler scheduler;
-        std::set seen;
-
-        scheduler.in(1s, [&] { seen.insert(1); });
-        scheduler.in(2s, [&] { seen.insert(2); });
-        auto token = scheduler.in(3s, [&] { seen.insert(3); });
-        scheduler.at(scheduler.now() + 4s, [&] { seen.insert(4); });
-        scheduler.at(scheduler.now() + 8s, [&] { seen.insert(8); });
-
-        auto start = scheduler.now();
-
-        // Process first event
-        BEAST_EXPECT(seen.empty());
-        BEAST_EXPECT(scheduler.stepOne());
-        BEAST_EXPECT(seen == std::set({1}));
-        BEAST_EXPECT(scheduler.now() == (start + 1s));
-
-        // No processing if stepping until current time
-        BEAST_EXPECT(scheduler.stepUntil(scheduler.now()));
-        BEAST_EXPECT(seen == std::set({1}));
-        BEAST_EXPECT(scheduler.now() == (start + 1s));
-
-        // Process next event
-        BEAST_EXPECT(scheduler.stepFor(1s));
-        BEAST_EXPECT(seen == std::set({1, 2}));
-        BEAST_EXPECT(scheduler.now() == (start + 2s));
-
-        // Don't process cancelled event, but advance clock
-        scheduler.cancel(token);
-        BEAST_EXPECT(scheduler.stepFor(1s));
-        BEAST_EXPECT(seen == std::set({1, 2}));
-        BEAST_EXPECT(scheduler.now() == (start + 3s));
-
-        // Process until 3 seen ints
-        BEAST_EXPECT(scheduler.stepWhile([&]() { return seen.size() < 3; }));
-        BEAST_EXPECT(seen == std::set({1, 2, 4}));
-        BEAST_EXPECT(scheduler.now() == (start + 4s));
-
-        // Process the rest
-        BEAST_EXPECT(scheduler.step());
-        BEAST_EXPECT(seen == std::set({1, 2, 4, 8}));
-        BEAST_EXPECT(scheduler.now() == (start + 8s));
-
-        // Process the rest again doesn't advance
-        BEAST_EXPECT(!scheduler.step());
-        BEAST_EXPECT(seen == std::set({1, 2, 4, 8}));
-        BEAST_EXPECT(scheduler.now() == (start + 8s));
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Scheduler, csf, xrpl);
-
-}  // namespace xrpl::test
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/AbstractClient.h b/src/test/jtx/AbstractClient.h
index f9d8de0768..58f57f67a5 100644
--- a/src/test/jtx/AbstractClient.h
+++ b/src/test/jtx/AbstractClient.h
@@ -40,6 +40,17 @@ public:
      */
     [[nodiscard]] virtual unsigned
     version() const = 0;
+
+    /**
+     * Close the client's connection to the server.
+     *
+     * Releases the connection the client holds against the server's per-port
+     * connection limit. After this call the client must not be used to
+     * invoke() again. Tests use this to deterministically free the slot
+     * rather than waiting for the server's idle timeout to drop it.
+     */
+    virtual void
+    disconnect() = 0;
 };
 
 }  // namespace xrpl::test
diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h
index 404ddbe31d..465bac03db 100644
--- a/src/test/jtx/ConfidentialTransfer.h
+++ b/src/test/jtx/ConfidentialTransfer.h
@@ -94,6 +94,36 @@ 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;
+    }
+
     // Get a bad ciphertext with valid structure but cryptographic invalid for
     // testing purposes. For preflight test purposes.
     static Buffer const&
diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h
index 7e22cdd571..5cb841578a 100644
--- a/src/test/jtx/Env.h
+++ b/src/test/jtx/Env.h
@@ -482,11 +482,52 @@ public:
             app().getNumberOfThreads() == 1,
             "syncClose() is only useful on an application with a single thread");
         auto const result = close();
-        auto serverBarrier = std::make_shared>();
-        auto future = serverBarrier->get_future();
-        boost::asio::post(app().getIOContext(), [serverBarrier]() { serverBarrier->set_value(); });
-        auto const status = future.wait_for(timeout);
-        return result && status == std::future_status::ready;
+        return result && drainServerIo(timeout);
+    }
+
+    /**
+     * Disconnect the Env's built-in client and wait for the server to
+     * register the dropped connection.
+     *
+     * Env holds one persistent client connection to the server's RPC port for
+     * its whole lifetime (see client()), and that connection counts against
+     * the port's connection limit. Tests that need a known starting occupancy
+     * can call this to deterministically release that slot instead of waiting
+     * out the server's localhost idle timeout.
+     *
+     * The server decrements its per-port connection count in the peer's
+     * destructor, which runs when the io_context processes the end-of-stream
+     * on the closed socket. After closing the client this drains the server's
+     * io_context twice: the first barrier guarantees the reactor has reaped
+     * the closed socket and queued the peer's teardown, and the second
+     * guarantees that teardown (and therefore the count decrement) has run.
+     *
+     * This is only sound when the server uses a single io_context thread, so
+     * that draining establishes ordering against the teardown - configure the
+     * Env with singleThreadIo() (as syncClose() also requires). Like
+     * syncClose(), it relies on loopback teardown latency being negligible.
+     *
+     * @param timeout Maximum time to wait for each barrier task to execute
+     * @return true if both barriers executed within timeout, false otherwise
+     */
+    [[nodiscard]] bool
+    disconnectClient(std::chrono::steady_clock::duration timeout = std::chrono::seconds{1})
+    {
+        XRPL_ASSERT(
+            app().getNumberOfThreads() == 1,
+            "disconnectClient() is only useful on an application with a single "
+            "thread");
+
+        bundle_.client->disconnect();
+
+        // Drain the server's single io thread twice: the first barrier flushes
+        // the reactor's reap of the closed socket (queuing the peer teardown),
+        // the second flushes that teardown - and therefore the connection-count
+        // decrement. Both run unconditionally so a timed-out first drain does
+        // not short-circuit the second.
+        bool const reaped = drainServerIo(timeout);
+        bool const toreDown = drainServerIo(timeout);
+        return reaped && toreDown;
     }
 
     /**
@@ -514,6 +555,49 @@ public:
         parseFailureExpected_ = b;
     }
 
+    /**
+     * RAII class to set and restore the parse failure flag (setParseFailureExpected).
+     *
+     * Can be created directly, or through the `getParseFailureGuard(bool)` function.
+     */
+    class ParseFailureGuard final
+    {
+        Env& self_;
+        bool const oldExpected_;
+
+    public:
+        ParseFailureGuard(Env& self, bool b)
+            : self_(self), oldExpected_(self_.parseFailureExpected_)
+        {
+            self_.setParseFailureExpected(b);
+        }
+
+        ~ParseFailureGuard()
+        {
+            self_.setParseFailureExpected(oldExpected_);
+        }
+
+        // No copy, no move
+        ParseFailureGuard(ParseFailureGuard const&) = delete;
+        ParseFailureGuard&
+        operator=(ParseFailureGuard const&) = delete;
+        ParseFailureGuard(ParseFailureGuard&& other) = delete;
+        ParseFailureGuard&
+        operator=(ParseFailureGuard&&) = delete;
+    };
+
+    /**
+     * Gets an RAII guard to set and restore the parse failure flag
+     *
+     * Usage:
+     * auto const guard = env.getParseFailureGuard(true/false);
+     */
+    [[nodiscard]] ParseFailureGuard
+    getParseFailureGuard(bool b)
+    {
+        return ParseFailureGuard{*this, b};
+    }
+
     /**
      * Turn off signature checks.
      */
@@ -803,6 +887,25 @@ public:
     }
 
 private:
+    /**
+     * Drain the (single) server io_context thread once.
+     *
+     * Posts a barrier task to the server's io_context and blocks until it
+     * runs, so every task queued before it has been processed. Only meaningful
+     * with a single io thread (see syncClose()/disconnectClient()).
+     *
+     * @param timeout Maximum time to wait for the barrier task to execute
+     * @return true if the barrier ran within timeout, false otherwise
+     */
+    [[nodiscard]] bool
+    drainServerIo(std::chrono::steady_clock::duration timeout)
+    {
+        auto barrier = std::make_shared>();
+        auto future = barrier->get_future();
+        boost::asio::post(app().getIOContext(), [barrier]() { barrier->set_value(); });
+        return future.wait_for(timeout) == std::future_status::ready;
+    }
+
     void
     fund(bool setDefaultRipple, STAmount const& amount, Account const& account);
 
@@ -978,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/TestHelpers.h b/src/test/jtx/TestHelpers.h
index e7a2808f07..801c3627b8 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 
@@ -42,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -314,19 +316,11 @@ auto const kData = JTxFieldWrapper(sfData);
 
 auto const kAmount = JTxFieldWrapper(sfAmount);
 
-// TODO We only need this long "requires" clause as polyfill, for C++20
-// implementations which are missing  header. Replace with
-// `std::ranges::range`, and accordingly use std::ranges::begin/end
-// when we have moved to better compilers.
-template 
+template 
 auto
 makeVector(Input const& input)
-    requires requires(Input& v) {
-        std::begin(v);
-        std::end(v);
-    }
 {
-    return std::vector(std::begin(input), std::end(input));
+    return std::vector(std::ranges::begin(input), std::ranges::end(input));
 }
 
 // Functions used in debugging
@@ -779,9 +773,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 +870,7 @@ checkMetrics(
 /* LoanBroker */
 /******************************************************************************/
 
-namespace loanBroker {
+namespace loan_broker {
 
 json::Value
 set(AccountID const& account, uint256 const& vaultId, std::uint32_t flags = 0);
@@ -917,7 +911,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/WSClient_test.cpp b/src/test/jtx/WSClient_test.cpp
index d77e0f948b..801ca50504 100644
--- a/src/test/jtx/WSClient_test.cpp
+++ b/src/test/jtx/WSClient_test.cpp
@@ -13,8 +13,9 @@ class WSClient_test : public beast::unit_test::Suite
 {
 public:
     void
-    run() override
+    testSmoke()
     {
+        testcase("smoke");
         using namespace jtx;
         Env env(*this);
         auto wsc = makeWSClient(env.app().config());
@@ -28,6 +29,47 @@ public:
         auto jv = wsc->getMsg(std::chrono::seconds(1));
         pass();
     }
+
+    void
+    testGracefulDisconnect()
+    {
+        testcase("graceful disconnect");
+        using namespace jtx;
+        using namespace std::chrono;
+
+        Env env(*this);
+        auto wsc = makeWSClient(env.app().config());
+
+        // Put real traffic on the connection before closing it.
+        json::Value stream;
+        stream["streams"] = json::ValueType::Array;
+        stream["streams"].append("ledger");
+        auto const sub = wsc->invoke("subscribe", stream);
+        BEAST_EXPECT(sub.isMember("result") || sub.isMember("status"));
+
+        // disconnect() performs a graceful WebSocket closing handshake and
+        // blocks until the server acknowledges. On loopback that completes in
+        // well under its internal 1s timeout; only a broken async_close/ack
+        // coordination would fall through to the force-close path at ~1s. A
+        // generous bound keeps this from flaking under load while still
+        // catching that regression.
+        auto const start = steady_clock::now();
+        wsc->disconnect();
+        auto const elapsed = duration_cast(steady_clock::now() - start);
+        BEAST_EXPECT(elapsed < milliseconds{750});
+
+        // disconnect() must be idempotent: a second call (and the subsequent
+        // destructor) must not hang, double-close, or crash.
+        wsc->disconnect();
+        pass();
+    }
+
+    void
+    run() override
+    {
+        testSmoke();
+        testGracefulDisconnect();
+    }
 };
 
 BEAST_DEFINE_TESTSUITE(WSClient, jtx, xrpl);
diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h
index 57a4502db9..94dd8aef9e 100644
--- a/src/test/jtx/amount.h
+++ b/src/test/jtx/amount.h
@@ -162,12 +162,6 @@ operator==(PrettyAmount const& lhs, PrettyAmount const& rhs)
     return lhs.value() == rhs.value();
 }
 
-inline bool
-operator!=(PrettyAmount const& lhs, PrettyAmount const& rhs)
-{
-    return !operator==(lhs, rhs);
-}
-
 std::ostream&
 operator<<(std::ostream& os, PrettyAmount const& amount);
 
diff --git a/src/test/jtx/impl/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/Env.cpp b/src/test/jtx/impl/Env.cpp
index 3f6aca9fcb..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
@@ -629,7 +629,7 @@ Env::autofill(JTx& jt)
     catch (ParseError const&)
     {
         if (!parseFailureExpected_)
-            test.log << "parse failed:\n" << pretty(jv) << std::endl;
+            test.log << "parse failure:\n" << pretty(jv) << std::endl;
         rethrow();
     }
 }
diff --git a/src/test/jtx/impl/JSONRPCClient.cpp b/src/test/jtx/impl/JSONRPCClient.cpp
index 495fc5a657..06474c3616 100644
--- a/src/test/jtx/impl/JSONRPCClient.cpp
+++ b/src/test/jtx/impl/JSONRPCClient.cpp
@@ -14,18 +14,23 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -84,6 +89,40 @@ class JSONRPCClient : public AbstractClient
     boost::beast::multi_buffer bout_;
     unsigned rpcVersion_;
 
+    bool disconnected_ = false;
+
+    // Errors that mean the persistent keep-alive connection was dropped by the
+    // server (rather than a genuine protocol failure), so the request can be
+    // safely retried on a fresh connection.
+    static bool
+    droppedConnection(boost::system::error_code const& ec)
+    {
+        namespace error = boost::asio::error;
+        static auto const kDroppedConnectionErrors = std::to_array({
+            boost::beast::http::error::end_of_stream,
+            error::eof,
+            error::connection_reset,
+            error::connection_aborted,
+            error::broken_pipe,
+            error::not_connected,
+        });
+
+        return std::ranges::any_of(
+            kDroppedConnectionErrors,
+            [&ec](boost::system::error_code const& e) { return ec == e; });
+    }
+
+    // Tear down and re-establish the socket to ep_, discarding any buffered
+    // bytes left over from the dropped connection.
+    void
+    reconnect()
+    {
+        boost::system::error_code ec;
+        stream_.close(ec);
+        bin_.clear();
+        stream_.connect(ep_);
+    }
+
 public:
     explicit JSONRPCClient(Config const& cfg, unsigned rpcVersion)
         : ep_(getEndpoint(cfg)), stream_(ios_), rpcVersion_(rpcVersion)
@@ -91,12 +130,10 @@ public:
         stream_.connect(ep_);
     }
 
-    /*
-        Return value is an Object type with up to three keys:
-            status
-            error
-            result
-    */
+    // Return value is an Object type with up to three keys:
+    //     status
+    //     error
+    //     result
     json::Value
     invoke(std::string const& cmd, json::Value const& params) override
     {
@@ -104,6 +141,13 @@ public:
         using namespace boost::asio;
         using namespace std::string_literals;
 
+        // Once disconnect() has released the slot, the client must not be
+        // reused (see AbstractClient::disconnect). Refuse rather than let the
+        // failed write/read below trip the reconnect path and silently
+        // re-consume a connection slot, which would defeat disconnectClient().
+        if (disconnected_)
+            Throw("JSONRPCClient::invoke called after disconnect()");
+
         request req;
         req.method(boost::beast::http::verb::post);
         req.target("/");
@@ -131,10 +175,29 @@ public:
             req.body() = to_string(jr);
         }
         req.prepare_payload();
-        write(stream_, req);
 
+        // The client keeps a single keep-alive connection for its whole
+        // lifetime, but the server drops idle localhost connections after a few
+        // seconds (BaseHTTPPeer::kTimeoutSecondsLocal). If a slow gap between
+        // requests let the server close the socket, the write/read here fails
+        // with end_of_stream; reconnect and retry the request exactly once.
         response res;
-        read(stream_, bin_, res);
+        auto writeAndRead = [&] {
+            write(stream_, req);
+            read(stream_, bin_, res);
+        };
+        try
+        {
+            writeAndRead();
+        }
+        catch (boost::system::system_error const& e)
+        {
+            if (!droppedConnection(e.code()))
+                throw;
+            reconnect();
+            res = {};
+            writeAndRead();
+        }
 
         json::Reader jr;
         json::Value jv;
@@ -151,6 +214,19 @@ public:
     {
         return rpcVersion_;
     }
+
+    void
+    disconnect() override
+    {
+        if (disconnected_)
+            return;
+
+        disconnected_ = true;
+
+        boost::system::error_code ec;
+        stream_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
+        stream_.close(ec);
+    }
 };
 
 std::unique_ptr
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/WSClient.cpp b/src/test/jtx/impl/WSClient.cpp
index ca322415fb..a8702c12d9 100644
--- a/src/test/jtx/impl/WSClient.cpp
+++ b/src/test/jtx/impl/WSClient.cpp
@@ -2,6 +2,7 @@
 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -112,10 +113,11 @@ class WSClientImpl : public WSClient
 
     bool peerClosed_ = false;
 
-    // synchronize destructor
-    bool b0_ = false;
-    std::mutex m0_;
-    std::condition_variable cv0_;
+    // disconnect() waits on this until the read loop ends (for any reason:
+    // the server acknowledged our close, or a timeout force-closed the socket).
+    static constexpr auto kDisconnectTimeout = std::chrono::seconds{1};
+    xrpl::Mutex readEnded_;
+    std::condition_variable readEndCv_;
 
     // synchronize message queue
     std::mutex m_;
@@ -127,23 +129,26 @@ class WSClientImpl : public WSClient
     void
     cleanup()
     {
-        boost::asio::post(ios_, boost::asio::bind_executor(strand_, [this] {
-                              if (!peerClosed_)
-                              {
-                                  ws_.async_close(
-                                      {}, boost::asio::bind_executor(strand_, [&](error_code) {
-                                          try
-                                          {
-                                              stream_.cancel();
-                                          }
-                                          // NOLINTNEXTLINE(bugprone-empty-catch)
-                                          catch (boost::system::system_error const&)
-                                          {
-                                              // ignored
-                                          }
-                                      }));
-                              }
-                          }));
+        boost::asio::post(
+            ios_,  //
+            boost::asio::bind_executor(strand_, [this] {
+                if (!peerClosed_)
+                {
+                    ws_.async_close(
+                        {},  //
+                        boost::asio::bind_executor(strand_, [&](error_code) {
+                            try
+                            {
+                                stream_.cancel();
+                            }
+                            // NOLINTNEXTLINE(bugprone-empty-catch)
+                            catch (boost::system::system_error const&)
+                            {
+                                // ignored
+                            }
+                        }));
+                }
+            }));
         work_ = std::nullopt;
         thread_.join();
     }
@@ -289,6 +294,44 @@ public:
         return rpcVersion_;
     }
 
+    void
+    disconnect() override
+    {
+        // Perform a graceful WebSocket closing handshake and block until the
+        // read loop ends, so the server observes a clean close (not a RST) and
+        // has finished tearing the connection down by the time we return.
+        // If the server already closed, the wait below returns immediately.
+        boost::asio::post(
+            ios_,
+            boost::asio::bind_executor(
+                strand_,  //
+                [this] {
+                    if (!peerClosed_)
+                    {
+                        ws_.async_close(
+                            boost::beast::websocket::close_code::normal,
+                            boost::asio::bind_executor(strand_, [](error_code) {}));
+                    }
+                }));
+
+        auto lock = readEnded_.lock();
+        readEndCv_.wait_for(lock, kDisconnectTimeout, [&lock] { return *lock; });
+
+        // On timeout (server gone or not replying) force the socket closed so
+        // the outstanding read ends and the worker thread can later be joined.
+        if (!*lock)
+        {
+            boost::asio::post(
+                ios_,
+                boost::asio::bind_executor(
+                    strand_,  //
+                    [this] {
+                        boost::system::error_code ec;
+                        stream_.close(ec);
+                    }));
+        }
+    }
+
 private:
     void
     onReadMsg(error_code const& ec)
@@ -297,33 +340,31 @@ private:
         {
             if (ec == boost::beast::websocket::error::closed)
                 peerClosed_ = true;
+
+            *readEnded_.lock() = true;
+            readEndCv_.notify_all();
+
             return;
         }
 
         json::Value jv;
         json::Reader jr;
+
         jr.parse(bufferString(rb_.data()), jv);
         rb_.consume(rb_.size());
+
         auto m = std::make_shared(std::move(jv));
         {
             std::scoped_lock const lock(m_);
             msgs_.push_front(m);
             cv_.notify_all();
         }
+
         ws_.async_read(
             rb_, boost::asio::bind_executor(strand_, [this](error_code const& ec, std::size_t) {
                 onReadMsg(ec);
             }));
     }
-
-    // Called when the read op terminates
-    void
-    onReadDone()
-    {
-        std::scoped_lock const lock(m0_);
-        b0_ = true;
-        cv0_.notify_all();
-    }
 };
 
 std::unique_ptr
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/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..c6cd49fa26 100644
--- a/src/test/jtx/impl/mpt.cpp
+++ b/src/test/jtx/impl/mpt.cpp
@@ -30,6 +30,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 
@@ -38,7 +39,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -94,21 +94,6 @@ makePedersenParams(PedersenProofParams const& params)
 
 }  // namespace
 
-struct MPTSetFlagMapping
-{
-    std::uint32_t setFlag;
-    std::uint32_t ledgerFlag;
-};
-
-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},
-}};
-
 void
 MptFlags::operator()(Env& env) const
 {
@@ -195,7 +180,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 +188,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 +230,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 +249,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 +448,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 +472,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 +639,15 @@ 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;
+    });
+}
+
 void
 MPTTester::pay(
     Account const& src,
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/vault.cpp b/src/test/jtx/impl/vault.cpp
index 7084347763..978c3864d6 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -9,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -19,13 +20,20 @@ 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;
     return {jv, keylet};
 }
 
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..35ab7264bd 100644
--- a/src/test/jtx/mpt.h
+++ b/src/test/jtx/mpt.h
@@ -147,7 +147,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 +183,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 +229,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;
@@ -609,6 +609,9 @@ public:
     [[nodiscard]] bool
     isTransferFeePresent() const;
 
+    [[nodiscard]] bool
+    checkImmutableFlags(std::uint32_t expectedFlags) const;
+
     [[nodiscard]] Account const&
     issuer() const
     {
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/vault.h b/src/test/jtx/vault.h
index e72eae89b7..992051b61f 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -25,6 +25,12 @@ struct Vault
         Asset asset;
         std::optional flags =
             std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional vaultKind =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional subscriptionDate =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        std::optional redemptionDate =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
     };
 
     /**
diff --git a/src/test/nodestore/Backend_test.cpp b/src/test/nodestore/Backend_test.cpp
deleted file mode 100644
index 65601b0cf5..0000000000
--- a/src/test/nodestore/Backend_test.cpp
+++ /dev/null
@@ -1,110 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::NodeStore {
-
-// Tests the Backend interface
-//
-class Backend_test : public TestBase
-{
-public:
-    void
-    testBackend(std::string const& type, std::uint64_t const seedValue, int numObjsToTest = 2000)
-    {
-        DummyScheduler scheduler;
-
-        testcase("Backend type=" + type);
-
-        Section params;
-        beast::TempDir const tempDir;
-        params.set(Keys::kType, type);
-        params.set(Keys::kPath, tempDir.path());
-
-        beast::xor_shift_engine rng(seedValue);
-
-        // Create a batch
-        auto batch = createPredictableBatch(numObjsToTest, rng());
-
-        using beast::Severity;
-        test::SuiteJournal journal("Backend_test", *this);
-
-        {
-            // Open the backend
-            std::unique_ptr backend =
-                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-            backend->open();
-
-            // Write the batch
-            storeBatch(*backend, batch);
-
-            {
-                // Read it back in
-                Batch copy;
-                fetchCopyOfBatch(*backend, ©, batch);
-                BEAST_EXPECT(areBatchesEqual(batch, copy));
-            }
-
-            {
-                // Reorder and read the copy again
-                std::shuffle(batch.begin(), batch.end(), rng);
-                Batch copy;
-                fetchCopyOfBatch(*backend, ©, batch);
-                BEAST_EXPECT(areBatchesEqual(batch, copy));
-            }
-        }
-
-        {
-            // Re-open the backend
-            std::unique_ptr backend =
-                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-            backend->open();
-
-            // Read it back in
-            Batch copy;
-            fetchCopyOfBatch(*backend, ©, batch);
-            // Canonicalize the source and destination batches
-            std::ranges::sort(batch, LessThan{});
-            std::ranges::sort(copy, LessThan{});
-            BEAST_EXPECT(areBatchesEqual(batch, copy));
-        }
-    }
-
-    //--------------------------------------------------------------------------
-
-    void
-    run() override
-    {
-        std::uint64_t const seedValue = 50;
-
-        testBackend("nudb", seedValue);
-
-#if XRPL_ROCKSDB_AVAILABLE
-        testBackend("rocksdb", seedValue);
-#endif
-
-#ifdef XRPL_ENABLE_SQLITE_BACKEND_TESTS
-        testBackend("sqlite", seedValue);
-#endif
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Backend, nodestore, xrpl);
-
-}  // namespace xrpl::NodeStore
diff --git a/src/test/nodestore/Basics_test.cpp b/src/test/nodestore/Basics_test.cpp
deleted file mode 100644
index 7d77b18630..0000000000
--- a/src/test/nodestore/Basics_test.cpp
+++ /dev/null
@@ -1,73 +0,0 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-
-namespace xrpl::NodeStore {
-
-// Tests predictable batches, and NodeObject blob encoding
-//
-class NodeStoreBasic_test : public TestBase
-{
-public:
-    // Make sure predictable object generation works!
-    void
-    testBatches(std::uint64_t const seedValue)
-    {
-        testcase("batch");
-
-        auto batch1 = createPredictableBatch(kNumObjectsToTest, seedValue);
-
-        auto batch2 = createPredictableBatch(kNumObjectsToTest, seedValue);
-
-        BEAST_EXPECT(areBatchesEqual(batch1, batch2));
-
-        auto batch3 = createPredictableBatch(kNumObjectsToTest, seedValue + 1);
-
-        BEAST_EXPECT(!areBatchesEqual(batch1, batch3));
-    }
-
-    // Checks encoding/decoding blobs
-    void
-    testBlobs(std::uint64_t const seedValue)
-    {
-        testcase("encoding");
-
-        auto batch = createPredictableBatch(kNumObjectsToTest, seedValue);
-
-        for (auto const& expected : batch)
-        {
-            EncodedBlob const encoded(expected);
-
-            DecodedBlob decoded(encoded.getKey(), encoded.getData(), encoded.getSize());
-
-            BEAST_EXPECT(decoded.wasOk());
-
-            if (decoded.wasOk())
-            {
-                std::shared_ptr const object(decoded.createObject());
-
-                BEAST_EXPECT(isSame(expected, object));
-            }
-        }
-    }
-
-    void
-    run() override
-    {
-        std::uint64_t const seedValue = 50;
-
-        testBatches(seedValue);
-
-        testBlobs(seedValue);
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(NodeStoreBasic, nodestore, xrpl);
-
-}  // namespace xrpl::NodeStore
diff --git a/src/test/nodestore/Database_test.cpp b/src/test/nodestore/DatabaseConfig_test.cpp
similarity index 59%
rename from src/test/nodestore/Database_test.cpp
rename to src/test/nodestore/DatabaseConfig_test.cpp
index bb8ec7d4fd..1f7f0f67bd 100644
--- a/src/test/nodestore/Database_test.cpp
+++ b/src/test/nodestore/DatabaseConfig_test.cpp
@@ -1,44 +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 
-#include 
 #include 
 
-namespace xrpl::NodeStore {
+namespace xrpl::node_store {
 
-class Database_test : public TestBase
+class DatabaseConfig_test : public beast::unit_test::Suite
 {
-    test::SuiteJournal journal_;
-
 public:
-    Database_test() : journal_("Database_test", *this)
-    {
-    }
-
     void
     testConfig()
     {
@@ -73,8 +50,8 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section(Sections::kSqlite);
-                    section.set(Keys::kSafetyLevel, "high");
+                    auto& section = p->section("sqlite");
+                    section.set("safety_level", "high");
                 }
                 p->ledgerHistory = 100'000'000;
 
@@ -102,8 +79,8 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section(Sections::kSqlite);
-                    section.set(Keys::kSafetyLevel, "low");
+                    auto& section = p->section("sqlite");
+                    section.set("safety_level", "low");
                 }
                 p->ledgerHistory = 100'000'000;
 
@@ -131,10 +108,10 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section(Sections::kSqlite);
-                    section.set(Keys::kJournalMode, "off");
-                    section.set(Keys::kSynchronous, "extra");
-                    section.set(Keys::kTempStore, "default");
+                    auto& section = p->section("sqlite");
+                    section.set("journal_mode", "off");
+                    section.set("synchronous", "extra");
+                    section.set("temp_store", "default");
                 }
 
                 return Env(
@@ -145,7 +122,7 @@ public:
             }();
 
             // No warning, even though higher risk settings were used because
-            // LEDGER_HISTORY is small
+            // ledgerHistory is small
             BEAST_EXPECT(!found);
             auto const s = setupDatabaseCon(env.app().config());
             if (BEAST_EXPECT(s.globalPragma->size() == 3))
@@ -163,10 +140,10 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section(Sections::kSqlite);
-                    section.set(Keys::kJournalMode, "off");
-                    section.set(Keys::kSynchronous, "extra");
-                    section.set(Keys::kTempStore, "default");
+                    auto& section = p->section("sqlite");
+                    section.set("journal_mode", "off");
+                    section.set("synchronous", "extra");
+                    section.set("temp_store", "default");
                 }
                 p->ledgerHistory = 50'000'000;
 
@@ -178,7 +155,7 @@ public:
             }();
 
             // No warning, even though higher risk settings were used because
-            // LEDGER_HISTORY is small
+            // ledgerHistory is small
             BEAST_EXPECT(found);
             auto const s = setupDatabaseCon(env.app().config());
             if (BEAST_EXPECT(s.globalPragma->size() == 3))
@@ -199,11 +176,11 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSafetyLevel, "low");
-                section.set(Keys::kJournalMode, "off");
-                section.set(Keys::kSynchronous, "extra");
-                section.set(Keys::kTempStore, "default");
+                auto& section = p->section("sqlite");
+                section.set("safety_level", "low");
+                section.set("journal_mode", "off");
+                section.set("synchronous", "extra");
+                section.set("temp_store", "default");
             }
 
             try
@@ -230,9 +207,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSafetyLevel, "high");
-                section.set(Keys::kJournalMode, "off");
+                auto& section = p->section("sqlite");
+                section.set("safety_level", "high");
+                section.set("journal_mode", "off");
             }
 
             try
@@ -259,9 +236,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSafetyLevel, "low");
-                section.set(Keys::kSynchronous, "extra");
+                auto& section = p->section("sqlite");
+                section.set("safety_level", "low");
+                section.set("synchronous", "extra");
             }
 
             try
@@ -288,9 +265,9 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSafetyLevel, "high");
-                section.set(Keys::kTempStore, "default");
+                auto& section = p->section("sqlite");
+                section.set("safety_level", "high");
+                section.set("temp_store", "default");
             }
 
             try
@@ -317,8 +294,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSafetyLevel, "slow");
+                auto& section = p->section("sqlite");
+                section.set("safety_level", "slow");
             }
 
             try
@@ -345,8 +322,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kJournalMode, "fast");
+                auto& section = p->section("sqlite");
+                section.set("journal_mode", "fast");
             }
 
             try
@@ -373,8 +350,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kSynchronous, "instant");
+                auto& section = p->section("sqlite");
+                section.set("synchronous", "instant");
             }
 
             try
@@ -401,8 +378,8 @@ public:
 
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kTempStore, "network");
+                auto& section = p->section("sqlite");
+                section.set("temp_store", "network");
             }
 
             try
@@ -436,9 +413,9 @@ public:
             Env env = [&]() {
                 auto p = test::jtx::envconfig();
                 {
-                    auto& section = p->section(Sections::kSqlite);
-                    section.set(Keys::kPageSize, "512");
-                    section.set(Keys::kJournalSizeLimit, "2582080");
+                    auto& section = p->section("sqlite");
+                    section.set("page_size", "512");
+                    section.set("journal_size_limit", "2582080");
                 }
                 return Env(*this, std::move(p));
             }();
@@ -457,8 +434,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kPageSize, "256");
+                auto& section = p->section("sqlite");
+                section.set("page_size", "256");
             }
             try
             {
@@ -480,8 +457,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kPageSize, "131072");
+                auto& section = p->section("sqlite");
+                section.set("page_size", "131072");
             }
             try
             {
@@ -503,8 +480,8 @@ public:
             bool found = false;
             auto p = test::jtx::envconfig();
             {
-                auto& section = p->section(Sections::kSqlite);
-                section.set(Keys::kPageSize, "513");
+                auto& section = p->section("sqlite");
+                section.set("page_size", "513");
             }
             try
             {
@@ -522,208 +499,13 @@ public:
         }
     }
 
-    //--------------------------------------------------------------------------
-
-    void
-    testImport(
-        std::string const& destBackendType,
-        std::string const& srcBackendType,
-        std::int64_t seedValue)
-    {
-        DummyScheduler scheduler;
-
-        beast::TempDir const nodeDb;
-        Section srcParams;
-        srcParams.set(Keys::kType, srcBackendType);
-        srcParams.set(Keys::kPath, nodeDb.path());
-
-        // Create a batch
-        auto batch = createPredictableBatch(kNumObjectsToTest, seedValue);
-
-        // Write to source db
-        {
-            std::unique_ptr src =
-                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal_);
-            storeBatch(*src, batch);
-        }
-
-        Batch copy;
-
-        {
-            // Re-open the db
-            std::unique_ptr src =
-                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal_);
-
-            // Set up the destination database
-            beast::TempDir const destDb;
-            Section destParams;
-            destParams.set(Keys::kType, destBackendType);
-            destParams.set(Keys::kPath, destDb.path());
-
-            std::unique_ptr dest =
-                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, destParams, journal_);
-
-            testcase("import into '" + destBackendType + "' from '" + srcBackendType + "'");
-
-            // Do the import
-            dest->importDatabase(*src);
-
-            // Get the results of the import
-            fetchCopyOfBatch(*dest, ©, batch);
-        }
-
-        // Canonicalize the source and destination batches
-        std::ranges::sort(batch, LessThan{});
-        std::ranges::sort(copy, LessThan{});
-        BEAST_EXPECT(areBatchesEqual(batch, copy));
-    }
-
-    //--------------------------------------------------------------------------
-
-    void
-    testNodeStore(
-        std::string const& type,
-        bool const testPersistence,
-        std::int64_t const seedValue,
-        int numObjsToTest = 2000)
-    {
-        DummyScheduler scheduler;
-
-        std::string const s = "NodeStore backend '" + type + "'";
-
-        testcase(s);
-
-        beast::TempDir const nodeDb;
-        Section nodeParams;
-        nodeParams.set(Keys::kType, type);
-        nodeParams.set(Keys::kPath, nodeDb.path());
-
-        beast::xor_shift_engine rng(seedValue);
-
-        // Create a batch
-        auto batch = createPredictableBatch(numObjsToTest, rng());
-
-        {
-            // Open the database
-            std::unique_ptr db =
-                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal_);
-
-            // Write the batch
-            storeBatch(*db, batch);
-
-            {
-                // Read it back in
-                Batch copy;
-                fetchCopyOfBatch(*db, ©, batch);
-                BEAST_EXPECT(areBatchesEqual(batch, copy));
-            }
-
-            {
-                // Reorder and read the copy again
-                std::shuffle(batch.begin(), batch.end(), rng);
-                Batch copy;
-                fetchCopyOfBatch(*db, ©, batch);
-                BEAST_EXPECT(areBatchesEqual(batch, copy));
-            }
-        }
-
-        if (testPersistence)
-        {
-            // Re-open the database without the ephemeral DB
-            std::unique_ptr db =
-                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal_);
-
-            // Read it back in
-            Batch copy;
-            fetchCopyOfBatch(*db, ©, batch);
-
-            // Canonicalize the source and destination batches
-            std::ranges::sort(batch, LessThan{});
-            std::ranges::sort(copy, LessThan{});
-            BEAST_EXPECT(areBatchesEqual(batch, copy));
-        }
-
-        if (type == "memory")
-        {
-            // Verify default earliest ledger sequence
-            {
-                std::unique_ptr db = Manager::instance().makeDatabase(
-                    megabytes(4), scheduler, 2, nodeParams, journal_);
-                BEAST_EXPECT(db->earliestLedgerSeq() == kXrpLedgerEarliestSeq);
-            }
-
-            // Set an invalid earliest ledger sequence
-            try
-            {
-                nodeParams.set(Keys::kEarliestSeq, "0");
-                std::unique_ptr const db = Manager::instance().makeDatabase(
-                    megabytes(4), scheduler, 2, nodeParams, journal_);
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(std::strcmp(e.what(), "Invalid earliest_seq") == 0);
-            }
-
-            {
-                // Set a valid earliest ledger sequence
-                nodeParams.set(Keys::kEarliestSeq, "1");
-                std::unique_ptr db = Manager::instance().makeDatabase(
-                    megabytes(4), scheduler, 2, nodeParams, journal_);
-
-                // Verify database uses the earliest ledger sequence setting
-                BEAST_EXPECT(db->earliestLedgerSeq() == 1);
-            }
-
-            // Create another database that attempts to set the value again
-            try
-            {
-                // Set to default earliest ledger sequence
-                nodeParams.set(Keys::kEarliestSeq, std::to_string(kXrpLedgerEarliestSeq));
-                std::unique_ptr const db2 = Manager::instance().makeDatabase(
-                    megabytes(4), scheduler, 2, nodeParams, journal_);
-            }
-            catch (std::runtime_error const& e)
-            {
-                BEAST_EXPECT(std::strcmp(e.what(), "earliest_seq set more than once") == 0);
-            }
-        }
-    }
-
-    //--------------------------------------------------------------------------
-
     void
     run() override
     {
-        std::int64_t const seedValue = 50;
-
         testConfig();
-
-        testNodeStore("memory", false, seedValue);
-
-        // Persistent backend tests
-        {
-            testNodeStore("nudb", true, seedValue);
-
-#if XRPL_ROCKSDB_AVAILABLE
-            testNodeStore("rocksdb", true, seedValue);
-#endif
-        }
-
-        // Import tests
-        {
-            testImport("nudb", "nudb", seedValue);
-
-#if XRPL_ROCKSDB_AVAILABLE
-            testImport("rocksdb", "rocksdb", seedValue);
-#endif
-
-#if XRPL_ENABLE_SQLITE_BACKEND_TESTS
-            testImport("sqlite", "sqlite", seedValue);
-#endif
-        }
     }
 };
 
-BEAST_DEFINE_TESTSUITE(Database, nodestore, xrpl);
+BEAST_DEFINE_TESTSUITE(DatabaseConfig, nodestore, xrpl);
 
-}  // namespace xrpl::NodeStore
+}  // namespace xrpl::node_store
diff --git a/src/test/nodestore/NuDBFactory_test.cpp b/src/test/nodestore/NuDBFactory_test.cpp
deleted file mode 100644
index d0675b3893..0000000000
--- a/src/test/nodestore/NuDBFactory_test.cpp
+++ /dev/null
@@ -1,443 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::NodeStore {
-
-class NuDBFactory_test : public TestBase
-{
-private:
-    // Helper function to create a Section with specified parameters
-    static Section
-    createSection(std::string const& path, std::string const& blockSize = "")
-    {
-        Section params;
-        params.set(Keys::kType, "nudb");
-        params.set(Keys::kPath, path);
-        if (!blockSize.empty())
-            params.set(Keys::kNudbBlockSize, blockSize);
-        return params;
-    }
-
-    // Helper function to create a backend and test basic functionality
-    bool
-    testBackendFunctionality(Section const& params, std::size_t expectedBlocksize)
-    {
-        try
-        {
-            DummyScheduler scheduler;
-            test::SuiteJournal journal("NuDBFactory_test", *this);
-
-            auto backend =
-                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-
-            if (!BEAST_EXPECT(backend))
-                return false;
-
-            if (!BEAST_EXPECT(backend->getBlockSize() == expectedBlocksize))
-                return false;
-
-            backend->open();
-
-            if (!BEAST_EXPECT(backend->isOpen()))
-                return false;
-
-            // Test basic store/fetch functionality
-            auto batch = createPredictableBatch(10, 12345);
-            storeBatch(*backend, batch);
-
-            Batch copy;
-            fetchCopyOfBatch(*backend, ©, batch);
-
-            backend->close();
-
-            return areBatchesEqual(batch, copy);
-        }
-        catch (...)
-        {
-            return false;
-        }
-    }
-
-    // Helper function to test log messages
-    void
-    testLogMessage(Section const& params, beast::Severity level, std::string const& expectedMessage)
-    {
-        test::StreamSink sink(level);
-        beast::Journal const journal(sink);
-
-        DummyScheduler scheduler;
-        auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-
-        std::string const logOutput = sink.messages().str();
-        BEAST_EXPECT(logOutput.contains(expectedMessage));
-    }
-
-    // Helper function to test power of two validation
-    void
-    testPowerOfTwoValidation(std::string const& size, bool shouldWork)
-    {
-        beast::TempDir const tempDir;
-        auto params = createSection(tempDir.path(), size);
-
-        test::StreamSink sink(beast::Severity::Warning);
-        beast::Journal const journal(sink);
-
-        DummyScheduler scheduler;
-        auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-
-        std::string const logOutput = sink.messages().str();
-        bool const hasWarning = logOutput.contains("Invalid nudb_block_size");
-
-        BEAST_EXPECT(hasWarning == !shouldWork);
-    }
-
-public:
-    void
-    testDefaultBlockSize()
-    {
-        testcase("Default block size (no nudb_block_size specified)");
-
-        beast::TempDir const tempDir;
-        auto params = createSection(tempDir.path());
-
-        // Should work with default 4096 block size
-        BEAST_EXPECT(testBackendFunctionality(params, 4096));
-    }
-
-    void
-    testValidBlockSizes()
-    {
-        testcase("Valid block sizes");
-
-        std::vector const validSizes = {4096, 8192, 16384, 32768};
-
-        for (auto const& size : validSizes)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), to_string(size));
-
-            BEAST_EXPECT(testBackendFunctionality(params, size));
-        }
-        // Empty value is ignored by the config parser, so uses the
-        // default
-        beast::TempDir const tempDir;
-        auto params = createSection(tempDir.path(), "");
-
-        BEAST_EXPECT(testBackendFunctionality(params, 4096));
-    }
-
-    void
-    testInvalidBlockSizes()
-    {
-        testcase("Invalid block sizes");
-
-        std::vector const invalidSizes = {
-            "2048",    // Too small
-            "1024",    // Too small
-            "65536",   // Too large
-            "131072",  // Too large
-            "5000",    // Not power of 2
-            "6000",    // Not power of 2
-            "10000",   // Not power of 2
-            "0",       // Zero
-            "-1",      // Negative
-            "abc",     // Non-numeric
-            "4k",      // Invalid format
-            "4096.5"   // Decimal
-        };
-
-        for (auto const& size : invalidSizes)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), size);
-
-            // Fails
-            BEAST_EXPECT(!testBackendFunctionality(params, 4096));
-        }
-
-        // Test whitespace cases separately since lexical_cast may handle them
-        std::vector const whitespaceInvalidSizes = {
-            "4096 ",  // Trailing space - might be handled by lexical_cast
-            " 4096"   // Leading space - might be handled by lexical_cast
-        };
-
-        for (auto const& size : whitespaceInvalidSizes)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), size);
-
-            // Fails
-            BEAST_EXPECT(!testBackendFunctionality(params, 4096));
-        }
-    }
-
-    void
-    testLogMessages()
-    {
-        testcase("Log message verification");
-
-        // Test valid custom block size logging
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), "8192");
-
-            testLogMessage(params, beast::Severity::Info, "Using custom NuDB block size: 8192");
-        }
-
-        // Test invalid block size failure
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), "5000");
-
-            test::StreamSink sink(beast::Severity::Warning);
-            beast::Journal const journal(sink);
-
-            DummyScheduler scheduler;
-            try
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                std::string const logOutput{e.what()};
-                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size: 5000"));
-                BEAST_EXPECT(logOutput.contains("Must be power of 2 between 4096 and 32768"));
-            }
-        }
-
-        // Test non-numeric value failure
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), "invalid");
-
-            test::StreamSink sink(beast::Severity::Warning);
-            beast::Journal const journal(sink);
-
-            DummyScheduler scheduler;
-            try
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-
-                fail();
-            }
-            catch (std::exception const& e)
-            {
-                std::string const logOutput{e.what()};
-                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size value: invalid"));
-            }
-        }
-    }
-
-    void
-    testPowerOfTwoValidation()
-    {
-        testcase("Power of 2 validation logic");
-
-        // Test edge cases around valid range
-        std::vector> const testCases = {
-            {"4095", false},   // Just below minimum
-            {"4096", true},    // Minimum valid
-            {"4097", false},   // Just above minimum, not power of 2
-            {"8192", true},    // Valid power of 2
-            {"8193", false},   // Just above valid power of 2
-            {"16384", true},   // Valid power of 2
-            {"32768", true},   // Maximum valid
-            {"32769", false},  // Just above maximum
-            {"65536", false}   // Power of 2 but too large
-        };
-
-        for (auto const& [size, shouldWork] : testCases)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), size);
-
-            // We test the validation logic by catching exceptions for invalid
-            // values
-            test::StreamSink sink(beast::Severity::Warning);
-            beast::Journal const journal(sink);
-
-            DummyScheduler scheduler;
-            try
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-                BEAST_EXPECT(shouldWork);
-            }
-            catch (std::exception const& e)
-            {
-                std::string const logOutput{e.what()};
-                BEAST_EXPECT(logOutput.contains("Invalid nudb_block_size"));
-            }
-        }
-    }
-
-    void
-    testBothConstructorVariants()
-    {
-        testcase("Both constructor variants work with custom block size");
-
-        beast::TempDir const tempDir;
-        auto params = createSection(tempDir.path(), "16384");
-
-        DummyScheduler scheduler;
-        test::SuiteJournal journal("NuDBFactory_test", *this);
-
-        // Test first constructor (without nudb::context)
-        {
-            auto backend1 =
-                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-            BEAST_EXPECT(backend1 != nullptr);
-            BEAST_EXPECT(testBackendFunctionality(params, 16384));
-        }
-
-        // Test second constructor (with nudb::context)
-        // Note: This would require access to nudb::context, which might not be
-        // easily testable without more complex setup. For now, we test that
-        // the factory can create backends with the first constructor.
-    }
-
-    void
-    testConfigurationParsing()
-    {
-        testcase("Configuration parsing edge cases");
-
-        // Test that whitespace is handled correctly
-        std::vector const validFormats = {
-            "8192"  // Basic valid format
-        };
-
-        // Test whitespace handling separately since lexical_cast behavior may
-        // vary
-        std::vector const whitespaceFormats = {
-            " 8192",  // Leading space - may or may not be handled by
-                      // lexical_cast
-            "8192 "   // Trailing space - may or may not be handled by
-                      // lexical_cast
-        };
-
-        // Test basic valid format
-        for (auto const& format : validFormats)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), format);
-
-            test::StreamSink sink(beast::Severity::Info);
-            beast::Journal const journal(sink);
-
-            DummyScheduler scheduler;
-            auto backend =
-                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-
-            // Should log success message for valid values
-            std::string const logOutput = sink.messages().str();
-            bool const hasSuccessMessage = logOutput.contains("Using custom NuDB block size");
-            BEAST_EXPECT(hasSuccessMessage);
-        }
-
-        // Test whitespace formats - these should work if lexical_cast handles
-        // them
-        for (auto const& format : whitespaceFormats)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), format);
-
-            // Use a lower threshold to capture both info and warning messages
-            test::StreamSink sink(beast::Severity::Debug);
-            beast::Journal const journal(sink);
-
-            DummyScheduler scheduler;
-            try
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-                fail();
-            }
-            catch (...)
-            {
-                // Fails
-                BEAST_EXPECT(!testBackendFunctionality(params, 8192));
-            }
-        }
-    }
-
-    void
-    testDataPersistence()
-    {
-        testcase("Data persistence with different block sizes");
-
-        std::vector const blockSizes = {"4096", "8192", "16384", "32768"};
-
-        for (auto const& size : blockSizes)
-        {
-            beast::TempDir const tempDir;
-            auto params = createSection(tempDir.path(), size);
-
-            DummyScheduler scheduler;
-            test::SuiteJournal journal("NuDBFactory_test", *this);
-
-            // Create test data
-            auto batch = createPredictableBatch(50, 54321);
-
-            // Store data
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-                backend->open();
-                storeBatch(*backend, batch);
-                backend->close();
-            }
-
-            // Retrieve data in new backend instance
-            {
-                auto backend =
-                    Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
-                backend->open();
-
-                Batch copy;
-                fetchCopyOfBatch(*backend, ©, batch);
-
-                BEAST_EXPECT(areBatchesEqual(batch, copy));
-                backend->close();
-            }
-        }
-    }
-
-    void
-    run() override
-    {
-        testDefaultBlockSize();
-        testValidBlockSizes();
-        testInvalidBlockSizes();
-        testLogMessages();
-        testPowerOfTwoValidation();
-        testBothConstructorVariants();
-        testConfigurationParsing();
-        testDataPersistence();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(NuDBFactory, xrpl_core, xrpl);
-
-}  // namespace xrpl::NodeStore
diff --git a/src/test/nodestore/TestBase.h b/src/test/nodestore/TestBase.h
deleted file mode 100644
index 235e76501f..0000000000
--- a/src/test/nodestore/TestBase.h
+++ /dev/null
@@ -1,202 +0,0 @@
-#pragma once
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::NodeStore {
-
-/**
- * Binary function that satisfies the strict-weak-ordering requirement.
- *
- * This compares the hashes of both objects and returns true if
- * the first hash is considered to go before the second.
- *
- * @see std::sort
- */
-struct LessThan
-{
-    bool
-    operator()(std::shared_ptr const& lhs, std::shared_ptr const& rhs)
-        const noexcept
-    {
-        return lhs->getHash() < rhs->getHash();
-    }
-};
-
-/**
- * Returns `true` if objects are identical.
- */
-inline bool
-isSame(std::shared_ptr const& lhs, std::shared_ptr const& rhs)
-{
-    return (lhs->getType() == rhs->getType()) && (lhs->getHash() == rhs->getHash()) &&
-        (lhs->getData() == rhs->getData());
-}
-
-// Some common code for the unit tests
-//
-class TestBase : public beast::unit_test::Suite
-{
-public:
-    // Tunable parameters
-    //
-    static std::size_t const kMinPayloadBytes = 1;
-    static std::size_t const kMaxPayloadBytes = 2000;
-    static int const kNumObjectsToTest = 2000;
-
-public:
-    // Create a predictable batch of objects
-    static Batch
-    createPredictableBatch(int numObjects, std::uint64_t seed)
-    {
-        Batch batch;
-        batch.reserve(numObjects);
-
-        beast::xor_shift_engine rng(seed);
-
-        for (int i = 0; i < numObjects; ++i)
-        {
-            NodeObjectType const type = [&] {
-                switch (randInt(rng, 3))
-                {
-                    case 0:
-                        return NodeObjectType::Ledger;
-                    case 1:
-                        return NodeObjectType::AccountNode;
-                    case 2:
-                        return NodeObjectType::TransactionNode;
-                    case 3:
-                    default:
-                        return NodeObjectType::Unknown;
-                }
-            }();
-
-            uint256 hash;
-            beast::rngfill(hash.begin(), hash.size(), rng);
-
-            Blob blob(randInt(rng, kMinPayloadBytes, kMaxPayloadBytes));
-            beast::rngfill(blob.data(), blob.size(), rng);
-
-            batch.push_back(NodeObject::createObject(type, std::move(blob), hash));
-        }
-
-        return batch;
-    }
-
-    // Compare two batches for equality
-    static bool
-    areBatchesEqual(Batch const& lhs, Batch const& rhs)
-    {
-        bool result = true;
-
-        if (lhs.size() == rhs.size())
-        {
-            for (int i = 0; i < lhs.size(); ++i)
-            {
-                if (!isSame(lhs[i], rhs[i]))
-                {
-                    result = false;
-                    break;
-                }
-            }
-        }
-        else
-        {
-            result = false;
-        }
-
-        return result;
-    }
-
-    // Store a batch in a backend
-    static void
-    storeBatch(Backend& backend, Batch const& batch)
-    {
-        for (auto const& object : batch)
-        {
-            backend.store(object);
-        }
-    }
-
-    // Get a copy of a batch in a backend
-    void
-    fetchCopyOfBatch(Backend& backend, Batch* pCopy, Batch const& batch)
-    {
-        pCopy->clear();
-        pCopy->reserve(batch.size());
-
-        for (auto const& expected : batch)
-        {
-            std::shared_ptr object;
-
-            Status const status = backend.fetch(expected->getHash(), &object);
-
-            BEAST_EXPECT(status == Status::Ok);
-
-            if (status == Status::Ok)
-            {
-                BEAST_EXPECT(object != nullptr);
-
-                pCopy->push_back(object);
-            }
-        }
-    }
-
-    void
-    fetchMissing(Backend& backend, Batch const& batch)
-    {
-        for (auto const& expected : batch)
-        {
-            std::shared_ptr object;
-
-            Status const status = backend.fetch(expected->getHash(), &object);
-
-            BEAST_EXPECT(status == Status::NotFound);
-        }
-    }
-
-    // Store all objects in a batch
-    static void
-    storeBatch(Database& db, Batch const& batch)
-    {
-        for (auto const& object : batch)
-        {
-            Blob data(object->getData());
-
-            db.store(object->getType(), std::move(data), object->getHash(), db.earliestLedgerSeq());
-        }
-    }
-
-    // Fetch all the hashes in one batch, into another batch.
-    static void
-    fetchCopyOfBatch(Database& db, Batch* pCopy, Batch const& batch)
-    {
-        pCopy->clear();
-        pCopy->reserve(batch.size());
-
-        for (auto const& expected : batch)
-        {
-            std::shared_ptr const object = db.fetchNodeObject(expected->getHash(), 0);
-
-            if (object != nullptr)
-                pCopy->push_back(object);
-        }
-    }
-};
-
-}  // namespace xrpl::NodeStore
diff --git a/src/test/nodestore/import_test.cpp b/src/test/nodestore/import_test.cpp
index d8c4a96713..c30d77029d 100644
--- a/src/test/nodestore/import_test.cpp
+++ b/src/test/nodestore/import_test.cpp
@@ -195,7 +195,7 @@ fmtdur(std::chrono::duration const& d)
 
 }  // namespace detail
 
-namespace NodeStore {
+namespace node_store {
 
 //------------------------------------------------------------------------------
 
@@ -552,5 +552,5 @@ BEAST_DEFINE_TESTSUITE_MANUAL(import, nodestore, xrpl);
 
 //------------------------------------------------------------------------------
 
-}  // namespace NodeStore
+}  // namespace node_store
 }  // namespace xrpl
diff --git a/src/test/nodestore/varint_test.cpp b/src/test/nodestore/varint_test.cpp
deleted file mode 100644
index 68e88d831a..0000000000
--- a/src/test/nodestore/varint_test.cpp
+++ /dev/null
@@ -1,57 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::NodeStore::tests {
-
-class varint_test : public beast::unit_test::Suite
-{
-public:
-    void
-    testVarints(std::vector vv)
-    {
-        testcase("encode, decode");
-        for (auto const v : vv)
-        {
-            std::array::kMax> vi{};
-            auto const n0 = writeVarint(vi.data(), v);
-            expect(n0 > 0, "write error");
-            expect(n0 == sizeVarint(v), "size error");
-            std::size_t v1 = 0;
-            auto const n1 = readVarint(vi.data(), n0, v1);
-            expect(n1 == n0, "read error");
-            expect(v == v1, "wrong value");
-        }
-    }
-
-    void
-    run() override
-    {
-        testVarints(
-            {0,
-             1,
-             2,
-             126,
-             127,
-             128,
-             253,
-             254,
-             255,
-             16127,
-             16128,
-             16129,
-             0xff,
-             0xffff,
-             0xffffffff,
-             0xffffffffffffUL,
-             0xffffffffffffffffUL});
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(varint, nodestore, xrpl);
-
-}  // namespace xrpl::NodeStore::tests
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/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp
index e579989181..6c9105164a 100644
--- a/src/test/overlay/TMGetObjectByHash_test.cpp
+++ b/src/test/overlay/TMGetObjectByHash_test.cpp
@@ -8,7 +8,6 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
@@ -16,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -44,7 +44,7 @@ using namespace jtx;
  * Test for TMGetObjectByHash reply size limiting.
  *
  * This verifies the fix that limits TMGetObjectByHash replies to
- * Tuning::hardMaxReplyNodes to prevent excessive memory usage and
+ * tuning::hardMaxReplyNodes to prevent excessive memory usage and
  * potential DoS attacks from peers requesting large numbers of objects.
  */
 class TMGetObjectByHash_test : public beast::unit_test::Suite
@@ -61,11 +61,11 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
     public:
         PeerTest(
             Application& app,
-            std::shared_ptr const& slot,
+            std::shared_ptr const& slot,
             http_request_type&& request,
             PublicKey const& publicKey,
             ProtocolVersion protocol,
-            Resource::Consumer consumer,
+            resource::Consumer consumer,
             std::unique_ptr&& streamPtr,
             OverlayImpl& overlay)
             : PeerImp(
@@ -133,8 +133,8 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
         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);
+        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);
@@ -227,7 +227,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite
     void
     run() override
     {
-        int const limit = static_cast(Tuning::kHardMaxReplyNodes);
+        int const limit = static_cast(tuning::kHardMaxReplyNodes);
         testReplyLimit(limit + 1, limit);
         testReplyLimit(limit, limit);
         testReplyLimit(limit - 1, limit - 1);
diff --git a/src/test/overlay/base_squelch_test.cpp b/src/test/overlay/base_squelch_test.cpp
index b31568be04..63eea26768 100644
--- a/src/test/overlay/base_squelch_test.cpp
+++ b/src/test/overlay/base_squelch_test.cpp
@@ -108,13 +108,13 @@ public:
     send(std::shared_ptr const& m) override
     {
     }
-    [[nodiscard]] beast::IP::Endpoint
+    [[nodiscard]] beast::ip::Endpoint
     getRemoteAddress() const override
     {
         return {};
     }
     void
-    charge(Resource::Charge const& fee, std::string const& context = {}) override
+    charge(resource::Charge const& fee, std::string const& context = {}) override
     {
     }
     [[nodiscard]] bool
@@ -162,7 +162,7 @@ public:
     setPublisherListSequence(PublicKey const&, std::size_t const) override
     {
     }
-    [[nodiscard]] uint256 const&
+    [[nodiscard]] uint256
     getClosedLedgerHash() const override
     {
         static uint256 const kHash{};
@@ -1642,7 +1642,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 = makeRequest(
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/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp
index a0d91d3aed..8626d3e19c 100644
--- a/src/test/overlay/tx_reduce_relay_test.cpp
+++ b/src/test/overlay/tx_reduce_relay_test.cpp
@@ -9,12 +9,12 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -121,11 +121,11 @@ private:
     public:
         PeerTest(
             Application& app,
-            std::shared_ptr const& slot,
+            std::shared_ptr const& slot,
             http_request_type&& request,
             PublicKey const& publicKey,
             ProtocolVersion protocol,
-            Resource::Consumer consumer,
+            resource::Consumer consumer,
             std::unique_ptr&& streamPtr,
             OverlayImpl& overlay)
             : PeerImp(
@@ -192,9 +192,9 @@ private:
         auto streamPtr = std::make_unique(
             socket_type(std::forward(env.app().getIOContext())),
             *context_);
-        beast::IP::Endpoint const local(
+        beast::ip::Endpoint const local(
             boost::asio::ip::make_address("172.1.1." + std::to_string(lid_)));
-        beast::IP::Endpoint const remote(
+        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);
diff --git a/src/test/peerfinder/Livecache_test.cpp b/src/test/peerfinder/Livecache_test.cpp
deleted file mode 100644
index 4f2d6e97e1..0000000000
--- a/src/test/peerfinder/Livecache_test.cpp
+++ /dev/null
@@ -1,212 +0,0 @@
-#include 
-#include 
-
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::PeerFinder {
-
-bool
-operator==(Endpoint const& a, Endpoint const& b)
-{
-    return (a.hops == b.hops && a.address == b.address);
-}
-
-class Livecache_test : public beast::unit_test::Suite
-{
-    TestStopwatch clock_;
-    test::SuiteJournal journal_;
-
-public:
-    Livecache_test() : journal_("Livecache_test", *this)
-    {
-    }
-
-    // Add the address as an endpoint
-    template 
-    void
-    add(beast::IP::Endpoint ep, C& c, std::uint32_t hops = 0)
-    {
-        Endpoint const cep{ep, hops};
-        c.insert(cep);
-    }
-
-    void
-    testBasicInsert()
-    {
-        testcase("Basic Insert");
-        Livecache<> c(clock_, journal_);
-        BEAST_EXPECT(c.empty());
-
-        for (auto i = 0; i < 10; ++i)
-            add(beast::IP::randomEP(true), c);
-
-        BEAST_EXPECT(!c.empty());
-        BEAST_EXPECT(c.size() == 10);
-
-        for (auto i = 0; i < 10; ++i)
-            add(beast::IP::randomEP(false), c);
-
-        BEAST_EXPECT(!c.empty());
-        BEAST_EXPECT(c.size() == 20);
-    }
-
-    void
-    testInsertUpdate()
-    {
-        testcase("Insert/Update");
-        Livecache<> c(clock_, journal_);
-
-        auto ep1 = Endpoint{beast::IP::randomEP(), 2};
-        c.insert(ep1);
-        BEAST_EXPECT(c.size() == 1);
-        // third position list will contain the entry
-        BEAST_EXPECT((c.hops.begin() + 2)->begin()->hops == 2);
-
-        auto ep2 = Endpoint{ep1.address, 4};
-        // this will not change the entry has higher hops
-        c.insert(ep2);
-        BEAST_EXPECT(c.size() == 1);
-        // still in third position list
-        BEAST_EXPECT((c.hops.begin() + 2)->begin()->hops == 2);
-
-        auto ep3 = Endpoint{ep1.address, 2};
-        // this will not change the entry has the same hops as existing
-        c.insert(ep3);
-        BEAST_EXPECT(c.size() == 1);
-        // still in third position list
-        BEAST_EXPECT((c.hops.begin() + 2)->begin()->hops == 2);
-
-        auto ep4 = Endpoint{ep1.address, 1};
-        c.insert(ep4);
-        BEAST_EXPECT(c.size() == 1);
-        // now at second position list
-        BEAST_EXPECT((c.hops.begin() + 1)->begin()->hops == 1);
-    }
-
-    void
-    testExpire()
-    {
-        testcase("Expire");
-        using namespace std::chrono_literals;
-        Livecache<> c(clock_, journal_);
-
-        auto ep1 = Endpoint{beast::IP::randomEP(), 1};
-        c.insert(ep1);
-        BEAST_EXPECT(c.size() == 1);
-        c.expire();
-        BEAST_EXPECT(c.size() == 1);
-        // verify that advancing to 1 sec before expiration
-        // leaves our entry intact
-        clock_.advance(Tuning::kLiveCacheSecondsToLive - 1s);
-        c.expire();
-        BEAST_EXPECT(c.size() == 1);
-        // now advance to the point of expiration
-        clock_.advance(1s);
-        c.expire();
-        BEAST_EXPECT(c.empty());
-    }
-
-    void
-    testHistogram()
-    {
-        testcase("Histogram");
-        static constexpr auto kNumEps = 40;
-        Livecache<> c(clock_, journal_);
-        for (auto i = 0; i < kNumEps; ++i)
-            add(beast::IP::randomEP(true), c, xrpl::randInt());
-        auto h = c.hops.histogram();
-        if (!BEAST_EXPECT(!h.empty()))
-            return;
-        std::vector v;
-        boost::split(v, h, boost::algorithm::is_any_of(","));
-        auto sum = 0;
-        for (auto const& n : v)
-        {
-            auto val = boost::lexical_cast(boost::trim_copy(n));
-            sum += val;
-            BEAST_EXPECT(val >= 0);
-        }
-        BEAST_EXPECT(sum == kNumEps);
-    }
-
-    void
-    testShuffle()
-    {
-        testcase("Shuffle");
-        Livecache<> c(clock_, journal_);
-        for (auto i = 0; i < 100; ++i)
-            add(beast::IP::randomEP(true), c, xrpl::randInt(Tuning::kMaxHops + 1));
-
-        using at_hop = std::vector;
-        using all_hops = std::array;
-
-        auto cmpEp = [](Endpoint const& a, Endpoint const& b) {
-            return (b.hops < a.hops || (b.hops == a.hops && b.address < a.address));
-        };
-        all_hops before;
-        all_hops beforeSorted;
-        for (auto i = std::make_pair(0, c.hops.begin()); i.second != c.hops.end();
-             ++i.first, ++i.second)
-        {
-            std::ranges::copy(*i.second, std::back_inserter(before[i.first]));
-            std::ranges::copy(*i.second, std::back_inserter(beforeSorted[i.first]));
-            std::ranges::sort(beforeSorted[i.first], cmpEp);
-        }
-
-        c.hops.shuffle();
-
-        all_hops after;
-        all_hops afterSorted;
-        for (auto i = std::make_pair(0, c.hops.begin()); i.second != c.hops.end();
-             ++i.first, ++i.second)
-        {
-            std::ranges::copy(*i.second, std::back_inserter(after[i.first]));
-            std::ranges::copy(*i.second, std::back_inserter(afterSorted[i.first]));
-            std::ranges::sort(afterSorted[i.first], cmpEp);
-        }
-
-        // each hop bucket should contain the same items
-        // before and after sort, albeit in different order
-        bool allMatch = true;
-        for (auto i = 0; i < before.size(); ++i)
-        {
-            BEAST_EXPECT(before[i].size() == after[i].size());
-            allMatch = allMatch && (before[i] == after[i]);
-            BEAST_EXPECT(beforeSorted[i] == afterSorted[i]);
-        }
-        BEAST_EXPECT(!allMatch);
-    }
-
-    void
-    run() override
-    {
-        testBasicInsert();
-        testInsertUpdate();
-        testExpire();
-        testHistogram();
-        testShuffle();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Livecache, peerfinder, xrpl);
-
-}  // namespace xrpl::PeerFinder
diff --git a/src/test/peerfinder/PeerFinder_test.cpp b/src/test/peerfinder/PeerFinder_test.cpp
deleted file mode 100644
index cf91800951..0000000000
--- a/src/test/peerfinder/PeerFinder_test.cpp
+++ /dev/null
@@ -1,789 +0,0 @@
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::PeerFinder {
-
-class PeerFinder_test : public beast::unit_test::Suite
-{
-    test::SuiteJournal journal_;
-
-public:
-    PeerFinder_test() : journal_("PeerFinder_test", *this)
-    {
-    }
-
-    struct TestStore : Store
-    {
-        std::size_t
-        load(load_callback const& cb) override
-        {
-            return 0;
-        }
-
-        void
-        save(std::vector const&) override
-        {
-        }
-    };
-
-    struct TestChecker
-    {
-        void
-        stop()
-        {
-        }
-
-        void
-        wait()
-        {
-        }
-
-        template 
-        void
-        asyncConnect(beast::IP::Endpoint const& ep, Handler&& handler)
-        {
-            // NOLINTNEXTLINE(misc-const-correctness)
-            boost::system::error_code ec;
-            handler(ec);
-        }
-    };
-
-    void
-    testBackoff1()
-    {
-        auto const seconds = 10000;
-        testcase("backoff 1");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        logic.addFixedPeer("test", beast::IP::Endpoint::fromString("65.0.0.1:5"));
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            logic.config(c);
-        }
-        std::size_t n = 0;
-        for (std::size_t i = 0; i < seconds; ++i)
-        {
-            auto const list = logic.autoconnect();
-            if (!list.empty())
-            {
-                BEAST_EXPECT(list.size() == 1);
-                auto const [slot, _] = logic.newOutboundSlot(list.front());
-                BEAST_EXPECT(
-                    logic.onConnected(slot, beast::IP::Endpoint::fromString("65.0.0.2:5")));
-                logic.onClosed(slot);
-                ++n;
-            }
-            clock.advance(std::chrono::seconds(1));
-            logic.oncePerSecond();
-        }
-        // Less than 20 attempts
-        BEAST_EXPECT(n < 20);
-    }
-
-    // with activate
-    void
-    testBackoff2()
-    {
-        auto const seconds = 10000;
-        testcase("backoff 2");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        logic.addFixedPeer("test", beast::IP::Endpoint::fromString("65.0.0.1:5"));
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            logic.config(c);
-        }
-
-        PublicKey const pk(randomKeyPair(KeyType::Secp256k1).first);
-        std::size_t n = 0;
-
-        for (std::size_t i = 0; i < seconds; ++i)
-        {
-            auto const list = logic.autoconnect();
-            if (!list.empty())
-            {
-                BEAST_EXPECT(list.size() == 1);
-                auto const [slot, _] = logic.newOutboundSlot(list.front());
-                if (!BEAST_EXPECT(
-                        logic.onConnected(slot, beast::IP::Endpoint::fromString("65.0.0.2:5"))))
-                    return;
-                if (!BEAST_EXPECT(logic.activate(slot, pk, false) == PeerFinder::Result::Success))
-                    return;
-                logic.onClosed(slot);
-                ++n;
-            }
-            clock.advance(std::chrono::seconds(1));
-            logic.oncePerSecond();
-        }
-        // No more often than once per minute
-        BEAST_EXPECT(n <= (seconds + 59) / 60);
-    }
-
-    // test accepting an incoming slot for an already existing outgoing slot
-    void
-    testDuplicateOutIn()
-    {
-        testcase("duplicate out/in");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            logic.config(c);
-        }
-
-        auto const remote = beast::IP::Endpoint::fromString("65.0.0.1:5");
-        auto const [slot1, r] = logic.newOutboundSlot(remote);
-        BEAST_EXPECT(slot1 != nullptr);
-        BEAST_EXPECT(r == Result::Success);
-        BEAST_EXPECT(logic.connectedAddresses.count(remote.address()) == 1);
-
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-        auto const [slot2, r2] = logic.newInboundSlot(local, remote);
-        BEAST_EXPECT(logic.connectedAddresses.count(remote.address()) == 1);
-        BEAST_EXPECT(r2 == Result::DuplicatePeer);
-
-        if (!BEAST_EXPECT(slot2 == nullptr))
-            logic.onClosed(slot2);
-
-        logic.onClosed(slot1);
-    }
-
-    // test establishing outgoing slot for an already existing incoming slot
-    void
-    testDuplicateInOut()
-    {
-        testcase("duplicate in/out");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            logic.config(c);
-        }
-
-        auto const remote = beast::IP::Endpoint::fromString("65.0.0.1:5");
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-
-        auto const [slot1, r] = logic.newInboundSlot(local, remote);
-        BEAST_EXPECT(slot1 != nullptr);
-        BEAST_EXPECT(r == Result::Success);
-        BEAST_EXPECT(logic.connectedAddresses.count(remote.address()) == 1);
-
-        auto const [slot2, r2] = logic.newOutboundSlot(remote);
-        BEAST_EXPECT(r2 == Result::DuplicatePeer);
-        BEAST_EXPECT(logic.connectedAddresses.count(remote.address()) == 1);
-        if (!BEAST_EXPECT(slot2 == nullptr))
-            logic.onClosed(slot2);
-        logic.onClosed(slot1);
-    }
-
-    void
-    testPeerLimitExceeded()
-    {
-        testcase("peer limit exceeded");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            logic.config(c);
-        }
-
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-        auto const [slot, r] =
-            logic.newInboundSlot(local, beast::IP::Endpoint::fromString("55.104.0.2:1025"));
-        BEAST_EXPECT(slot != nullptr);
-        BEAST_EXPECT(r == Result::Success);
-
-        auto const [slot1, r1] =
-            logic.newInboundSlot(local, beast::IP::Endpoint::fromString("55.104.0.2:1026"));
-        BEAST_EXPECT(slot1 != nullptr);
-        BEAST_EXPECT(r1 == Result::Success);
-
-        auto const [slot2, r2] =
-            logic.newInboundSlot(local, beast::IP::Endpoint::fromString("55.104.0.2:1027"));
-        BEAST_EXPECT(r2 == Result::IpLimitExceeded);
-
-        if (!BEAST_EXPECT(slot2 == nullptr))
-            logic.onClosed(slot2);
-        logic.onClosed(slot1);
-        logic.onClosed(slot);
-    }
-
-    void
-    testActivateDuplicatePeer()
-    {
-        testcase("test activate duplicate peer");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            logic.config(c);
-        }
-
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-
-        PublicKey const pk1(randomKeyPair(KeyType::Secp256k1).first);
-
-        auto const [slot, rSlot] =
-            logic.newOutboundSlot(beast::IP::Endpoint::fromString("55.104.0.2:1025"));
-        BEAST_EXPECT(slot != nullptr);
-        BEAST_EXPECT(rSlot == Result::Success);
-
-        auto const [slot2, r2Slot] =
-            logic.newOutboundSlot(beast::IP::Endpoint::fromString("55.104.0.2:1026"));
-        BEAST_EXPECT(slot2 != nullptr);
-        BEAST_EXPECT(r2Slot == Result::Success);
-
-        BEAST_EXPECT(logic.onConnected(slot, local));
-        BEAST_EXPECT(logic.onConnected(slot2, local));
-
-        BEAST_EXPECT(logic.activate(slot, pk1, false) == Result::Success);
-
-        // activating a different slot with the same node ID (pk) must fail
-        BEAST_EXPECT(logic.activate(slot2, pk1, false) == Result::DuplicatePeer);
-
-        logic.onClosed(slot);
-
-        // accept the same key for a new slot after removing the old slot
-        BEAST_EXPECT(logic.activate(slot2, pk1, false) == Result::Success);
-        logic.onClosed(slot2);
-    }
-
-    void
-    testActivateInboundDisabled()
-    {
-        testcase("test activate inbound disabled");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            logic.config(c);
-        }
-
-        PublicKey const pk1(randomKeyPair(KeyType::Secp256k1).first);
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-
-        auto const [slot, rSlot] =
-            logic.newInboundSlot(local, beast::IP::Endpoint::fromString("55.104.0.2:1025"));
-        BEAST_EXPECT(slot != nullptr);
-        BEAST_EXPECT(rSlot == Result::Success);
-
-        BEAST_EXPECT(logic.activate(slot, pk1, false) == Result::InboundDisabled);
-
-        {
-            Config c;
-            c.autoConnect = false;
-            c.listeningPort = 1024;
-            c.ipLimit = 2;
-            c.inPeers = 1;
-            logic.config(c);
-        }
-        // new inbound slot must succeed when inbound connections are enabled
-        BEAST_EXPECT(logic.activate(slot, pk1, false) == Result::Success);
-
-        // creating a new inbound slot must succeed as IP Limit is not exceeded
-        auto const [slot2, r2Slot] =
-            logic.newInboundSlot(local, beast::IP::Endpoint::fromString("55.104.0.2:1026"));
-        BEAST_EXPECT(slot2 != nullptr);
-        BEAST_EXPECT(r2Slot == Result::Success);
-
-        PublicKey const pk2(randomKeyPair(KeyType::Secp256k1).first);
-
-        // an inbound slot exceeding inPeers limit must fail
-        BEAST_EXPECT(logic.activate(slot2, pk2, false) == Result::Full);
-
-        logic.onClosed(slot2);
-        logic.onClosed(slot);
-    }
-
-    void
-    testAddFixedPeerNoPort()
-    {
-        testcase("test addFixedPeer no port");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-        try
-        {
-            logic.addFixedPeer("test", beast::IP::Endpoint::fromString("65.0.0.2"));
-            fail("invalid endpoint successfully added");
-        }
-        catch (std::runtime_error const& e)
-        {
-            pass();
-        }
-    }
-
-    void
-    testIsValidAddress()
-    {
-        testcase("is_valid_address");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-
-        auto const pass = [&](std::string const& s) {
-            BEAST_EXPECT(logic.isValidAddress(beast::IP::Endpoint::fromString(s)));
-        };
-        auto const fail = [&](std::string const& s) {
-            BEAST_EXPECT(!logic.isValidAddress(beast::IP::Endpoint::fromString(s)));
-        };
-
-        // Invalid: port 0
-        fail("65.0.0.1:0");
-
-        // --- IPv4 ranges ---
-        // For each range: 1 before (pass), first (fail), last (fail),
-        // 1 after (pass)
-
-        // 0.0.0.0/8 - "This network"
-        // No "before" - nothing before 0.0.0.0
-        fail("0.0.0.0:8080");
-        fail("0.255.255.255:8080");
-        pass("1.0.0.0:8080");
-
-        // 10.0.0.0/8 - Private (RFC 1918)
-        pass("9.255.255.255:8080");
-        fail("10.0.0.0:8080");
-        fail("10.255.255.255:8080");
-        pass("11.0.0.0:8080");
-
-        // 100.64.0.0/10 - Shared Address Space / CGNAT (RFC 6598)
-        pass("100.63.255.255:8080");
-        fail("100.64.0.0:8080");
-        fail("100.127.255.255:8080");
-        pass("100.128.0.0:8080");
-
-        // 127.0.0.0/8 - Loopback
-        pass("126.255.255.255:8080");
-        fail("127.0.0.0:8080");
-        fail("127.255.255.255:8080");
-        pass("128.0.0.0:8080");
-
-        // 169.254.0.0/16 - Link-local
-        pass("169.253.255.255:8080");
-        fail("169.254.0.0:8080");
-        fail("169.254.255.255:8080");
-        pass("169.255.0.0:8080");
-
-        // 172.16.0.0/12 - Private (RFC 1918)
-        pass("172.15.255.255:8080");
-        fail("172.16.0.0:8080");
-        fail("172.31.255.255:8080");
-        pass("172.32.0.0:8080");
-
-        // 192.0.0.0/24 - IETF Protocol Assignments (RFC 6890)
-        pass("191.255.255.255:8080");
-        fail("192.0.0.0:8080");
-        fail("192.0.0.255:8080");
-        pass("192.0.1.0:8080");
-
-        // 192.0.2.0/24 - TEST-NET-1 (RFC 5737)
-        pass("192.0.1.255:8080");
-        fail("192.0.2.0:8080");
-        fail("192.0.2.255:8080");
-        pass("192.0.3.0:8080");
-
-        // 192.88.99.0/24 - 6to4 Relay Anycast (RFC 7526)
-        pass("192.88.98.255:8080");
-        fail("192.88.99.0:8080");
-        fail("192.88.99.255:8080");
-        pass("192.88.100.0:8080");
-
-        // 192.168.0.0/16 - Private (RFC 1918)
-        pass("192.167.255.255:8080");
-        fail("192.168.0.0:8080");
-        fail("192.168.255.255:8080");
-        pass("192.169.0.0:8080");
-
-        // 198.18.0.0/15 - Benchmarking (RFC 2544)
-        pass("198.17.255.255:8080");
-        fail("198.18.0.0:8080");
-        fail("198.19.255.255:8080");
-        pass("198.20.0.0:8080");
-
-        // 198.51.100.0/24 - TEST-NET-2 (RFC 5737)
-        pass("198.51.99.255:8080");
-        fail("198.51.100.0:8080");
-        fail("198.51.100.255:8080");
-        pass("198.51.101.0:8080");
-
-        // 203.0.113.0/24 - TEST-NET-3 (RFC 5737)
-        pass("203.0.112.255:8080");
-        fail("203.0.113.0:8080");
-        fail("203.0.113.255:8080");
-        pass("203.0.114.0:8080");
-
-        // 224.0.0.0/4 - Multicast
-        pass("223.255.255.255:8080");
-        fail("224.0.0.0:8080");
-        fail("239.255.255.255:8080");
-        // 240.0.0.0 (after multicast) is also blocked (reserved)
-
-        // 240.0.0.0/4 - Reserved (RFC 1112)
-        // 239.255.255.255 (before reserved) is also blocked (multicast)
-        fail("240.0.0.0:8080");
-        fail("255.255.255.255:8080");
-
-        // --- IPv6 ranges ---
-
-        // ::1 - Loopback (single address)
-        fail("[::1]:8080");
-
-        // :: - Unspecified (single address)
-        fail("[::]:8080");
-
-        // fc00::/7 - Unique Local Address (ULA)
-        pass("[fb00::1]:8080");
-        fail("[fc00::1]:8080");
-        fail("[fdff::1]:8080");
-        pass("[fe00::1]:8080");
-
-        // fe80::/10 - Link-local
-        pass("[fe7f::1]:8080");
-        fail("[fe80::1]:8080");
-        fail("[febf::1]:8080");
-        pass("[fec0::1]:8080");
-
-        // ff00::/8 - Multicast
-        pass("[feff::1]:8080");
-        fail("[ff00::1]:8080");
-        fail("[ffff::1]:8080");
-        // No "after" - ffff:... is the highest IPv6 range
-
-        // 100::/64 - Discard prefix (RFC 6666)
-        pass("[ff::1]:8080");
-        fail("[100::]:8080");
-        fail("[100::ffff:ffff:ffff:ffff]:8080");
-        pass("[100:0:0:1::1]:8080");
-
-        // 2001::/32 - IETF Protocol Assignments / Teredo (RFC 4380)
-        pass("[2000:ffff::1]:8080");
-        fail("[2001::]:8080");
-        fail("[2001:0:ffff::1]:8080");
-        pass("[2001:1::1]:8080");
-
-        // 2001:20::/28 - ORCHIDv2 (RFC 7343)
-        pass("[2001:1f::1]:8080");
-        fail("[2001:20::1]:8080");
-        fail("[2001:2f::1]:8080");
-        pass("[2001:30::1]:8080");
-
-        // 2001:db8::/32 - Documentation (RFC 3849)
-        pass("[2001:db7::1]:8080");
-        fail("[2001:db8::1]:8080");
-        fail("[2001:db8:ffff::1]:8080");
-        pass("[2001:db9::1]:8080");
-
-        // 2002::/16 - 6to4 (RFC 3056, deprecated)
-        pass("[2001:ffff::1]:8080");
-        fail("[2002::1]:8080");
-        fail("[2002:ffff::1]:8080");
-        pass("[2003::1]:8080");
-
-        // --- IPv6 v4-mapped (delegates to IPv4 checks) ---
-        fail("[::ffff:10.0.0.1]:8080");
-        fail("[::ffff:100.64.0.1]:8080");
-        fail("[::ffff:169.254.1.1]:8080");
-        fail("[::ffff:192.0.2.1]:8080");
-        fail("[::ffff:198.18.0.1]:8080");
-        fail("[::ffff:224.0.0.1]:8080");
-        fail("[::ffff:240.0.0.1]:8080");
-
-        // --- Valid public addresses ---
-        pass("8.8.8.8:443");
-        pass("65.0.0.1:8080");
-        pass("[2001:4860:4860::8888]:8080");
-        pass("[2606:4700:4700::1111]:8080");
-    }
-
-    void
-    testVerifyEndpoints()
-    {
-        // Helper that sets up a Logic instance, creates and activates a slot,
-        // then calls on_endpoints with the given list and returns the
-        // livecache size afterwards.
-        auto run = [&](bool verifyEndpoints, Endpoints eps) -> std::size_t {
-            TestStore store;
-            TestChecker checker;
-            TestStopwatch clock;
-            Logic logic(clock, store, checker, journal_);
-            {
-                Config c;
-                c.autoConnect = false;
-                c.listeningPort = 1024;
-                c.ipLimit = 2;
-                c.verifyEndpoints = verifyEndpoints;
-                logic.config(c);
-            }
-
-            auto const remote = beast::IP::Endpoint::fromString("65.0.0.1:5");
-            auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1024");
-
-            auto const [slot, r] = logic.newOutboundSlot(remote);
-            BEAST_EXPECT(slot != nullptr);
-            BEAST_EXPECT(r == Result::Success);
-            BEAST_EXPECT(logic.onConnected(slot, local));
-
-            PublicKey const pk(randomKeyPair(KeyType::Secp256k1).first);
-            BEAST_EXPECT(logic.activate(slot, pk, false) == Result::Success);
-
-            logic.onEndpoints(slot, std::move(eps));
-
-            auto const size = logic.livecache.size();
-            logic.onClosed(slot);
-            return size;
-        };
-
-        {
-            testcase("verify_endpoints enabled");
-
-            // Valid public addresses
-            Endpoints eps;
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.1:5"), 1);
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.2:6"), 1);
-            // Invalid: private address
-            eps.emplace_back(beast::IP::Endpoint::fromString("10.0.0.1:5"), 1);
-            // Invalid: port 0
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.3:0"), 1);
-
-            // With verification enabled, only the 2 valid endpoints survive
-            BEAST_EXPECT(run(true, eps) == 2);
-        }
-        {
-            testcase("verify_endpoints disabled");
-
-            Endpoints eps;
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.1:5"), 1);
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.2:6"), 1);
-            // Private address — kept when verification is off
-            eps.emplace_back(beast::IP::Endpoint::fromString("10.0.0.1:5"), 1);
-            // Port 0 — kept when verification is off
-            eps.emplace_back(beast::IP::Endpoint::fromString("44.0.0.3:0"), 1);
-
-            // Without verification, all 4 endpoints survive
-            BEAST_EXPECT(run(false, eps) == 4);
-        }
-    }
-
-    void
-    testOnConnectedSelfConnection()
-    {
-        testcase("test onConnected self connection");
-        TestStore store;
-        TestChecker checker;
-        TestStopwatch clock;
-        Logic logic(clock, store, checker, journal_);
-
-        auto const local = beast::IP::Endpoint::fromString("65.0.0.2:1234");
-        auto const [slot, r] = logic.newOutboundSlot(local);
-        BEAST_EXPECT(slot != nullptr);
-        BEAST_EXPECT(r == Result::Success);
-
-        // Must fail when a slot is to our own IP address
-        BEAST_EXPECT(!logic.onConnected(slot, local));
-        logic.onClosed(slot);
-    }
-
-    void
-    testConfig()
-    {
-        // if peers_max is configured then peers_in_max and peers_out_max
-        // are ignored
-        auto run = [&](std::string const& test,
-                       std::optional maxPeers,
-                       std::optional maxIn,
-                       std::optional maxOut,
-                       std::uint16_t port,
-                       std::uint16_t expectOut,
-                       std::uint16_t expectIn,
-                       std::uint16_t expectIpLimit) {
-            xrpl::Config c;
-
-            testcase(test);
-
-            std::string toLoad;
-            int max = 0;
-            if (maxPeers)
-            {
-                max = maxPeers.value();
-                toLoad += "[peers_max]\n" + std::to_string(max) + "\n" + "[peers_in_max]\n" +
-                    std::to_string(maxIn.value_or(0)) + "\n" + "[peers_out_max]\n" +
-                    std::to_string(maxOut.value_or(0)) + "\n";
-            }
-            else if (maxIn && maxOut)
-            {
-                toLoad += "[peers_in_max]\n" + std::to_string(*maxIn) + "\n" + "[peers_out_max]\n" +
-                    std::to_string(*maxOut) + "\n";
-            }
-
-            c.loadFromString(toLoad);
-            BEAST_EXPECT(
-                (c.peersMax == max && c.peersInMax == 0 && c.peersOutMax == 0) ||
-                (c.peersInMax == *maxIn && c.peersOutMax == *maxOut));
-
-            Config const config = Config::makeConfig(c, port, false, 0, true);
-
-            Counts counts;
-            counts.onConfig(config);
-            BEAST_EXPECT(
-                counts.outMax() == expectOut && counts.inMax() == expectIn &&
-                config.ipLimit == expectIpLimit);
-
-            TestStore store;
-            TestChecker checker;
-            TestStopwatch clock;
-            Logic logic(clock, store, checker, journal_);
-            logic.config(config);
-
-            BEAST_EXPECT(logic.config() == config);
-        };
-
-        // if max_peers == 0 => maxPeers = 21,
-        //   else if max_peers < 10 => maxPeers = 10 else maxPeers =
-        //   max_peers
-        // expectOut => if legacy => max(0.15 * maxPeers, 10),
-        //   if legacy && !wantIncoming => maxPeers else max_out_peers
-        // expectIn => if legacy && wantIncoming => maxPeers - outPeers
-        //   else if !wantIncoming => 0 else max_in_peers
-        // ipLimit => if expectIn <= 21 => 2 else 2 + min(5, expectIn/21)
-        // ipLimit = max(1, min(ipLimit, expectIn/2))
-
-        // legacy test with max_peers
-        run("legacy no config", {}, {}, {}, 4000, 10, 11, 2);
-        run("legacy max_peers 0", 0, 100, 10, 4000, 10, 11, 2);
-        run("legacy max_peers 5", 5, 100, 10, 4000, 10, 0, 1);
-        run("legacy max_peers 20", 20, 100, 10, 4000, 10, 10, 2);
-        run("legacy max_peers 100", 100, 100, 10, 4000, 15, 85, 6);
-        run("legacy max_peers 20, private", 20, 100, 10, 0, 20, 0, 1);
-
-        // test with max_in_peers and max_out_peers
-        run("new in 100/out 10", {}, 100, 10, 4000, 10, 100, 6);
-        run("new in 0/out 10", {}, 0, 10, 4000, 10, 0, 1);
-        run("new in 100/out 10, private", {}, 100, 10, 0, 10, 0, 6);
-    }
-
-    void
-    testInvalidConfig()
-    {
-        testcase("invalid config");
-
-        auto run = [&](std::string const& toLoad) {
-            xrpl::Config c;
-            try
-            {
-                c.loadFromString(toLoad);
-                fail();
-            }
-            catch (...)
-            {
-                pass();
-            }
-        };
-        run(R"xrpldConfig(
-[peers_in_max]
-100
-)xrpldConfig");
-        run(R"xrpldConfig(
-[peers_out_max]
-100
-)xrpldConfig");
-        run(R"xrpldConfig(
-[peers_in_max]
-100
-[peers_out_max]
-5
-)xrpldConfig");
-        run(R"xrpldConfig(
-[peers_in_max]
-1001
-[peers_out_max]
-10
-)xrpldConfig");
-        run(R"xrpldConfig(
-[peers_in_max]
-10
-[peers_out_max]
-1001
-)xrpldConfig");
-    }
-
-    void
-    run() override
-    {
-        testBackoff1();
-        testBackoff2();
-        testDuplicateOutIn();
-        testDuplicateInOut();
-        testConfig();
-        testInvalidConfig();
-        testPeerLimitExceeded();
-        testActivateDuplicatePeer();
-        testActivateInboundDisabled();
-        testAddFixedPeerNoPort();
-        testOnConnectedSelfConnection();
-        testIsValidAddress();
-        testVerifyEndpoints();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(PeerFinder, peerfinder, xrpl);
-
-}  // namespace xrpl::PeerFinder
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/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/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..be78864cac 100644
--- a/src/test/rpc/Handler_test.cpp
+++ b/src/test/rpc/Handler_test.cpp
@@ -88,7 +88,7 @@ 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());
+        std::vector names = test::jtx::makeVector(xrpl::rpc::getHandlerNames());
 
         std::uniform_int_distribution distr{0, names.size() - 1};
 
@@ -96,7 +96,7 @@ class Handler_test : public beast::unit_test::Suite
         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(1, false, names[i]);
                 dummy = dummy + i + (int)d->role;
             },
             [&]() -> std::size_t { return distr(prng); });
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..af93108ff2 100644
--- a/src/test/rpc/LedgerRPC_test.cpp
+++ b/src/test/rpc/LedgerRPC_test.cpp
@@ -158,7 +158,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.");
         }
 
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..8e719e6407 100644
--- a/src/test/rpc/NoRippleCheck_test.cpp
+++ b/src/test/rpc/NoRippleCheck_test.cpp
@@ -27,8 +27,6 @@
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 
@@ -203,13 +201,13 @@ class NoRippleCheck_test : public beast::unit_test::Suite
 
             if (user)
             {
-                BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You appear to have set"));
-                BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should probably set"));
+                BEAST_EXPECT(pa[0u].asString().starts_with("You appear to have set"));
+                BEAST_EXPECT(pa[1u].asString().starts_with("You should probably set"));
             }
             else
             {
-                BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You should immediately set"));
-                BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should clear"));
+                BEAST_EXPECT(pa[0u].asString().starts_with("You should immediately set"));
+                BEAST_EXPECT(pa[1u].asString().starts_with("You should clear"));
             }
         }
         else
@@ -286,9 +284,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 +299,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..ef3213008c 100644
--- a/src/test/rpc/RPCCall_test.cpp
+++ b/src/test/rpc/RPCCall_test.cpp
@@ -5855,8 +5855,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 +5870,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
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 60ea622616..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);
@@ -558,10 +557,12 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         using namespace test::jtx;
         using namespace boost::asio;
         using namespace boost::beast::http;
-        Env env{*this, envconfig([&](std::unique_ptr cfg) {
+        // Run the server with a single io thread so disconnectClient() below
+        // can deterministically drain the server's io_context (see its docs).
+        Env env{*this, singleThreadIo(envconfig([&](std::unique_ptr cfg) {
                     (*cfg)[Sections::kPortRpc].set(Keys::kLimit, std::to_string(limit));
                     return cfg;
-                })};
+                }))};
 
         auto const section = env.app().config().section(Sections::kPortRpc);
         // NOLINTBEGIN(bugprone-unchecked-optional-access)
@@ -580,16 +581,27 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
         BEAST_EXPECT(!ec);
 
         std::vector> clients;
-        int connectionCount{1};  // starts at 1 because the Env already has one
-                                 // for JSONRPCCLient
 
-        // for nonzero limits, go one past the limit, although failures happen
-        // at the limit, so this really leads to the last two clients failing.
-        // for zero limit, pick an arbitrary nonzero number of clients - all
-        // should connect fine.
+        // Env owns a persistent JSON-RPC HTTP client connection to port_rpc as
+        // part of startup, which counts against this port's connection limit.
+        // This test wants a known starting occupancy of zero, so for nonzero
+        // limits it deterministically drops that hidden client and waits for
+        // the server to register the disconnect before opening its own clients.
+        //
+        // Starting from zero is important because the port limit rejects once
+        // the incremented connection count reaches the configured limit. With a
+        // zero baseline and N = limit + 1 test-owned clients, exactly the last
+        // two requests should be rejected.
+        if (limit != 0)
+            BEAST_EXPECT(env.disconnectClient());
+
+        // For nonzero limits, go one past the limit. The port rejects at the
+        // limit, not only above it, so this yields the last two clients
+        // failing. For zero limit, pick an arbitrary nonzero number of clients
+        // and expect them all to succeed.
 
         int const testTo = (limit == 0) ? 50 : limit + 1;
-        while (connectionCount < testTo)
+        while (static_cast(clients.size()) < testTo)
         {
             clients.emplace_back(ip::tcp::socket{ios}, boost::beast::multi_buffer{});
             async_connect(clients.back().first, it, yield[ec]);
@@ -597,19 +609,24 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
             auto req = makeHTTPRequest(ip, port, to_string(jr), {});
             async_write(clients.back().first, req, yield[ec]);
             BEAST_EXPECT(!ec);
-            ++connectionCount;
         }
 
-        int readCount = 0;
+        int successfulReads = 0;
         for (auto& [soc, buf] : clients)
         {
             boost::beast::http::response resp;
             async_read(soc, buf, resp, yield[ec]);
-            ++readCount;
-            // expect the reads to fail for the clients that connected at or
-            // above the limit. If limit is 0, all reads should succeed
-            BEAST_EXPECT((limit == 0 || readCount < limit - 1) ? (!ec) : bool(ec));
+            if (!ec)
+                ++successfulReads;
         }
+
+        // This test cares about the exact number of accepted requests, not which
+        // specific client observed the rejection. With a zero baseline (the
+        // hidden Env client dropped above), the server accepts until the
+        // connection count reaches the limit: all clients for limit 0, else
+        // limit - 1 of the limit + 1 clients (the last two are rejected).
+        int const expectedReads = (limit == 0) ? static_cast(clients.size()) : limit - 1;
+        BEAST_EXPECT(successfulReads == expectedReads);
     }
 
     void
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 cafe72eff9..9cbfb8ca10 100644
--- a/src/tests/libxrpl/CMakeLists.txt
+++ b/src/tests/libxrpl/CMakeLists.txt
@@ -21,22 +21,31 @@ set_target_properties(
 )
 # Lets test sources include the shared helpers as .
 target_include_directories(xrpl_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
-target_link_libraries(xrpl_tests PRIVATE GTest::gtest xrpl.libxrpl)
+target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl)
 
 # One source subdirectory per module. Network unit tests are currently not
 # supported on Windows.
 set(test_modules
     basics
+    beast
+    consensus
     crypto
     json
+    nodestore
+    peerfinder
+    protocol
     resource
     shamap
     tx
     protocol_autogen
+    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).
@@ -46,6 +55,12 @@ foreach(module IN LISTS test_modules)
         "${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp"
         "${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp"
     )
+    if(NOT rust)
+        # Tests of the Rust interop include generated cxxbridge headers, which
+        # do not exist without the crates, so keep them out of the build tree
+        # entirely. They are named `Rust.cpp`.
+        list(FILTER sources EXCLUDE REGEX "/Rust[^/]*\\.cpp$")
+    endif()
     target_sources(xrpl_tests PRIVATE ${sources})
 
     # Expose the module's private headers under their canonical include path.
@@ -57,6 +72,16 @@ foreach(module IN LISTS test_modules)
     )
 endforeach()
 
+# The consensus tests use the CSF (Consensus Simulation Framework) helpers, so
+# compile the CSF sources into the test binary. The consensus engine itself now
+# lives in libxrpl, so no xrpld sources or include paths are needed here.
+file(
+    GLOB_RECURSE csf_sources
+    CONFIGURE_DEPENDS
+    "${CMAKE_CURRENT_SOURCE_DIR}/csf/*.cpp"
+)
+target_sources(xrpl_tests PRIVATE ${csf_sources})
+
 # The test helpers and per-module test headers are not built with add_module,
 # so verify them against the test binary's own compile environment.
 if(verify_headers)
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..52151262b0 100644
--- a/src/tests/libxrpl/basics/MallocTrim.cpp
+++ b/src/tests/libxrpl/basics/MallocTrim.cpp
@@ -199,7 +199,7 @@ 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 36e1b4a700..8e958b40d4 100644
--- a/src/tests/libxrpl/basics/Number.cpp
+++ b/src/tests/libxrpl/basics/Number.cpp
@@ -1,5 +1,6 @@
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -16,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -183,6 +185,17 @@ TEST(NumberTest, limits)
         }
         EXPECT_TRUE(caught);
 
+        try
+        {
+            Number{1, 2000000, Number::Normalized{}};
+            ADD_FAILURE();
+        }
+        catch (std::overflow_error const& e)
+        {
+            std::string const expected = "Number::normalize 2";
+            EXPECT_EQ(e.what(), expected) << e.what();
+        }
+
         if (scale == MantissaRange::MantissaScale::Large330)
         {
             // Normalization with the other scales, including the older large mantissa scales, will
@@ -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/RustInterop.cpp b/src/tests/libxrpl/basics/RustInterop.cpp
new file mode 100644
index 0000000000..8a6ad8a4ed
--- /dev/null
+++ b/src/tests/libxrpl/basics/RustInterop.cpp
@@ -0,0 +1,9 @@
+#include 
+#include 
+
+#include 
+
+TEST(RustInteropTest, hello_world)
+{
+    EXPECT_EQ(std::string(rs::hello_world::hello_world()), "hello_world");
+}
diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp
index a10711abdb..0180e25db0 100644
--- a/src/tests/libxrpl/basics/StringUtilities.cpp
+++ b/src/tests/libxrpl/basics/StringUtilities.cpp
@@ -290,4 +290,44 @@ TEST_F(StringUtilitiesTest, to_string)
     EXPECT_EQ(result, "hello");
 }
 
+TEST_F(StringUtilitiesTest, trimWhitespace)
+{
+    EXPECT_EQ(trimWhitespace(""), "");
+    EXPECT_EQ(trimWhitespace("   "), "");
+    EXPECT_EQ(trimWhitespace("abc"), "abc");
+    EXPECT_EQ(trimWhitespace("  abc"), "abc");
+    EXPECT_EQ(trimWhitespace("abc  "), "abc");
+    EXPECT_EQ(trimWhitespace(" \t\n\v\f\r abc \t\n\v\f\r "), "abc");
+
+    // Interior whitespace is preserved.
+    EXPECT_EQ(trimWhitespace("  a b\tc  "), "a b\tc");
+}
+
+TEST_F(StringUtilitiesTest, toLower)
+{
+    EXPECT_EQ(toLower(""), "");
+    EXPECT_EQ(toLower("ABC"), "abc");
+    EXPECT_EQ(toLower("AbC123"), "abc123");
+    EXPECT_EQ(toLower("already lower"), "already lower");
+
+    // Only 'A'-'Z' are remapped. Neighbouring punctuation and digits, which a
+    // buggy range check could catch, must survive untouched.
+    EXPECT_EQ(toLower("@[`{_^"), "@[`{_^");
+}
+
+// Both helpers are documented as depending only on their input. Guard that by
+// checking the bytes just outside ASCII, which a locale-aware isspace/tolower
+// could classify differently.
+TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale)
+{
+    // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales.
+    std::string const nbsp("\xA0", 1);
+    EXPECT_EQ(trimWhitespace(nbsp), nbsp);
+    EXPECT_EQ(trimWhitespace(" " + nbsp + " "), nbsp);
+
+    // 0xC0 is LATIN CAPITAL LETTER A WITH GRAVE in Latin-1.
+    std::string const agrave("\xC0", 1);
+    EXPECT_EQ(toLower(agrave), agrave);
+}
+
 }  // namespace xrpl
diff --git a/src/tests/libxrpl/basics/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/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp
new file mode 100644
index 0000000000..969705b5b7
--- /dev/null
+++ b/src/tests/libxrpl/basics/base_uint.cpp
@@ -0,0 +1,407 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+// a non-hashing Hasher that just copies the bytes.
+// Used to test hash_append in base_uint
+template 
+struct Nonhash
+{
+    static constexpr auto const kEndian = boost::endian::order::big;
+    static constexpr std::size_t kWidth = Bits / 8;
+
+    std::array data;
+
+    Nonhash() = default;
+
+    void
+    operator()(void const* key, std::size_t len) noexcept
+    {
+        assert(len == kWidth);
+        memcpy(data.data(), key, len);
+    }
+
+    explicit
+    operator std::size_t() noexcept
+    {
+        return kWidth;
+    }
+};
+
+struct BaseUintTest : public ::testing::Test
+{
+    using BaseUInt96 = BaseUInt<96>;
+    static_assert(std::is_copy_constructible_v);
+    static_assert(std::is_copy_assignable_v);
+
+    static void
+    testComparisons()
+    {
+        using HexPair = std::pair;
+
+        {
+            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 smaller{smallerText}, larger{largerText};
+                // For code readability, we want to use general boolean
+                // expectations instead of specific EXPECT_LT etc.
+                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 auto kTestArgs = std::to_array({
+                {"000000000000000000000000", "000000000000000000000001"},
+                {"000000000000000000000000", "ffffffffffffffffffffffff"},
+                {"0123456789ab0123456789ab", "123456789abc123456789abc"},
+                {"555555555555555555555555", "55555555555a555555555555"},
+                {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"},
+                {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"},
+            });
+
+            for (auto const& [smallerText, largerText] : kTestArgs)
+            {
+                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);
+            }
+        }
+    }
+};
+
+using BaseUintDeathTest = BaseUintTest;
+
+TEST_F(BaseUintDeathTest, fromRaw_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.
+#ifdef ENABLE_VOIDSTAR
+    GTEST_SKIP() << "ENABLE_VOIDSTAR is a debug build, but does not crash on failed asserts.";
+#else
+    auto smallConstruct = [] {
+        // Container smaller than the base_uint (8 bytes vs 12 bytes for
+        // test96). Only the first 8 bytes are copied; the remaining 4 bytes
+        // stay zero.
+        Blob const tooSmall{1, 2, 3, 4, 5, 6, 7, 8};
+        BaseUInt96 const result = BaseUInt96::fromRaw(tooSmall);
+        auto const resultText = to_string(result);
+        EXPECT_EQ(resultText, "010203040506070800000000") << resultText;
+    };
+    EXPECT_DEBUG_DEATH(smallConstruct(), "input size match");
+
+    auto largeConstruct = [] {
+        // Container larger than the base_uint (16 bytes vs 12 bytes for
+        // test96). Only the first 12 bytes are copied; the extra bytes are
+        // ignored.
+        Blob const tooBig{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+        BaseUInt96 const result = BaseUInt96::fromRaw(tooBig);
+        auto const resultText = to_string(result);
+        EXPECT_EQ(resultText, "0102030405060708090A0B0C") << resultText;
+    };
+    EXPECT_DEBUG_DEATH(largeConstruct(), "input size match");
+
+    auto smallCopy = [] {
+        // Container smaller than the base_uint (8 bytes vs 12 bytes for
+        // test96). Only the first 8 bytes are copied; the remaining 4 bytes
+        // stay zero.
+        Blob const tooSmall{1, 2, 3, 4, 5, 6, 7, 8};
+        BaseUInt96 result{};
+        --result;
+        {
+            auto const originalText = to_string(result);
+            EXPECT_EQ(originalText, "FFFFFFFFFFFFFFFFFFFFFFFF") << originalText;
+        }
+        result = tooSmall;
+        auto const resultText = to_string(result);
+        EXPECT_EQ(resultText, "010203040506070800000000") << resultText;
+    };
+    EXPECT_DEBUG_DEATH(smallCopy(), "input size match");
+
+    auto const largeCopy = [] {
+        // Container larger than the base_uint (16 bytes vs 12 bytes for
+        // test96). Only the first 12 bytes are copied; the extra bytes are
+        // ignored.
+        Blob const tooBig{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
+        BaseUInt96 result{};
+        --result;
+        {
+            auto const originalText = to_string(result);
+            EXPECT_EQ(originalText, "FFFFFFFFFFFFFFFFFFFFFFFF") << originalText;
+        }
+        result = tooBig;
+        auto const resultText = to_string(result);
+        EXPECT_EQ(resultText, "0102030405060708090A0B0C") << resultText;
+    };
+    EXPECT_DEBUG_DEATH(largeCopy(), "input size match");
+#endif
+}
+
+TEST_F(BaseUintTest, base_uint)
+{
+    static_assert(!std::is_constructible_v>);
+    static_assert(!std::is_assignable_v>);
+
+    testComparisons();
+
+    // used to verify set insertion (hashing required)
+    std::unordered_set> uset;
+
+    Blob const raw{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
+    EXPECT_EQ(BaseUInt96::kBytes, raw.size());
+
+    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 (hasher)
+    // and then extracting the bytes that were written during hashing
+    // 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 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());
+
+    expectedByte = 0xff;
+    for (auto& byte : complement)
+        EXPECT_EQ(byte, --expectedByte);
+
+    EXPECT_LT(ascending, complement);
+    EXPECT_GT(complement, ascending);
+
+    complement = ascending;
+    EXPECT_EQ(complement, ascending);
+
+    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 defaultZero;
+        EXPECT_EQ(defaultZero, zero) << to_string(defaultZero);
+
+        BaseUInt96 const bracedZero{};
+        EXPECT_EQ(bracedZero, zero) << to_string(bracedZero);
+
+        BaseUInt96 const zeroFromUInt{0u};
+        EXPECT_EQ(zeroFromUInt, zero) << to_string(zeroFromUInt);
+    }
+
+    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 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 parsed;
+    EXPECT_TRUE(parsed.parseHex(to_string(ascending)));
+    EXPECT_EQ(parsed, ascending);
+    parsed = zero;
+
+    // fails with extra char
+    EXPECT_FALSE(parsed.parseHex("A" + to_string(ascending)));
+    parsed = zero;
+
+    // fails with extra char at end
+    EXPECT_FALSE(parsed.parseHex(to_string(ascending) + "A"));
+
+    // fails with a non-hex character at some point in the string:
+    parsed = zero;
+
+    for (std::size_t i = 0; i != 24; ++i)
+    {
+        std::string xored = to_string(zero);
+        xored[i] = ('G' + (i % 10));
+        EXPECT_FALSE(parsed.parseHex(xored));
+    }
+
+    // Walking 1s:
+    for (std::size_t i = 0; i != 24; ++i)
+    {
+        std::string s1 = "000000000000000000000000";
+        s1[i] = '1';
+
+        EXPECT_TRUE(parsed.parseHex(s1));
+        EXPECT_EQ(to_string(parsed), s1);
+    }
+
+    // Walking 0s:
+    for (std::size_t i = 0; i != 24; ++i)
+    {
+        std::string s1 = "111111111111111111111111";
+        s1[i] = '0';
+
+        EXPECT_TRUE(parsed.parseHex(s1));
+        EXPECT_EQ(to_string(parsed), s1);
+    }
+
+    // Constexpr constructors
+    {
+        static_assert(BaseUInt96{}.signum() == 0);
+        static_assert(BaseUInt96("0").signum() == 0);
+        static_assert(BaseUInt96("000000000000000000000000").signum() == 0);
+        static_assert(BaseUInt96("000000000000000000000001").signum() == 1);
+        static_assert(BaseUInt96("800000000000000000000000").signum() == 1);
+
+        // Using the constexpr constructor in a non-constexpr context
+        // with an error in the parsing throws an exception.
+        {
+            // 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);
+            };
+            EXPECT_THAT(
+                tooShort,
+                ::testing::ThrowsMessage("invalid length for hex string"));
+        }
+        {
+            // Invalid character in string.
+            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);
+            };
+            EXPECT_THAT(
+                badCharacter, ::testing::ThrowsMessage("invalid hex character"));
+        }
+
+        // Verify that constexpr base_uints interpret a string the same
+        // way parseHex() does.
+        struct StrBaseUInt
+        {
+            char const* const str;
+            BaseUInt96 tst;
+
+            constexpr StrBaseUInt(char const* s) : str(s), tst(s)
+            {
+            }
+        };
+        constexpr auto kTestCases = std::to_array({
+            "000000000000000000000000",
+            "000000000000000000000001",
+            "fedcba9876543210ABCDEF91",
+            "19FEDCBA0123456789abcdef",
+            "800000000000000000000000",
+            "fFfFfFfFfFfFfFfFfFfFfFfF",
+        });
+
+        for (StrBaseUInt const& expectedByte : kTestCases)
+        {
+            BaseUInt96 t96;
+            EXPECT_TRUE(t96.parseHex(expectedByte.str));
+            EXPECT_EQ(t96, expectedByte.tst);
+        }
+    }
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/basics/base_uint_test.cpp b/src/tests/libxrpl/basics/base_uint_test.cpp
deleted file mode 100644
index c9bfc35c94..0000000000
--- a/src/tests/libxrpl/basics/base_uint_test.cpp
+++ /dev/null
@@ -1,421 +0,0 @@
-#include 
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl::test {
-
-// a non-hashing Hasher that just copies the bytes.
-// Used to test hash_append in base_uint
-template 
-struct Nonhash
-{
-    static constexpr auto const kEndian = boost::endian::order::big;
-    static constexpr std::size_t kWidth = Bits / 8;
-
-    std::array data;
-
-    Nonhash() = default;
-
-    void
-    operator()(void const* key, std::size_t len) noexcept
-    {
-        assert(len == kWidth);
-        memcpy(data.data(), key, len);
-    }
-
-    explicit
-    operator std::size_t() noexcept
-    {
-        return kWidth;
-    }
-};
-
-struct BaseUintTest : public ::testing::Test
-{
-    using BaseUInt96 = BaseUInt<96>;
-    static_assert(std::is_copy_constructible_v);
-    static_assert(std::is_copy_assignable_v);
-
-    static void
-    testComparisons()
-    {
-        {
-            static constexpr std::array, 6> kTestArgs{
-                {{"0000000000000000", "0000000000000001"},
-                 {"0000000000000000", "ffffffffffffffff"},
-                 {"1234567812345678", "2345678923456789"},
-                 {"8000000000000000", "8000000000000001"},
-                 {"aaaaaaaaaaaaaaa9", "aaaaaaaaaaaaaaaa"},
-                 {"fffffffffffffffe", "ffffffffffffffff"}}};
-
-            for (auto const& arg : kTestArgs)
-            {
-                xrpl::BaseUInt<64> const u{arg.first}, v{arg.second};
-                // 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);
-            }
-        }
-
-        {
-            static constexpr std::array, 6> kTestArgs{
-                {
-                    {"000000000000000000000000", "000000000000000000000001"},
-                    {"000000000000000000000000", "ffffffffffffffffffffffff"},
-                    {"0123456789ab0123456789ab", "123456789abc123456789abc"},
-                    {"555555555555555555555555", "55555555555a555555555555"},
-                    {"aaaaaaaaaaaaaaa9aaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaa"},
-                    {"fffffffffffffffffffffffe", "ffffffffffffffffffffffff"},
-                }};
-
-            for (auto const& arg : 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);
-            }
-        }
-    }
-};
-
-using BaseUintDeathTest = BaseUintTest;
-
-TEST_F(BaseUintDeathTest, fromRaw_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.
-#ifdef ENABLE_VOIDSTAR
-    GTEST_SKIP() << "ENABLE_VOIDSTAR is a debug build, but does not crash on failed asserts.";
-#else
-    auto smallConstruct = [] {
-        // Container smaller than the base_uint (8 bytes vs 12 bytes for
-        // test96). Only the first 8 bytes are copied; the remaining 4 bytes
-        // stay zero.
-        Blob const tooSmall{1, 2, 3, 4, 5, 6, 7, 8};
-        BaseUInt96 const result = BaseUInt96::fromRaw(tooSmall);
-        auto const resultText = to_string(result);
-        EXPECT_EQ(resultText, "010203040506070800000000") << resultText;
-    };
-    EXPECT_DEBUG_DEATH(smallConstruct(), "input size match");
-
-    auto largeConstruct = [] {
-        // Container larger than the base_uint (16 bytes vs 12 bytes for
-        // test96). Only the first 12 bytes are copied; the extra bytes are
-        // ignored.
-        Blob const tooBig{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
-        BaseUInt96 const result = BaseUInt96::fromRaw(tooBig);
-        auto const resultText = to_string(result);
-        EXPECT_EQ(resultText, "0102030405060708090A0B0C") << resultText;
-    };
-    EXPECT_DEBUG_DEATH(largeConstruct(), "input size match");
-
-    auto smallCopy = [] {
-        // Container smaller than the base_uint (8 bytes vs 12 bytes for
-        // test96). Only the first 8 bytes are copied; the remaining 4 bytes
-        // stay zero.
-        Blob const tooSmall{1, 2, 3, 4, 5, 6, 7, 8};
-        BaseUInt96 result{};
-        --result;
-        {
-            auto const originalText = to_string(result);
-            EXPECT_EQ(originalText, "FFFFFFFFFFFFFFFFFFFFFFFF") << originalText;
-        }
-        result = tooSmall;
-        auto const resultText = to_string(result);
-        EXPECT_EQ(resultText, "010203040506070800000000") << resultText;
-    };
-    EXPECT_DEBUG_DEATH(smallCopy(), "input size match");
-
-    auto const largeCopy = [] {
-        // Container larger than the base_uint (16 bytes vs 12 bytes for
-        // test96). Only the first 12 bytes are copied; the extra bytes are
-        // ignored.
-        Blob const tooBig{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
-        BaseUInt96 result{};
-        --result;
-        {
-            auto const originalText = to_string(result);
-            EXPECT_EQ(originalText, "FFFFFFFFFFFFFFFFFFFFFFFF") << originalText;
-        }
-        result = tooBig;
-        auto const resultText = to_string(result);
-        EXPECT_EQ(resultText, "0102030405060708090A0B0C") << resultText;
-    };
-    EXPECT_DEBUG_DEATH(largeCopy(), "input size match");
-#endif
-}
-
-TEST_F(BaseUintTest, base_uint)
-{
-    static_assert(!std::is_constructible_v>);
-    static_assert(!std::is_assignable_v>);
-
-    testComparisons();
-
-    // used to verify set insertion (hashing required)
-    std::unordered_set> uset;
-
-    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);
-    }
-
-    // Test hash_append by "hashing" with a no-op hasher (h)
-    // 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);
-
-    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());
-
-    t = 0xff;
-    for (auto& d : v)
-    {
-        EXPECT_EQ(d, --t);
-    }
-
-    EXPECT_LT(u, v);
-    EXPECT_GT(v, u);
-
-    v = u;
-    EXPECT_EQ(v, u);
-
-    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);
-    }
-
-    {
-        // 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 z2{};
-        EXPECT_EQ(z2, z) << to_string(z2);
-
-        BaseUInt96 const z3{0u};
-        EXPECT_EQ(z3, z) << to_string(z3);
-    }
-
-    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 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);
-
-    EXPECT_EQ(uset.size(), 4);
-
-    BaseUInt96 tmp;
-    EXPECT_TRUE(tmp.parseHex(to_string(u)));
-    EXPECT_EQ(tmp, u);
-    tmp = z;
-
-    // fails with extra char
-    EXPECT_FALSE(tmp.parseHex("A" + to_string(u)));
-    tmp = z;
-
-    // fails with extra char at end
-    EXPECT_FALSE(tmp.parseHex(to_string(u) + "A"));
-
-    // fails with a non-hex character at some point in the string:
-    tmp = z;
-
-    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));
-    }
-
-    // Walking 1s:
-    for (std::size_t i = 0; i != 24; ++i)
-    {
-        std::string s1 = "000000000000000000000000";
-        s1[i] = '1';
-
-        EXPECT_TRUE(tmp.parseHex(s1));
-        EXPECT_EQ(to_string(tmp), s1);
-    }
-
-    // Walking 0s:
-    for (std::size_t i = 0; i != 24; ++i)
-    {
-        std::string s1 = "111111111111111111111111";
-        s1[i] = '0';
-
-        EXPECT_TRUE(tmp.parseHex(s1));
-        EXPECT_EQ(to_string(tmp), s1);
-    }
-
-    // Constexpr constructors
-    {
-        static_assert(BaseUInt96{}.signum() == 0);
-        static_assert(BaseUInt96("0").signum() == 0);
-        static_assert(BaseUInt96("000000000000000000000000").signum() == 0);
-        static_assert(BaseUInt96("000000000000000000000001").signum() == 1);
-        static_assert(BaseUInt96("800000000000000000000000").signum() == 1);
-
-        // 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');
-                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);
-        }
-        {
-            // Invalid character in string.
-            bool caught = false;
-            try
-            {
-                // Try to prevent constant evaluation.
-                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);
-        }
-
-        // Verify that constexpr base_uints interpret a string the same
-        // way parseHex() does.
-        struct StrBaseUInt
-        {
-            char const* const str;
-            BaseUInt96 tst;
-
-            constexpr StrBaseUInt(char const* s) : str(s), tst(s)
-            {
-            }
-        };
-        constexpr StrBaseUInt kTestCases[] = {
-            "000000000000000000000000",
-            "000000000000000000000001",
-            "fedcba9876543210ABCDEF91",
-            "19FEDCBA0123456789abcdef",
-            "800000000000000000000000",
-            "fFfFfFfFfFfFfFfFfFfFfFfF",
-        };
-
-        for (StrBaseUInt const& t : kTestCases)
-        {
-            BaseUInt96 t96;
-            EXPECT_TRUE(t96.parseHex(t.str));
-            EXPECT_EQ(t96, t.tst);
-        }
-    }
-}
-
-}  // namespace xrpl::test
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/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/ByzantineFailureSim.cpp b/src/tests/libxrpl/consensus/ByzantineFailureSim.cpp
new file mode 100644
index 0000000000..e712817121
--- /dev/null
+++ b/src/tests/libxrpl/consensus/ByzantineFailureSim.cpp
@@ -0,0 +1,81 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(ByzantineFailureSimTest, DISABLED_byzantine_failure_sim)
+{
+    using namespace csf;
+    using namespace std::chrono;
+
+    // This test simulates a specific topology with nodes generating
+    // different ledgers due to a simulated byzantine failure (injecting
+    // an extra non-consensus transaction).
+
+    Sim sim;
+    ConsensusParms const parms{};
+
+    SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+    PeerGroup a = sim.createGroup(1);
+    PeerGroup b = sim.createGroup(1);
+    PeerGroup c = sim.createGroup(1);
+    PeerGroup d = sim.createGroup(1);
+    PeerGroup e = sim.createGroup(1);
+    PeerGroup f = sim.createGroup(1);
+    PeerGroup g = sim.createGroup(1);
+
+    a.trustAndConnect(a + b + c + g, delay);
+    b.trustAndConnect(b + a + c + d + e, delay);
+    c.trustAndConnect(c + a + b + d + e, delay);
+    d.trustAndConnect(d + b + c + e + f, delay);
+    e.trustAndConnect(e + b + c + d + f, delay);
+    f.trustAndConnect(f + d + e + g, delay);
+    g.trustAndConnect(g + a + f, delay);
+
+    PeerGroup const network = a + b + c + d + e + f + g;
+
+    StreamCollector sc{std::cout};
+
+    sim.collectors.add(sc);
+
+    for (TrustGraph::ForkInfo const& fi : sim.trustGraph.forkablePairs(0.8))
+    {
+        std::cout << "Can fork " << PeerGroup{fi.unlA} << " "
+                  << " " << PeerGroup{fi.unlB} << " overlap " << fi.overlap << " required "
+                  << fi.required << "\n";
+    };
+
+    // set prior state
+    sim.run(1);
+
+    PeerGroup byzantineNodes = a + b + c + g;
+    // All peers see some TX 0
+    for (Peer* peer : network)
+    {
+        peer->submit(Tx{0});
+        // Peers 0,1,2,6 will close the next ledger differently by injecting
+        // a non-consensus approved transaction
+        if (byzantineNodes.contains(peer))
+        {
+            peer->txInjections.emplace(peer->lastClosedLedger.seq(), Tx{42});
+        }
+    }
+    sim.run(4);
+    std::cout << "Branches: " << sim.branches() << "\n";
+    std::cout << "Fully synchronized: " << std::boolalpha << sim.synchronized() << "\n";
+    // Not tessting anything currently.
+    SUCCEED();
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/CensorshipDetector.cpp b/src/tests/libxrpl/consensus/CensorshipDetector.cpp
new file mode 100644
index 0000000000..2c6b6ec731
--- /dev/null
+++ b/src/tests/libxrpl/consensus/CensorshipDetector.cpp
@@ -0,0 +1,81 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+namespace {
+
+void
+runRound(
+    CensorshipDetector& cdet,
+    int round,
+    std::vector proposed,
+    std::vector accepted,
+    std::vector remain,
+    std::vector remove)
+{
+    // Begin tracking what we're proposing this round
+    CensorshipDetector::TxIDSeqVec proposal;
+    for (auto const& i : proposed)
+        proposal.emplace_back(i, round);
+    cdet.propose(std::move(proposal));
+
+    // Finalize the round, by processing what we accepted; then
+    // remove anything that needs to be removed and ensure that
+    // what remains is correct.
+    cdet.check(std::move(accepted), [&remove, &remain](auto id, auto seq) {
+        // If the item is supposed to be removed from the censorship
+        // detector internal tracker manually, do it now:
+        if (std::ranges::find(remove, id) != remove.end())
+            return true;
+
+        // If the item is supposed to still remain in the censorship
+        // detector internal tracker; remove it from the vector.
+        auto it = std::ranges::find(remain, id);
+        if (it != remain.end())
+            remain.erase(it);
+        return false;
+    });
+
+    // On entry, this set contained all the elements that should be tracked
+    // by the detector after we process this round. We removed all the items
+    // that actually were in the tracker, so this should now be empty:
+    EXPECT_TRUE(remain.empty());
+}
+
+}  // namespace
+
+TEST(CensorshipDetectorTest, censorship_detector)
+{
+    SCOPED_TRACE("Censorship Detector");
+
+    CensorshipDetector cdet;
+    int round = 0;
+    // proposed            accepted    remain          remove
+    runRound(cdet, ++round, {}, {}, {}, {});
+    runRound(cdet, ++round, {10, 11, 12, 13}, {11, 2}, {10, 13}, {});
+    runRound(cdet, ++round, {10, 13, 14, 15}, {14}, {10, 13, 15}, {});
+    runRound(cdet, ++round, {10, 13, 15, 16}, {15, 16}, {10, 13}, {});
+    runRound(cdet, ++round, {10, 13}, {17, 18}, {10, 13}, {});
+    runRound(cdet, ++round, {10, 19}, {}, {10, 19}, {});
+    runRound(cdet, ++round, {10, 19, 20}, {20}, {10}, {19});
+    runRound(cdet, ++round, {21}, {21}, {}, {});
+    runRound(cdet, ++round, {}, {22}, {}, {});
+    runRound(cdet, ++round, {23, 24, 25, 26}, {25, 27}, {23, 26}, {24});
+    runRound(cdet, ++round, {23, 26, 28}, {26, 28}, {23}, {});
+
+    for (auto i = 0uz; i != 10; ++i)
+        runRound(cdet, ++round, {23}, {}, {23}, {});
+
+    runRound(cdet, ++round, {23, 29}, {29}, {23}, {});
+    runRound(cdet, ++round, {30, 31}, {31}, {30}, {});
+    runRound(cdet, ++round, {30}, {30}, {}, {});
+    runRound(cdet, ++round, {}, {}, {}, {});
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/Consensus.cpp b/src/tests/libxrpl/consensus/Consensus.cpp
new file mode 100644
index 0000000000..d303d28e89
--- /dev/null
+++ b/src/tests/libxrpl/consensus/Consensus.cpp
@@ -0,0 +1,1455 @@
+#include 
+
+#include 
+#include 
+#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 {
+
+namespace {
+
+beast::Journal
+journal()
+{
+    return beast::Journal{TestSink::instance()};
+}
+
+bool
+shouldCloseLedger(
+    bool anyTransactions,
+    std::size_t prevProposers,
+    std::size_t proposersClosed,
+    std::size_t proposersValidated,
+    std::chrono::milliseconds prevRoundTime,
+    std::chrono::milliseconds timeSincePrevClose,
+    std::chrono::milliseconds openTime,
+    std::chrono::milliseconds idleInterval,
+    ConsensusParms const& parms,
+    std::unique_ptr const& clog = {})
+{
+    return xrpl::shouldCloseLedger(
+        anyTransactions,
+        prevProposers,
+        proposersClosed,
+        proposersValidated,
+        prevRoundTime,
+        timeSincePrevClose,
+        openTime,
+        idleInterval,
+        parms,
+        journal(),
+        clog);
+}
+
+ConsensusState
+checkConsensus(
+    std::size_t prevProposers,
+    std::size_t currentProposers,
+    std::size_t currentAgree,
+    std::size_t currentFinished,
+    std::chrono::milliseconds previousAgreeTime,
+    std::chrono::milliseconds currentAgreeTime,
+    bool stalled,
+    ConsensusParms const& parms,
+    bool proposing,
+    std::unique_ptr const& clog = {})
+{
+    return xrpl::checkConsensus(
+        prevProposers,
+        currentProposers,
+        currentAgree,
+        currentFinished,
+        previousAgreeTime,
+        currentAgreeTime,
+        stalled,
+        parms,
+        proposing,
+        journal(),
+        clog);
+}
+
+using CsfDisputedTx = DisputedTx;
+
+CsfDisputedTx
+makeDisputedTx(csf::Tx tx, bool ourVote, std::size_t numPeers)
+{
+    return CsfDisputedTx{tx, ourVote, numPeers, journal()};
+}
+
+bool
+isStalled(
+    CsfDisputedTx const& dispute,
+    ConsensusParms const& parms,
+    bool proposing,
+    int peersUnchanged,
+    std::unique_ptr const& clog)
+{
+    return dispute.stalled(parms, proposing, peersUnchanged, journal(), clog);
+}
+
+// Helper collector for testPreferredByBranch
+// Invasively disconnects network at bad times to cause splits
+struct Disruptor
+{
+    csf::PeerGroup& network;
+    csf::PeerGroup& groupCfast;
+    csf::PeerGroup& groupCsplit;
+    csf::SimDuration delay;
+    bool reconnected = false;
+
+    Disruptor(csf::PeerGroup& net, csf::PeerGroup& c, csf::PeerGroup& split, csf::SimDuration d)
+        : network(net), groupCfast(c), groupCsplit(split), delay(d)
+    {
+    }
+
+    template 
+    void
+    on(csf::PeerID, csf::SimTime, E const&)
+    {
+    }
+
+    void
+    on(csf::PeerID who, csf::SimTime, csf::FullyValidateLedger const& e)
+    {
+        using namespace std::chrono;
+        // As soon as the fastC node fully validates C, disconnect
+        // ALL c nodes from the network. The fast C node needs to disconnect
+        // as well to prevent it from relaying the validations it did see
+        if (who == groupCfast[0]->id && e.ledger.seq() == csf::Ledger::Seq{2})
+        {
+            network.disconnect(groupCsplit);
+            network.disconnect(groupCfast);
+        }
+    }
+
+    void
+    on(csf::PeerID who, csf::SimTime, csf::AcceptLedger const& e)
+    {
+        // As soon as anyone generates a child of B or C, reconnect the
+        // network so those validations make it through
+        if (!reconnected && e.ledger.seq() == csf::Ledger::Seq{3})
+        {
+            reconnected = true;
+            network.connect(groupCsplit, delay);
+        }
+    }
+};
+
+// Helper collector for testPauseForLaggards
+// This will remove the ledgerAccept delay used to
+// initially create the slow vs. fast validator groups.
+struct UndoDelay
+{
+    csf::PeerGroup& g;
+
+    UndoDelay(csf::PeerGroup& a) : g(a)
+    {
+    }
+
+    template 
+    void
+    on(csf::PeerID, csf::SimTime, E const&)
+    {
+    }
+
+    void
+    on(csf::PeerID who, csf::SimTime, csf::AcceptLedger const& e)
+    {
+        for (csf::Peer* p : g)
+        {
+            if (p->id == who)
+                p->delays.ledgerAccept = std::chrono::seconds{0};
+        }
+    }
+};
+
+}  // namespace
+
+TEST(ConsensusTest, should_close_ledger)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("should close ledger");
+
+    // Use default parameters
+    ConsensusParms const p{};
+
+    // Bizarre times forcibly close
+    EXPECT_TRUE(shouldCloseLedger(true, 10, 10, 10, -10s, 10s, 1s, 1s, p));
+    EXPECT_TRUE(shouldCloseLedger(true, 10, 10, 10, 100h, 10s, 1s, 1s, p));
+    EXPECT_TRUE(shouldCloseLedger(true, 10, 10, 10, 10s, 100h, 1s, 1s, p));
+
+    // Rest of network has closed
+    EXPECT_TRUE(shouldCloseLedger(true, 10, 3, 5, 10s, 10s, 10s, 10s, p));
+
+    // No transactions means wait until end of internval
+    EXPECT_TRUE(!shouldCloseLedger(false, 10, 0, 0, 1s, 1s, 1s, 10s, p));
+    EXPECT_TRUE(shouldCloseLedger(false, 10, 0, 0, 1s, 10s, 1s, 10s, p));
+
+    // Enforce minimum ledger open time
+    EXPECT_TRUE(!shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 1s, 10s, p));
+
+    // Don't go too much faster than last time
+    EXPECT_TRUE(!shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 3s, 10s, p));
+
+    EXPECT_TRUE(shouldCloseLedger(true, 10, 0, 0, 10s, 10s, 10s, 10s, p));
+}
+
+TEST(ConsensusTest, check_consensus)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("check consensus");
+
+    // Use default parameters
+    ConsensusParms const p{};
+
+    ///////////////
+    // Disputes still in doubt
+    //
+    // Not enough time has elapsed
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 2s, false, p, true));
+
+    // If not enough peers have proposed, ensure
+    // more time for proposals
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 4s, false, p, true));
+
+    // Enough time has elapsed and we all agree
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(10, 2, 2, 0, 3s, 10s, false, p, true));
+
+    // Enough time has elapsed and we don't yet agree
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(10, 2, 1, 0, 3s, 10s, false, p, true));
+
+    // Our peers have moved on
+    // Enough time has elapsed and we all agree
+    EXPECT_TRUE(ConsensusState::MovedOn == checkConsensus(10, 2, 1, 8, 3s, 10s, false, p, true));
+
+    // If no peers, don't agree until time has passed.
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(0, 0, 0, 0, 3s, 10s, false, p, true));
+
+    // Agree if no peers and enough time has passed.
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(0, 0, 0, 0, 3s, 16s, false, p, true));
+
+    // Expire if too much time has passed without agreement
+    EXPECT_TRUE(ConsensusState::Expired == checkConsensus(10, 8, 1, 0, 1s, 19s, false, p, true));
+
+    ///////////////
+    // Stalled
+    //
+    // Not enough time has elapsed
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 2s, true, p, true));
+
+    // If not enough peers have proposed, ensure
+    // more time for proposals
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(10, 2, 2, 0, 3s, 4s, true, p, true));
+
+    // Enough time has elapsed and we all agree
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(10, 2, 2, 0, 3s, 10s, true, p, true));
+
+    // Enough time has elapsed and we don't yet agree, but there's nothing
+    // left to dispute
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(10, 2, 1, 0, 3s, 10s, true, p, true));
+
+    // Our peers have moved on
+    // Enough time has elapsed and we all agree, nothing left to dispute
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(10, 2, 1, 8, 3s, 10s, true, p, true));
+
+    // If no peers, don't agree until time has passed.
+    EXPECT_TRUE(ConsensusState::No == checkConsensus(0, 0, 0, 0, 3s, 10s, true, p, true));
+
+    // Agree if no peers and enough time has passed.
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(0, 0, 0, 0, 3s, 16s, true, p, true));
+
+    // We are done if there's nothing left to dispute, no matter how much
+    // time has passed
+    EXPECT_TRUE(ConsensusState::Yes == checkConsensus(10, 8, 1, 0, 1s, 19s, true, p, true));
+}
+
+TEST(ConsensusTest, standalone)
+{
+    using namespace std::chrono_literals;
+    using namespace csf;
+    SCOPED_TRACE("standalone");
+
+    Sim s;
+    PeerGroup const peers = s.createGroup(1);
+    Peer* peer = peers[0];
+    peer->targetLedgers = 1;
+    peer->start();
+    peer->submit(Tx{1});
+
+    s.scheduler.step();
+
+    // Inspect that the proper ledger was created
+    auto const& lcl = peer->lastClosedLedger;
+    EXPECT_TRUE(peer->prevLedgerID() == lcl.id());
+    EXPECT_TRUE(lcl.seq() == Ledger::Seq{1});
+    EXPECT_TRUE(lcl.txs().size() == 1);
+    EXPECT_TRUE(lcl.txs().contains(Tx{1}));
+    EXPECT_TRUE(peer->prevProposers == 0);
+}
+
+TEST(ConsensusTest, peers_agree)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("peers agree");
+
+    ConsensusParms const parms{};
+    Sim sim;
+    PeerGroup peers = sim.createGroup(5);
+
+    // Connected trust and network graphs with single fixed delay
+    peers.trustAndConnect(peers, round(0.2 * parms.ledgerGRANULARITY));
+
+    // everyone submits their own ID as a TX
+    for (Peer* p : peers)
+        p->submit(Tx(static_cast(p->id)));
+
+    sim.run(1);
+
+    // All peers are in sync
+    EXPECT_TRUE(sim.synchronized());
+    if (sim.synchronized())
+    {
+        for (Peer const* peer : peers)
+        {
+            auto const& lcl = peer->lastClosedLedger;
+            EXPECT_TRUE(lcl.id() == peer->prevLedgerID());
+            EXPECT_TRUE(lcl.seq() == Ledger::Seq{1});
+            // All peers proposed
+            EXPECT_TRUE(peer->prevProposers == peers.size() - 1);
+            // All transactions were accepted
+            for (std::uint32_t i = 0; i < peers.size(); ++i)
+                EXPECT_TRUE(lcl.txs().contains(Tx{i}));
+        }
+    }
+}
+
+TEST(ConsensusTest, slow_peers)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("slow peers");
+
+    // Several tests of a complete trust graph with a subset of peers
+    // that have significantly longer network delays to the rest of the
+    // network
+
+    // Test when a slow peer doesn't delay a consensus quorum (4/5 agree)
+    {
+        ConsensusParms const parms{};
+        Sim sim;
+        PeerGroup slow = sim.createGroup(1);
+        PeerGroup fast = sim.createGroup(4);
+        PeerGroup network = fast + slow;
+
+        // Fully connected trust graph
+        network.trust(network);
+
+        // Fast and slow network connections
+        fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
+
+        slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
+
+        // All peers submit their own ID as a transaction
+        for (Peer* peer : network)
+            peer->submit(Tx{static_cast(peer->id)});
+
+        sim.run(1);
+
+        // Verify all peers have same LCL but are missing transaction 0
+        // All peers are in sync even with a slower peer 0
+        EXPECT_TRUE(sim.synchronized());
+        if (sim.synchronized())
+        {
+            for (Peer const* peer : network)
+            {
+                auto const& lcl = peer->lastClosedLedger;
+                EXPECT_TRUE(lcl.id() == peer->prevLedgerID());
+                EXPECT_TRUE(lcl.seq() == Ledger::Seq{1});
+
+                EXPECT_TRUE(peer->prevProposers == network.size() - 1);
+                EXPECT_TRUE(peer->prevRoundTime == network[0]->prevRoundTime);
+
+                EXPECT_TRUE(not lcl.txs().contains(Tx{0}));
+                for (std::uint32_t i = 2; i < network.size(); ++i)
+                    EXPECT_TRUE(lcl.txs().contains(Tx{i}));
+
+                // Tx 0 didn't make it
+                EXPECT_TRUE(peer->openTxs.contains(Tx{0}));
+            }
+        }
+    }
+
+    // Test when the slow peers delay a consensus quorum (4/6 agree)
+    {
+        // Run two tests
+        //  1. The slow peers are participating in consensus
+        //  2. The slow peers are just observing
+
+        for (auto isParticipant : {true, false})
+        {
+            ConsensusParms const parms{};
+
+            Sim sim;
+            PeerGroup slow = sim.createGroup(2);
+            PeerGroup fast = sim.createGroup(4);
+            PeerGroup network = fast + slow;
+
+            // Connected trust graph
+            network.trust(network);
+
+            // Fast and slow network connections
+            fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
+
+            slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
+
+            for (Peer* peer : slow)
+                peer->runAsValidator = isParticipant;
+
+            // All peers submit their own ID as a transaction and relay it
+            // to peers
+            for (Peer* peer : network)
+                peer->submit(Tx{static_cast(peer->id)});
+
+            sim.run(1);
+
+            EXPECT_TRUE(sim.synchronized());
+            if (sim.synchronized())
+            {
+                // Verify all peers have same LCL but are missing
+                // transaction 0,1 which was not received by all peers
+                // before the ledger closed
+                for (Peer const* peer : network)
+                {
+                    // Closed ledger has all but transaction 0,1
+                    auto const& lcl = peer->lastClosedLedger;
+                    EXPECT_TRUE(lcl.seq() == Ledger::Seq{1});
+                    EXPECT_TRUE(not lcl.txs().contains(Tx{0}));
+                    EXPECT_TRUE(not lcl.txs().contains(Tx{1}));
+                    for (std::uint32_t i = slow.size(); i < network.size(); ++i)
+                        EXPECT_TRUE(lcl.txs().contains(Tx{i}));
+
+                    // Tx 0-1 didn't make it
+                    EXPECT_TRUE(peer->openTxs.contains(Tx{0}));
+                    EXPECT_TRUE(peer->openTxs.contains(Tx{1}));
+                }
+
+                Peer const* slowPeer = slow[0];
+                if (isParticipant)
+                {
+                    EXPECT_TRUE(slowPeer->prevProposers == network.size() - 1);
+                }
+                else
+                {
+                    EXPECT_TRUE(slowPeer->prevProposers == fast.size());
+                }
+
+                for (Peer const* peer : fast)
+                {
+                    // Due to the network link delay settings
+                    //    Peer 0 initially proposes {0}
+                    //    Peer 1 initially proposes {1}
+                    //    Peers 2-5 initially propose {2,3,4,5}
+                    // Since peers 2-5 agree, 4/6 > the initial 50% needed
+                    // to include a disputed transaction, so Peer 0/1 switch
+                    // to agree with those peers. Peer 0/1 then closes with
+                    // an 80% quorum of agreeing positions (5/6) match.
+                    //
+                    // Peers 2-5 do not change position, since tx 0 or tx 1
+                    // have less than the 50% initial threshold. They also
+                    // cannot declare consensus, since 4/6 agreeing
+                    // positions are < 80% threshold. They therefore need an
+                    // additional timerEntry call to see the updated
+                    // positions from Peer 0 & 1.
+
+                    if (isParticipant)
+                    {
+                        EXPECT_TRUE(peer->prevProposers == network.size() - 1);
+                        EXPECT_TRUE(peer->prevRoundTime > slowPeer->prevRoundTime);
+                    }
+                    else
+                    {
+                        EXPECT_TRUE(peer->prevProposers == fast.size() - 1);
+                        // so all peers should have closed together
+                        EXPECT_TRUE(peer->prevRoundTime == slowPeer->prevRoundTime);
+                    }
+                }
+            }
+        }
+    }
+}
+
+TEST(ConsensusTest, close_time_disagree)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("close time disagree");
+
+    // This is a very specialized test to get ledgers to disagree on
+    // the close time. It unfortunately assumes knowledge about current
+    // timing constants. This is a necessary evil to get coverage up
+    // pending more extensive refactorings of timing constants.
+
+    // In order to agree-to-disagree on the close time, there must be no
+    // clear majority of nodes agreeing on a close time. This test
+    // sets a relative offset to the peers internal clocks so that they
+    // send proposals with differing times.
+
+    // However, agreement is on the effective close time, not the
+    // exact close time. The minimum closeTimeResolution is given by
+    // ledgerPossibleTimeResolutions[0], which is currently 10s. This means
+    // the skews need to be at least 10 seconds to have different effective
+    // close times.
+
+    // Complicating this matter is that nodes will ignore proposals
+    // with times more than proposeFRESHNESS =20s in the past. So at
+    // the minimum granularity, we have at most 3 types of skews
+    // (0s,10s,20s).
+
+    // This test therefore has 6 nodes, with 2 nodes having each type of
+    // skew. Then no majority (1/3 < 1/2) of nodes will agree on an
+    // actual close time.
+
+    ConsensusParms const parms{};
+    Sim sim;
+
+    PeerGroup groupA = sim.createGroup(2);
+    PeerGroup const groupB = sim.createGroup(2);
+    PeerGroup const groupC = sim.createGroup(2);
+    PeerGroup network = groupA + groupB + groupC;
+
+    network.trust(network);
+    network.connect(network, round(0.2 * parms.ledgerGRANULARITY));
+
+    // Run consensus without skew until we have a short close time
+    // resolution
+    Peer const* firstPeer = *groupA.begin();
+    while (firstPeer->lastClosedLedger.closeTimeResolution() >= parms.proposeFRESHNESS)
+        sim.run(1);
+
+    // Introduce a shift on the time of 2/3 of peers
+    for (Peer* peer : groupA)
+        peer->clockSkew = parms.proposeFRESHNESS / 2;
+    for (Peer* peer : groupB)
+        peer->clockSkew = parms.proposeFRESHNESS;
+
+    sim.run(1);
+
+    // All nodes agreed to disagree on the close time
+    EXPECT_TRUE(sim.synchronized());
+    if (sim.synchronized())
+    {
+        for (Peer const* peer : network)
+            EXPECT_TRUE(!peer->lastClosedLedger.closeAgree());
+    }
+}
+
+TEST(ConsensusTest, wrong_lcl)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("wrong LCL");
+
+    // Specialized test to exercise a temporary fork in which some peers
+    // are working on an incorrect prior ledger.
+
+    ConsensusParms const parms{};
+
+    // Vary the time it takes to process validations to exercise detecting
+    // the wrong LCL at different phases of consensus
+    for (auto validationDelay : {0ms, parms.ledgerMinClose})
+    {
+        // Consider 10 peers:
+        // 0 1         2 3 4       5 6 7 8 9
+        // minority   majorityA   majorityB
+        //
+        // Nodes 0-1 trust nodes 0-4
+        // Nodes 2-9 trust nodes 2-9
+        //
+        // By submitting tx 0 to nodes 0-4 and tx 1 to nodes 5-9,
+        // nodes 0-1 will generate the wrong LCL (with tx 0). The remaining
+        // nodes will instead accept the ledger with tx 1.
+
+        // Nodes 0-1 will detect this mismatch during a subsequent round
+        // since nodes 2-4 will validate a different ledger.
+
+        // Nodes 0-1 will acquire the proper ledger from the network and
+        // resume consensus and eventually generate the dominant network
+        // ledger.
+
+        // This topology can potentially fork with the above trust relations
+        // but that is intended for this test.
+
+        Sim sim;
+
+        PeerGroup minority = sim.createGroup(2);
+        PeerGroup const majorityA = sim.createGroup(3);
+        PeerGroup const majorityB = sim.createGroup(5);
+
+        PeerGroup majority = majorityA + majorityB;
+        PeerGroup const network = minority + majority;
+
+        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+        minority.trustAndConnect(minority + majorityA, delay);
+        majority.trustAndConnect(majority, delay);
+
+        CollectByNode jumps;
+        sim.collectors.add(jumps);
+
+        EXPECT_TRUE(sim.trustGraph.canFork(parms.minConsensusPct / 100.));
+
+        // initial round to set prior state
+        sim.run(1);
+
+        // Nodes in smaller UNL have seen tx 0, nodes in other unl have seen
+        // tx 1
+        for (Peer* peer : network)
+            peer->delays.recvValidation = validationDelay;
+        for (Peer* peer : (minority + majorityA))
+            peer->openTxs.insert(Tx{0});
+        for (Peer* peer : majorityB)
+            peer->openTxs.insert(Tx{1});
+
+        // Run for additional rounds
+        // With no validation delay, only 2 more rounds are needed.
+        //  1. Round to generate different ledgers
+        //  2. Round to detect different prior ledgers (but still generate
+        //    wrong ones) and recover within that round since wrong LCL
+        //    is detected before we close
+        //
+        // With a validation delay of ledgerMinClose, we need 3 more
+        // rounds.
+        //  1. Round to generate different ledgers
+        //  2. Round to detect different prior ledgers (but still generate
+        //     wrong ones) but end up declaring consensus on wrong LCL (but
+        //     with the right transaction set!). This is because we detect
+        //     the wrong LCL after we have closed the ledger, so we declare
+        //     consensus based solely on our peer proposals. But we haven't
+        //     had time to acquire the right ledger.
+        //  3. Round to correct
+        sim.run(3);
+
+        // The network never actually forks, since node 0-1 never see a
+        // quorum of validations to fully validate the incorrect chain.
+
+        // However, for a non zero-validation delay, the network is not
+        // synchronized because nodes 0 and 1 are running one ledger behind
+        EXPECT_TRUE(sim.branches() == 1);
+        if (sim.branches() == 1)
+        {
+            for (Peer const* peer : majority)
+            {
+                // No jumps for majority nodes
+                EXPECT_TRUE(jumps[peer->id].closeJumps.empty());
+                EXPECT_TRUE(jumps[peer->id].fullyValidatedJumps.empty());
+            }
+            for (Peer const* peer : minority)
+            {
+                auto& peerJumps = jumps[peer->id];
+                // last closed ledger jump between chains
+                {
+                    EXPECT_TRUE(peerJumps.closeJumps.size() == 1);
+                    if (peerJumps.closeJumps.size() == 1)
+                    {
+                        JumpCollector::Jump const& jump = peerJumps.closeJumps.front();
+                        // Jump is to a different chain
+                        EXPECT_TRUE(jump.from.seq() <= jump.to.seq());
+                        EXPECT_TRUE(!jump.to.isAncestor(jump.from));
+                    }
+                }
+                // fully validated jump forward in same chain
+                {
+                    EXPECT_TRUE(peerJumps.fullyValidatedJumps.size() == 1);
+                    if (peerJumps.fullyValidatedJumps.size() == 1)
+                    {
+                        JumpCollector::Jump const& jump = peerJumps.fullyValidatedJumps.front();
+                        // Jump is to a different chain with same seq
+                        EXPECT_TRUE(jump.from.seq() < jump.to.seq());
+                        EXPECT_TRUE(jump.to.isAncestor(jump.from));
+                    }
+                }
+            }
+        }
+    }
+
+    {
+        // Additional test engineered to switch LCL during the establish
+        // phase. This was added to trigger a scenario that previously
+        // crashed, in which switchLCL switched from establish to open
+        // phase, but still processed the establish phase logic.
+
+        // Loner node will accept an initial ledger A, but all other nodes
+        // accept ledger B a bit later. By delaying the time it takes
+        // to process a validation, loner node will detect the wrongLCL
+        // after it is already in the establish phase of the next round.
+
+        Sim sim;
+        PeerGroup loner = sim.createGroup(1);
+        PeerGroup const friends = sim.createGroup(3);
+        loner.trust(loner + friends);
+
+        PeerGroup const others = sim.createGroup(6);
+        PeerGroup clique = friends + others;
+        clique.trust(clique);
+
+        PeerGroup network = loner + clique;
+        network.connect(network, round(0.2 * parms.ledgerGRANULARITY));
+
+        // initial round to set prior state
+        sim.run(1);
+        for (Peer* peer : (loner + friends))
+            peer->openTxs.insert(Tx(0));
+        for (Peer* peer : others)
+            peer->openTxs.insert(Tx(1));
+
+        // Delay validation processing
+        for (Peer* peer : network)
+            peer->delays.recvValidation = parms.ledgerGRANULARITY;
+
+        // additional rounds to generate wrongLCL and recover
+        sim.run(2);
+
+        // Check all peers recovered
+        for (Peer const* p : network)
+            EXPECT_TRUE(p->prevLedgerID() == network[0]->prevLedgerID());
+    }
+}
+
+TEST(ConsensusTest, consensus_close_time_rounding)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("consensus close time rounding");
+
+    // This is a specialized test engineered to yield ledgers with different
+    // close times even though the peers believe they had close time
+    // consensus on the ledger.
+    ConsensusParms const parms;
+
+    Sim sim;
+
+    // This requires a group of 4 fast and 2 slow peers to create a
+    // situation in which a subset of peers requires seeing additional
+    // proposals to declare consensus.
+    PeerGroup slow = sim.createGroup(2);
+    PeerGroup fast = sim.createGroup(4);
+    PeerGroup network = fast + slow;
+
+    // Connected trust graph
+    network.trust(network);
+
+    // Fast and slow network connections
+    fast.connect(fast, round(0.2 * parms.ledgerGRANULARITY));
+    slow.connect(network, round(1.1 * parms.ledgerGRANULARITY));
+
+    // Run to the ledger *prior* to decreasing the resolution
+    sim.run(kIncreaseLedgerTimeResolutionEvery - 2);
+
+    // In order to create the discrepancy, we want a case where if
+    //   X = effCloseTime(closeTime, resolution, parentCloseTime)
+    //   X != effCloseTime(X, resolution, parentCloseTime)
+    //
+    // That is, the effective close time is not a fixed point. This can
+    // happen if X = parentCloseTime + 1, but a subsequent rounding goes
+    // to the next highest multiple of resolution.
+
+    // So we want to find an offset  (now + offset) % 30s = 15
+    //                               (now + offset) % 20s = 15
+    // This way, the next ledger will close and round up   Due to the
+    // network delay settings, the round of consensus will take 5s, so
+    // the next ledger's close time will
+
+    NetClock::duration when = network[0]->now().time_since_epoch();
+
+    // Check we are before the 30s to 20s transition
+    NetClock::duration const resolution = network[0]->lastClosedLedger.closeTimeResolution();
+    EXPECT_TRUE(resolution == NetClock::duration{30s});
+
+    while (((when % NetClock::duration{30s}) != NetClock::duration{15s}) ||
+           ((when % NetClock::duration{20s}) != NetClock::duration{15s}))
+        when += 1s;
+    // Advance the clock without consensus running (IS THIS WHAT
+    // PREVENTS IT IN PRACTICE?)
+    sim.scheduler.stepFor(NetClock::time_point{when} - network[0]->now());
+
+    // Run one more ledger with 30s resolution
+    sim.run(1);
+    EXPECT_TRUE(sim.synchronized());
+    if (sim.synchronized())
+    {
+        // close time should be ahead of clock time since we engineered
+        // the close time to round up
+        for (Peer const* peer : network)
+        {
+            EXPECT_TRUE(peer->lastClosedLedger.closeTime() > peer->now());
+            EXPECT_TRUE(peer->lastClosedLedger.closeAgree());
+        }
+    }
+
+    // All peers submit their own ID as a transaction
+    for (Peer* peer : network)
+        peer->submit(Tx{static_cast(peer->id)});
+
+    // Run 1 more round, this time it will have a decreased
+    // resolution of 20 seconds.
+
+    // The network delays are engineered so that the slow peers
+    // initially have the wrong tx hash, but they see a majority
+    // of agreement from their peers and declare consensus
+    //
+    // The trick is that everyone starts with a raw close time of
+    //  84681s
+    // Which has
+    //   effCloseTime(86481s, 20s,  86490s) = 86491s
+    // However, when the slow peers update their position, they change
+    // the close time to 86451s. The fast peers declare consensus with
+    // the 86481s as their position still.
+    //
+    // When accepted the ledger
+    // - fast peers use eff(86481s) -> 86491s as the close time
+    // - slow peers use eff(eff(86481s)) -> eff(86491s) -> 86500s!
+
+    sim.run(1);
+
+    EXPECT_TRUE(sim.synchronized());
+}
+
+TEST(ConsensusTest, fork)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("fork");
+
+    std::uint32_t const numPeers = 10;
+    // Vary overlap between two UNLs
+    for (std::uint32_t overlap = 0; overlap <= numPeers; ++overlap)
+    {
+        ConsensusParms const parms{};
+        Sim sim;
+
+        std::uint32_t const numA = (numPeers - overlap) / 2;
+        std::uint32_t const numB = numPeers - numA - overlap;
+
+        PeerGroup const aOnly = sim.createGroup(numA);
+        PeerGroup const bOnly = sim.createGroup(numB);
+        PeerGroup const commonOnly = sim.createGroup(overlap);
+
+        PeerGroup a = aOnly + commonOnly;
+        PeerGroup b = bOnly + commonOnly;
+
+        PeerGroup const network = a + b;
+
+        SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+        a.trustAndConnect(a, delay);
+        b.trustAndConnect(b, delay);
+
+        // Initial round to set prior state
+        sim.run(1);
+        for (Peer* peer : network)
+        {
+            // Nodes have only seen transactions from their neighbors
+            peer->openTxs.insert(Tx{static_cast(peer->id)});
+            for (Peer const* to : sim.trustGraph.trustedPeers(peer))
+                peer->openTxs.insert(Tx{static_cast(to->id)});
+        }
+        sim.run(1);
+
+        // Fork should not happen for 40% or greater overlap
+        // Since the overlapped nodes have a UNL that is the union of the
+        // two cliques, the maximum sized UNL list is the number of peers
+        if (overlap > 0.4 * numPeers)
+        {
+            EXPECT_TRUE(sim.synchronized());
+        }
+        else
+        {
+            // Even if we do fork, there shouldn't be more than 3 ledgers
+            // One for cliqueA, one for cliqueB and one for nodes in both
+            EXPECT_TRUE(sim.branches() <= 3);
+        }
+    }
+}
+
+TEST(ConsensusTest, hub_network)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("hub network");
+
+    // Simulate a set of 5 validators that aren't directly connected but
+    // rely on a single hub node for communication
+
+    ConsensusParms const parms{};
+    Sim sim;
+    PeerGroup validators = sim.createGroup(5);
+    PeerGroup center = sim.createGroup(1);
+    validators.trust(validators);
+    center.trust(validators);
+
+    SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+    validators.connect(center, delay);
+
+    center[0]->runAsValidator = false;
+
+    // prep round to set initial state.
+    sim.run(1);
+
+    // everyone submits their own ID as a TX and relay it to peers
+    for (Peer* p : validators)
+        p->submit(Tx(static_cast(p->id)));
+
+    sim.run(1);
+
+    // All peers are in sync
+    EXPECT_TRUE(sim.synchronized());
+}
+
+TEST(ConsensusTest, preferred_by_branch)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("preferred by branch");
+
+    // Simulate network splits that are prevented from forking when using
+    // preferred ledger by trie.  This is a contrived example that involves
+    // excessive network splits, but demonstrates the safety improvement
+    // from the preferred ledger by trie approach.
+
+    // Consider 10 validating nodes that comprise a single common UNL
+    // Ledger history:
+    // 1:           A
+    //            _/ \_
+    // 2:         B    C
+    //          _/  _/  \_
+    // 3:       D   C'  |||||||| (8 different ledgers)
+
+    // - All nodes generate the common ledger A
+    // - 2 nodes generate B and 8 nodes generate C
+    // - Only 1 of the C nodes sees all the C validations and fully
+    //   validates C. The rest of the C nodes split at just the right time
+    //   such that they never see any C validations but their own.
+    // - The C nodes continue and generate 8 different child ledgers.
+    // - Meanwhile, the D nodes only saw 1 validation for C and 2
+    // validations
+    //   for B.
+    // - The network reconnects and the validations for generation 3 ledgers
+    //   are observed (D and the 8 C's)
+    // - In the old approach, 2 votes for D outweighs 1 vote for each C'
+    //   so the network would avalanche towards D and fully validate it
+    //   EVEN though C was fully validated by one node
+    // - In the new approach, 2 votes for D are not enough to outweight the
+    //   8 implicit votes for C, so nodes will avalanche to C instead
+
+    ConsensusParms const parms{};
+    Sim sim;
+
+    // Goes A->B->D
+    PeerGroup const groupABD = sim.createGroup(2);
+    // Single node that initially fully validates C before the split
+    PeerGroup groupCfast = sim.createGroup(1);
+    // Generates C, but fails to fully validate before the split
+    PeerGroup groupCsplit = sim.createGroup(7);
+
+    PeerGroup groupNotFastC = groupABD + groupCsplit;
+    PeerGroup network = groupABD + groupCsplit + groupCfast;
+
+    SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+    SimDuration const fDelay = round(0.1 * parms.ledgerGRANULARITY);
+
+    network.trust(network);
+    // C must have a shorter delay to see all the validations before the
+    // other nodes
+    network.connect(groupCfast, fDelay);
+    // The rest of the network is connected at the same speed
+    groupNotFastC.connect(groupNotFastC, delay);
+
+    Disruptor dc(network, groupCfast, groupCsplit, delay);
+    sim.collectors.add(dc);
+
+    // Consensus round to generate ledger A
+    sim.run(1);
+    EXPECT_TRUE(sim.synchronized());
+
+    // Next round generates B and C
+    // To force B, we inject an extra transaction in to those nodes
+    for (Peer* peer : groupABD)
+    {
+        peer->txInjections.emplace(peer->lastClosedLedger.seq(), Tx{42});
+    }
+    // The Disruptor will ensure that nodes disconnect before the C
+    // validations make it to all but the fastC node
+    sim.run(1);
+
+    // We are no longer in sync, but have not yet forked:
+    // 9 nodes consider A the last fully validated ledger and fastC sees C
+    EXPECT_TRUE(!sim.synchronized());
+    EXPECT_TRUE(sim.branches() == 1);
+
+    //  Run another round to generate the 8 different C' ledgers
+    for (Peer* p : network)
+        p->submit(Tx(static_cast(p->id)));
+    sim.run(1);
+
+    // Still not forked
+    EXPECT_TRUE(!sim.synchronized());
+    EXPECT_TRUE(sim.branches() == 1);
+
+    // Disruptor will reconnect all but the fastC node
+    sim.run(1);
+
+    EXPECT_TRUE(sim.branches() == 1);
+    if (sim.branches() == 1)
+    {
+        EXPECT_TRUE(sim.synchronized());
+    }
+    else  // old approach caused a fork
+    {
+        EXPECT_TRUE(sim.branches(groupNotFastC) == 1);
+        EXPECT_TRUE(sim.synchronized(groupNotFastC) == 1);
+    }
+}
+
+TEST(ConsensusTest, pause_for_laggards)
+{
+    using namespace csf;
+    using namespace std::chrono;
+    SCOPED_TRACE("pause for laggards");
+
+    // Test that validators that jump ahead of the network slow
+    // down.
+
+    // We engineer the following validated ledger history scenario:
+    //
+    //  / --> B1 --> C1 --> ... -> G1  "ahead"
+    // A
+    //  \ --> B2 --> C2 "behind"
+    //
+    // After validating a common ledger A, a set of "behind" validators
+    // briefly run slower and validate the lower chain of ledgers.
+    // The "ahead" validators run normal speed and run ahead validating the
+    // upper chain of ledgers.
+    //
+    // Due to the uncommitted support definition of the preferred branch
+    // protocol, even if the "behind" validators are a majority, the "ahead"
+    // validators cannot jump to the proper branch until the "behind"
+    // validators catch up to the same sequence number. For this test to
+    // succeed, the ahead validators need to briefly slow down consensus.
+
+    ConsensusParms const parms{};
+    Sim sim;
+    SimDuration const delay = round(0.2 * parms.ledgerGRANULARITY);
+
+    PeerGroup behind = sim.createGroup(3);
+    PeerGroup const ahead = sim.createGroup(2);
+    PeerGroup network = ahead + behind;
+
+    hash_set trustedKeys;
+    for (Peer const* p : network)
+        trustedKeys.insert(p->key);
+    for (Peer* p : network)
+        p->trustedKeys = trustedKeys;
+
+    network.trustAndConnect(network, delay);
+
+    // Initial seed round to set prior state
+    sim.run(1);
+
+    // Have the "behind" group initially take a really long time to
+    // accept a ledger after ending deliberation
+    for (Peer* p : behind)
+        p->delays.ledgerAccept = 20s;
+
+    // Use the collector to revert the delay after the single
+    // slow ledger is generated
+    UndoDelay undoDelay{behind};
+    sim.collectors.add(undoDelay);
+
+    // Run the simulation for 100 seconds of simulation time with
+    std::chrono::nanoseconds const simDuration = 100s;
+
+    // Simulate clients submitting 1 tx every 5 seconds to a random
+    // validator
+    Rate const rate{.count = 1, .duration = 5s};
+    auto peerSelector = makeSelector(
+        network.begin(), network.end(), std::vector(network.size(), 1.), sim.rng);
+    auto txSubmitter = makeSubmitter(
+        ConstantDistribution{rate.inv()},
+        sim.scheduler.now(),
+        sim.scheduler.now() + simDuration,
+        peerSelector,
+        sim.scheduler,
+        sim.rng);
+
+    // Run simulation
+    sim.run(simDuration);
+
+    // Verify that the network recovered
+    EXPECT_TRUE(sim.synchronized());
+}
+
+TEST(ConsensusTest, disputes)
+{
+    SCOPED_TRACE("disputes");
+
+    using namespace csf;
+
+    // Test dispute objects directly
+    using Dispute = CsfDisputedTx;
+
+    Tx const txTrue{99};
+    Tx const txFalse{98};
+    Tx const txFollowingTrue{97};
+    Tx const txFollowingFalse{96};
+    int const numPeers = 100;
+    ConsensusParms const p;
+    std::size_t peersUnchanged = 0;
+
+    auto clog = std::make_unique();
+
+    // Three cases:
+    // 1 proposing, initial vote yes
+    // 2 proposing, initial vote no
+    // 3 not proposing, initial vote doesn't matter after the first update,
+    // use yes
+    {
+        Dispute proposingTrue = makeDisputedTx(txTrue, true, numPeers);
+        Dispute proposingFalse = makeDisputedTx(txFalse, false, numPeers);
+        Dispute followingTrue = makeDisputedTx(txFollowingTrue, true, numPeers);
+        Dispute followingFalse = makeDisputedTx(txFollowingFalse, false, numPeers);
+        EXPECT_TRUE(proposingTrue.id() == 99);
+        EXPECT_TRUE(proposingFalse.id() == 98);
+        EXPECT_TRUE(followingTrue.id() == 97);
+        EXPECT_TRUE(followingFalse.id() == 96);
+
+        // Create an even split in the peer votes
+        for (int i = 0; i < numPeers; ++i)
+        {
+            EXPECT_TRUE(proposingTrue.setVote(PeerID(i), i < 50));
+            EXPECT_TRUE(proposingFalse.setVote(PeerID(i), i < 50));
+            EXPECT_TRUE(followingTrue.setVote(PeerID(i), i < 50));
+            EXPECT_TRUE(followingFalse.setVote(PeerID(i), i < 50));
+        }
+        // Switch the middle vote to match mine
+        EXPECT_TRUE(proposingTrue.setVote(PeerID(50), true));
+        EXPECT_TRUE(proposingFalse.setVote(PeerID(49), false));
+        EXPECT_TRUE(followingTrue.setVote(PeerID(50), true));
+        EXPECT_TRUE(followingFalse.setVote(PeerID(49), false));
+
+        // no changes yet
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+        EXPECT_TRUE(!isStalled(proposingTrue, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(proposingFalse, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingTrue, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingFalse, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(clog->str().empty());
+
+        // I'm in the majority, my vote should not change
+        EXPECT_TRUE(!proposingTrue.updateVote(5, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(5, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(5, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(5, false, p));
+
+        EXPECT_TRUE(!proposingTrue.updateVote(10, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(10, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(10, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(10, false, p));
+
+        peersUnchanged = 2;
+        EXPECT_TRUE(!isStalled(proposingTrue, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(proposingFalse, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingTrue, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingFalse, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(clog->str().empty());
+
+        // Right now, the vote is 51%. The requirement is about to jump to
+        // 65%
+        EXPECT_TRUE(proposingTrue.updateVote(55, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(55, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(55, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(55, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == false);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+        // 16 validators change their vote to match my original vote
+        for (int i = 0; i < 16; ++i)
+        {
+            auto pTrue = PeerID(numPeers - i - 1);
+            auto pFalse = PeerID(i);
+            EXPECT_TRUE(proposingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(proposingFalse.setVote(pFalse, false));
+            EXPECT_TRUE(followingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(followingFalse.setVote(pFalse, false));
+        }
+        // The vote should now be 66%, threshold is 65%
+        EXPECT_TRUE(proposingTrue.updateVote(60, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(60, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(60, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(60, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        // Threshold jumps to 70%
+        EXPECT_TRUE(proposingTrue.updateVote(86, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(86, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(86, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(86, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == false);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        // 5 more validators change their vote to match my original vote
+        for (int i = 16; i < 21; ++i)
+        {
+            auto pTrue = PeerID(numPeers - i - 1);
+            auto pFalse = PeerID(i);
+            EXPECT_TRUE(proposingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(proposingFalse.setVote(pFalse, false));
+            EXPECT_TRUE(followingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(followingFalse.setVote(pFalse, false));
+        }
+
+        // The vote should now be 71%, threshold is 70%
+        EXPECT_TRUE(proposingTrue.updateVote(90, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(90, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(90, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(90, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        // The vote should now be 71%, threshold is 70%
+        EXPECT_TRUE(!proposingTrue.updateVote(150, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(150, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(150, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(150, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        // The vote should now be 71%, threshold is 70%
+        EXPECT_TRUE(!proposingTrue.updateVote(190, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(190, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(190, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(190, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        peersUnchanged = 3;
+        EXPECT_TRUE(!isStalled(proposingTrue, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(proposingFalse, p, true, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingTrue, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(!isStalled(followingFalse, p, false, peersUnchanged, clog));
+        EXPECT_TRUE(clog->str().empty());
+
+        // Threshold jumps to 95%
+        EXPECT_TRUE(proposingTrue.updateVote(220, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(220, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(220, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(220, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == false);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        // 25 more validators change their vote to match my original vote
+        for (int i = 21; i < 46; ++i)
+        {
+            auto pTrue = PeerID(numPeers - i - 1);
+            auto pFalse = PeerID(i);
+            EXPECT_TRUE(proposingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(proposingFalse.setVote(pFalse, false));
+            EXPECT_TRUE(followingTrue.setVote(pTrue, true));
+            EXPECT_TRUE(followingFalse.setVote(pFalse, false));
+        }
+
+        // The vote should now be 96%, threshold is 95%
+        EXPECT_TRUE(proposingTrue.updateVote(250, true, p));
+        EXPECT_TRUE(!proposingFalse.updateVote(250, true, p));
+        EXPECT_TRUE(!followingTrue.updateVote(250, false, p));
+        EXPECT_TRUE(!followingFalse.updateVote(250, false, p));
+
+        EXPECT_TRUE(proposingTrue.getOurVote() == true);
+        EXPECT_TRUE(proposingFalse.getOurVote() == false);
+        EXPECT_TRUE(followingTrue.getOurVote() == true);
+        EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+        for (peersUnchanged = 0; peersUnchanged < 6; ++peersUnchanged)
+        {
+            EXPECT_TRUE(!isStalled(proposingTrue, p, true, peersUnchanged, clog));
+            EXPECT_TRUE(!isStalled(proposingFalse, p, true, peersUnchanged, clog));
+            EXPECT_TRUE(!isStalled(followingTrue, p, false, peersUnchanged, clog));
+            EXPECT_TRUE(!isStalled(followingFalse, p, false, peersUnchanged, clog));
+            EXPECT_TRUE(clog->str().empty());
+        }
+
+        auto expectStalled = [&clog](
+                                 int txid,
+                                 bool ourVote,
+                                 int ourTime,
+                                 int peerTime,
+                                 int support,
+                                 std::uint32_t line) {
+            using namespace std::string_literals;
+
+            auto const s = clog->str();
+            SCOPED_TRACE(::testing::Message() << __FILE__ << ":" << line);
+            EXPECT_NE(s.find("stalled"), s.npos) << s;
+            EXPECT_TRUE(s.starts_with("Transaction "s + std::to_string(txid))) << s;
+            EXPECT_NE(s.find("voting "s + (ourVote ? "YES" : "NO")), s.npos) << s;
+            EXPECT_NE(s.find("for "s + std::to_string(ourTime) + " rounds."s), s.npos) << s;
+            EXPECT_NE(s.find("votes in "s + std::to_string(peerTime) + " rounds."), s.npos) << s;
+            EXPECT_TRUE(s.ends_with("has "s + std::to_string(support) + "% support. "s)) << s;
+            clog = std::make_unique();
+        };
+
+        for (int i = 0; i < 1; ++i)
+        {
+            EXPECT_TRUE(!proposingTrue.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!proposingFalse.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!followingTrue.updateVote(250 + (10 * i), false, p));
+            EXPECT_TRUE(!followingFalse.updateVote(250 + (10 * i), false, p));
+
+            EXPECT_TRUE(proposingTrue.getOurVote() == true);
+            EXPECT_TRUE(proposingFalse.getOurVote() == false);
+            EXPECT_TRUE(followingTrue.getOurVote() == true);
+            EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+            // true vote has changed recently, so not stalled
+            EXPECT_TRUE(!isStalled(proposingTrue, p, true, 0, clog));
+            EXPECT_TRUE(clog->str().empty());
+            // remaining votes have been unchanged in so long that we only
+            // need to hit the second round at 95% to be stalled, regardless
+            // of peers
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, 0, clog));
+            expectStalled(98, false, 11, 0, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, 0, clog));
+            expectStalled(97, true, 11, 0, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, 0, clog));
+            expectStalled(96, false, 11, 0, 3, __LINE__);
+
+            // true vote has changed recently, so not stalled
+            EXPECT_TRUE(!isStalled(proposingTrue, p, true, peersUnchanged, clog));
+            EXPECT_TRUE(clog->str().empty()) << clog->str();
+            // remaining votes have been unchanged in so long that we only
+            // need to hit the second round at 95% to be stalled, regardless
+            // of peers
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, peersUnchanged, clog));
+            expectStalled(98, false, 11, 6, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, peersUnchanged, clog));
+            expectStalled(97, true, 11, 6, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, peersUnchanged, clog));
+            expectStalled(96, false, 11, 6, 3, __LINE__);
+        }
+        for (int i = 1; i < 3; ++i)
+        {
+            EXPECT_TRUE(!proposingTrue.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!proposingFalse.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!followingTrue.updateVote(250 + (10 * i), false, p));
+            EXPECT_TRUE(!followingFalse.updateVote(250 + (10 * i), false, p));
+
+            EXPECT_TRUE(proposingTrue.getOurVote() == true);
+            EXPECT_TRUE(proposingFalse.getOurVote() == false);
+            EXPECT_TRUE(followingTrue.getOurVote() == true);
+            EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+            // true vote changed 2 rounds ago, and peers are changing, so
+            // not stalled
+            EXPECT_TRUE(!isStalled(proposingTrue, p, true, 0, clog));
+            EXPECT_TRUE(clog->str().empty()) << clog->str();
+            // still stalled
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, 0, clog));
+            expectStalled(98, false, 11 + i, 0, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, 0, clog));
+            expectStalled(97, true, 11 + i, 0, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, 0, clog));
+            expectStalled(96, false, 11 + i, 0, 3, __LINE__);
+
+            // true vote changed 2 rounds ago, and peers are NOT changing,
+            // so stalled
+            EXPECT_TRUE(isStalled(proposingTrue, p, true, peersUnchanged, clog));
+            expectStalled(99, true, 1 + i, 6, 97, __LINE__);
+            // still stalled
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, peersUnchanged, clog));
+            expectStalled(98, false, 11 + i, 6, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, peersUnchanged, clog));
+            expectStalled(97, true, 11 + i, 6, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, peersUnchanged, clog));
+            expectStalled(96, false, 11 + i, 6, 3, __LINE__);
+        }
+        for (int i = 3; i < 5; ++i)
+        {
+            EXPECT_TRUE(!proposingTrue.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!proposingFalse.updateVote(250 + (10 * i), true, p));
+            EXPECT_TRUE(!followingTrue.updateVote(250 + (10 * i), false, p));
+            EXPECT_TRUE(!followingFalse.updateVote(250 + (10 * i), false, p));
+
+            EXPECT_TRUE(proposingTrue.getOurVote() == true);
+            EXPECT_TRUE(proposingFalse.getOurVote() == false);
+            EXPECT_TRUE(followingTrue.getOurVote() == true);
+            EXPECT_TRUE(followingFalse.getOurVote() == false);
+
+            EXPECT_TRUE(isStalled(proposingTrue, p, true, 0, clog));
+            expectStalled(99, true, 1 + i, 0, 97, __LINE__);
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, 0, clog));
+            expectStalled(98, false, 11 + i, 0, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, 0, clog));
+            expectStalled(97, true, 11 + i, 0, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, 0, clog));
+            expectStalled(96, false, 11 + i, 0, 3, __LINE__);
+
+            EXPECT_TRUE(isStalled(proposingTrue, p, true, peersUnchanged, clog));
+            expectStalled(99, true, 1 + i, 6, 97, __LINE__);
+            EXPECT_TRUE(isStalled(proposingFalse, p, true, peersUnchanged, clog));
+            expectStalled(98, false, 11 + i, 6, 2, __LINE__);
+            EXPECT_TRUE(isStalled(followingTrue, p, false, peersUnchanged, clog));
+            expectStalled(97, true, 11 + i, 6, 97, __LINE__);
+            EXPECT_TRUE(isStalled(followingFalse, p, false, peersUnchanged, clog));
+            expectStalled(96, false, 11 + i, 6, 3, __LINE__);
+        }
+    }
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/DistributedValidatorsSim.cpp b/src/tests/libxrpl/consensus/DistributedValidatorsSim.cpp
new file mode 100644
index 0000000000..f5ba508cbc
--- /dev/null
+++ b/src/tests/libxrpl/consensus/DistributedValidatorsSim.cpp
@@ -0,0 +1,252 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+namespace {
+
+[[nodiscard]] std::string const&
+arg()
+{
+    static std::string const kEMPTY;
+    return kEMPTY;
+}
+
+void
+completeTrustCompleteConnectFixedDelay(
+    std::size_t numPeers,
+    std::chrono::milliseconds delay = std::chrono::milliseconds(200),
+    bool printHeaders = false)
+{
+    using namespace csf;
+    using namespace std::chrono;
+
+    // Initialize persistent collector logs specific to this method
+    std::string const prefix =
+        "DistributedValidators_"
+        "completeTrustCompleteConnectFixedDelay";
+    std::fstream txLog(prefix + "_tx.csv", std::ofstream::app),
+        ledgerLog(prefix + "_ledger.csv", std::ofstream::app);
+
+    // title
+    std::cout << prefix << "(" << numPeers << "," << delay.count() << ")" << std::endl;
+
+    // number of peers, UNLs, connections
+    EXPECT_TRUE(numPeers >= 1);
+
+    Sim sim;
+    PeerGroup peers = sim.createGroup(numPeers);
+
+    // complete trust graph
+    peers.trust(peers);
+
+    // complete connect graph with fixed delay
+    peers.connect(peers, delay);
+
+    // Initialize collectors to track statistics to report
+    TxCollector txCollector;
+    LedgerCollector ledgerCollector;
+    auto colls = makeCollectors(txCollector, ledgerCollector);
+    sim.collectors.add(colls);
+
+    // Initial round to set prior state
+    sim.run(1);
+
+    // Run for 10 minutes, submitting 100 tx/second
+    std::chrono::nanoseconds const simDuration = 10min;
+    std::chrono::nanoseconds const quiet = 10s;
+    Rate const rate{.count = 100, .duration = 1000ms};
+
+    // Initialize timers
+    HeartbeatTimer heart(sim.scheduler);
+
+    // txs, start/stop/step, target
+    auto peerSelector =
+        makeSelector(peers.begin(), peers.end(), std::vector(numPeers, 1.), sim.rng);
+    auto txSubmitter = makeSubmitter(
+        ConstantDistribution{rate.inv()},
+        sim.scheduler.now() + quiet,
+        sim.scheduler.now() + simDuration - quiet,
+        peerSelector,
+        sim.scheduler,
+        sim.rng);
+
+    // run simulation for given duration
+    heart.start();
+    sim.run(simDuration);
+
+    // EXPECT_TRUE(sim.branches() == 1);
+    // EXPECT_TRUE(sim.synchronized());
+
+    std::cout << std::right;
+    std::cout << "| Peers: " << std::setw(2) << peers.size();
+    std::cout << " | Duration: " << std::setw(6) << duration_cast(simDuration).count()
+              << " ms";
+    std::cout << " | Branches: " << std::setw(1) << sim.branches();
+    std::cout << " | Synchronized: " << std::setw(1) << (sim.synchronized() ? "Y" : "N");
+    std::cout << " |" << std::endl;
+
+    txCollector.report(simDuration, std::cout, true);
+    ledgerCollector.report(simDuration, std::cout, false);
+
+    std::string const tag = std::to_string(numPeers);
+    txCollector.csv(simDuration, txLog, tag, printHeaders);
+    ledgerCollector.csv(simDuration, ledgerLog, tag, printHeaders);
+
+    std::cout << std::endl;
+}
+
+void
+completeTrustScaleFreeConnectFixedDelay(
+    std::size_t numPeers,
+    std::chrono::milliseconds delay = std::chrono::milliseconds(200),
+    bool printHeaders = false)
+{
+    using namespace csf;
+    using namespace std::chrono;
+
+    // Initialize persistent collector logs specific to this method
+    std::string const prefix =
+        "DistributedValidators__"
+        "completeTrustScaleFreeConnectFixedDelay";
+    std::fstream txLog(prefix + "_tx.csv", std::ofstream::app),
+        ledgerLog(prefix + "_ledger.csv", std::ofstream::app);
+
+    // title
+    std::cout << prefix << "(" << numPeers << "," << delay.count() << ")" << std::endl;
+
+    // number of peers, UNLs, connections
+    int const numCNLs = std::max(int(1.00 * numPeers), 1);
+    int const minCNLSize = std::max(int(0.25 * numCNLs), 1);
+    int const maxCNLSize = std::max(int(0.50 * numCNLs), 1);
+    EXPECT_TRUE(numPeers >= 1);
+    EXPECT_TRUE(numCNLs >= 1);
+    EXPECT_TRUE(1 <= minCNLSize && minCNLSize <= maxCNLSize && maxCNLSize <= numPeers);
+
+    Sim sim;
+    PeerGroup peers = sim.createGroup(numPeers);
+
+    // complete trust graph
+    peers.trust(peers);
+
+    // scale-free connect graph with fixed delay
+    std::vector const ranks = sample(peers.size(), PowerLawDistribution{1, 3}, sim.rng);
+    randomRankedConnect(
+        peers,
+        ranks,
+        numCNLs,
+        std::uniform_int_distribution<>{minCNLSize, maxCNLSize},
+        sim.rng,
+        delay);
+
+    // Initialize collectors to track statistics to report
+    TxCollector txCollector;
+    LedgerCollector ledgerCollector;
+    auto colls = makeCollectors(txCollector, ledgerCollector);
+    sim.collectors.add(colls);
+
+    // Initial round to set prior state
+    sim.run(1);
+
+    // Run for 10 minutes, submitting 100 tx/second
+    std::chrono::nanoseconds const simDuration = 10min;
+    std::chrono::nanoseconds const quiet = 10s;
+    Rate const rate{.count = 100, .duration = 1000ms};
+
+    // Initialize timers
+    HeartbeatTimer heart(sim.scheduler);
+
+    // txs, start/stop/step, target
+    auto peerSelector =
+        makeSelector(peers.begin(), peers.end(), std::vector(numPeers, 1.), sim.rng);
+    auto txSubmitter = makeSubmitter(
+        ConstantDistribution{rate.inv()},
+        sim.scheduler.now() + quiet,
+        sim.scheduler.now() + simDuration - quiet,
+        peerSelector,
+        sim.scheduler,
+        sim.rng);
+
+    // run simulation for given duration
+    heart.start();
+    sim.run(simDuration);
+
+    // EXPECT_TRUE(sim.branches() == 1);
+    // EXPECT_TRUE(sim.synchronized());
+
+    std::cout << std::right;
+    std::cout << "| Peers: " << std::setw(2) << peers.size();
+    std::cout << " | Duration: " << std::setw(6) << duration_cast(simDuration).count()
+              << " ms";
+    std::cout << " | Branches: " << std::setw(1) << sim.branches();
+    std::cout << " | Synchronized: " << std::setw(1) << (sim.synchronized() ? "Y" : "N");
+    std::cout << " |" << std::endl;
+
+    txCollector.report(simDuration, std::cout, true);
+    ledgerCollector.report(simDuration, std::cout, false);
+
+    std::string const tag = std::to_string(numPeers);
+    txCollector.csv(simDuration, txLog, tag, printHeaders);
+    ledgerCollector.csv(simDuration, ledgerLog, tag, printHeaders);
+
+    std::cout << std::endl;
+}
+
+}  // namespace
+
+// In progress simulations for diversifying and distributing validators
+TEST(DistributedValidatorsTest, DISABLED_distributed_validators)
+{
+    std::string const defaultArgs = "5 200";
+    std::string const args = arg().empty() ? defaultArgs : arg();
+    std::stringstream argStream(args);
+
+    int maxNumValidators = 0;
+    int delayCount(200);
+    argStream >> maxNumValidators;
+    argStream >> delayCount;
+
+    std::chrono::milliseconds const delay(delayCount);
+
+    std::cout << "DistributedValidators: 1 to " << maxNumValidators << " Peers" << std::endl;
+
+    // Simulate with N = 1 to N
+    // - complete trust graph is complete
+    // - complete network connectivity
+    // - fixed delay for network links
+    completeTrustCompleteConnectFixedDelay(1, delay, true);
+    for (int i = 2; i <= maxNumValidators; i++)
+    {
+        completeTrustCompleteConnectFixedDelay(i, delay);
+    }
+
+    // Simulate with N = 1 to N
+    // - complete trust graph is complete
+    // - scale-free network connectivity
+    // - fixed delay for network links
+    completeTrustScaleFreeConnectFixedDelay(1, delay, true);
+    for (int i = 2; i <= maxNumValidators; i++)
+    {
+        completeTrustScaleFreeConnectFixedDelay(i, delay);
+    }
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/LedgerTiming.cpp b/src/tests/libxrpl/consensus/LedgerTiming.cpp
new file mode 100644
index 0000000000..0cab895fda
--- /dev/null
+++ b/src/tests/libxrpl/consensus/LedgerTiming.cpp
@@ -0,0 +1,105 @@
+#include 
+
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(LedgerTimingTest, get_next_ledger_time_resolution)
+{
+    // helper to iteratively call into getNextLedgerTimeResolution
+    struct TestRes
+    {
+        std::uint32_t decrease = 0;
+        std::uint32_t equal = 0;
+        std::uint32_t increase = 0;
+
+        static TestRes
+        run(bool previousAgree, std::uint32_t rounds)
+        {
+            TestRes res;
+            auto closeResolution = kLedgerDefaultTimeResolution;
+            auto nextCloseResolution = closeResolution;
+            std::uint32_t round = 0;
+            do
+            {
+                nextCloseResolution =
+                    getNextLedgerTimeResolution(closeResolution, previousAgree, ++round);
+                if (nextCloseResolution < closeResolution)
+                {
+                    ++res.decrease;
+                }
+                else if (nextCloseResolution > closeResolution)
+                {
+                    ++res.increase;
+                }
+                else
+                {
+                    ++res.equal;
+                }
+                std::swap(nextCloseResolution, closeResolution);
+            } while (round < rounds);
+            return res;
+        }
+    };
+
+    // If we never agree on close time, only can increase resolution
+    // until hit the max
+    auto decreases = TestRes::run(false, 10);
+    EXPECT_TRUE(decreases.increase == 3);
+    EXPECT_TRUE(decreases.decrease == 0);
+    EXPECT_TRUE(decreases.equal == 7);
+
+    // If we always agree on close time, only can decrease resolution
+    // until hit the min
+    auto increases = TestRes::run(false, 100);
+    EXPECT_TRUE(increases.increase == 3);
+    EXPECT_TRUE(increases.decrease == 0);
+    EXPECT_TRUE(increases.equal == 97);
+}
+
+TEST(LedgerTimingTest, round_close_time)
+{
+    using namespace std::chrono_literals;
+    // A closeTime equal to the epoch is not modified
+    using tp = NetClock::time_point;
+    tp const def;
+    EXPECT_TRUE(def == roundCloseTime(def, 30s));
+
+    // Otherwise, the closeTime is rounded to the nearest
+    // rounding up on ties
+    EXPECT_TRUE(tp{0s} == roundCloseTime(tp{29s}, 60s));
+    EXPECT_TRUE(tp{30s} == roundCloseTime(tp{30s}, 1s));
+    EXPECT_TRUE(tp{60s} == roundCloseTime(tp{31s}, 60s));
+    EXPECT_TRUE(tp{60s} == roundCloseTime(tp{30s}, 60s));
+    EXPECT_TRUE(tp{60s} == roundCloseTime(tp{59s}, 60s));
+    EXPECT_TRUE(tp{60s} == roundCloseTime(tp{60s}, 60s));
+    EXPECT_TRUE(tp{60s} == roundCloseTime(tp{61s}, 60s));
+}
+
+TEST(LedgerTimingTest, eff_close_time)
+{
+    using namespace std::chrono_literals;
+    using tp = NetClock::time_point;
+    tp close = effCloseTime(tp{10s}, 30s, tp{0s});
+    EXPECT_TRUE(close == tp{1s});
+
+    close = effCloseTime(tp{16s}, 30s, tp{0s});
+    EXPECT_TRUE(close == tp{30s});
+
+    close = effCloseTime(tp{16s}, 30s, tp{30s});
+    EXPECT_TRUE(close == tp{31s});
+
+    close = effCloseTime(tp{16s}, 30s, tp{60s});
+    EXPECT_TRUE(close == tp{61s});
+
+    close = effCloseTime(tp{31s}, 30s, tp{0s});
+    EXPECT_TRUE(close == tp{30s});
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/LedgerTrie.cpp b/src/tests/libxrpl/consensus/LedgerTrie.cpp
new file mode 100644
index 0000000000..1259a5049a
--- /dev/null
+++ b/src/tests/libxrpl/consensus/LedgerTrie.cpp
@@ -0,0 +1,693 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(LedgerTrieTest, insert)
+{
+    using namespace csf;
+    // Single entry by itself
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 1);
+
+        t.insert(h["abc"]);
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+    }
+    // Suffix of existing (extending tree)
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        EXPECT_TRUE(t.checkInvariants());
+        // extend with no siblings
+        t.insert(h["abcd"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 1);
+
+        // extend with existing sibling
+        t.insert(h["abce"]);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 3);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abce"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abce"]) == 1);
+    }
+    // uncommitted of existing node
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abcd"]);
+        EXPECT_TRUE(t.checkInvariants());
+        // uncommitted with no siblings
+        t.insert(h["abcdf"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcdf"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcdf"]) == 1);
+
+        // uncommitted with existing child
+        t.insert(h["abc"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 3);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcdf"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcdf"]) == 1);
+    }
+    // Suffix + uncommitted of existing node
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abcd"]);
+        EXPECT_TRUE(t.checkInvariants());
+        t.insert(h["abce"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abce"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abce"]) == 1);
+    }
+    // Suffix + uncommitted with existing child
+    {
+        //  abcd : abcde, abcf
+
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abcd"]);
+        EXPECT_TRUE(t.checkInvariants());
+        t.insert(h["abcde"]);
+        EXPECT_TRUE(t.checkInvariants());
+        t.insert(h["abcf"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 3);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcf"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcf"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abcde"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcde"]) == 1);
+    }
+
+    // Multiple counts
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"], 4);
+        EXPECT_TRUE(t.tipSupport(h["ab"]) == 4);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 4);
+        EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["a"]) == 4);
+
+        t.insert(h["abc"], 2);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["ab"]) == 4);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 6);
+        EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["a"]) == 6);
+    }
+}
+
+TEST(LedgerTrieTest, remove)
+{
+    using namespace csf;
+    // Not in trie
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+
+        EXPECT_TRUE(!t.remove(h["ab"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(!t.remove(h["a"]));
+        EXPECT_TRUE(t.checkInvariants());
+    }
+    // In trie but with 0 tip support
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abcd"]);
+        t.insert(h["abce"]);
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+        EXPECT_TRUE(!t.remove(h["abc"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+    }
+    // In trie with > 1 tip support
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"], 2);
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.remove(h["abc"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+
+        t.insert(h["abc"], 1);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.remove(h["abc"], 2));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+
+        t.insert(h["abc"], 3);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 3);
+        EXPECT_TRUE(t.remove(h["abc"], 300));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+    }
+    // In trie with = 1 tip support, no children
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"]);
+        t.insert(h["abc"]);
+
+        EXPECT_TRUE(t.tipSupport(h["ab"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 1);
+
+        EXPECT_TRUE(t.remove(h["abc"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["ab"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 0);
+    }
+    // In trie with = 1 tip support, 1 child
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"]);
+        t.insert(h["abc"]);
+        t.insert(h["abcd"]);
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 1);
+
+        EXPECT_TRUE(t.remove(h["abc"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abcd"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abcd"]) == 1);
+    }
+    // In trie with = 1 tip support, > 1 children
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"]);
+        t.insert(h["abc"]);
+        t.insert(h["abcd"]);
+        t.insert(h["abce"]);
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 3);
+
+        EXPECT_TRUE(t.remove(h["abc"]));
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["abc"]) == 2);
+    }
+
+    // In trie with = 1 tip support, parent compaction
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"]);
+        t.insert(h["abc"]);
+        t.insert(h["abd"]);
+        EXPECT_TRUE(t.checkInvariants());
+        t.remove(h["ab"]);
+        EXPECT_TRUE(t.checkInvariants());
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["abd"]) == 1);
+        EXPECT_TRUE(t.tipSupport(h["ab"]) == 0);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 2);
+
+        t.remove(h["abd"]);
+        EXPECT_TRUE(t.checkInvariants());
+
+        EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+        EXPECT_TRUE(t.branchSupport(h["ab"]) == 1);
+    }
+}
+
+TEST(LedgerTrieTest, empty)
+{
+    using namespace csf;
+    LedgerTrie t;
+    LedgerHistoryHelper h;
+    EXPECT_TRUE(t.empty());
+
+    Ledger const genesis = h[""];
+    t.insert(genesis);
+    EXPECT_TRUE(!t.empty());
+    t.remove(genesis);
+    EXPECT_TRUE(t.empty());
+
+    t.insert(h["abc"]);
+    EXPECT_TRUE(!t.empty());
+    t.remove(h["abc"]);
+    EXPECT_TRUE(t.empty());
+}
+
+TEST(LedgerTrieTest, support)
+{
+    using namespace csf;
+
+    LedgerTrie t;
+    LedgerHistoryHelper h;
+    EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["axy"]) == 0);
+
+    EXPECT_TRUE(t.branchSupport(h["a"]) == 0);
+    EXPECT_TRUE(t.branchSupport(h["axy"]) == 0);
+
+    t.insert(h["abc"]);
+    EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["ab"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+    EXPECT_TRUE(t.tipSupport(h["abcd"]) == 0);
+
+    EXPECT_TRUE(t.branchSupport(h["a"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["ab"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["abc"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["abcd"]) == 0);
+
+    t.insert(h["abe"]);
+    EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["ab"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["abc"]) == 1);
+    EXPECT_TRUE(t.tipSupport(h["abe"]) == 1);
+
+    EXPECT_TRUE(t.branchSupport(h["a"]) == 2);
+    EXPECT_TRUE(t.branchSupport(h["ab"]) == 2);
+    EXPECT_TRUE(t.branchSupport(h["abc"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["abe"]) == 1);
+
+    t.remove(h["abc"]);
+    EXPECT_TRUE(t.tipSupport(h["a"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["ab"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["abc"]) == 0);
+    EXPECT_TRUE(t.tipSupport(h["abe"]) == 1);
+
+    EXPECT_TRUE(t.branchSupport(h["a"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["ab"]) == 1);
+    EXPECT_TRUE(t.branchSupport(h["abc"]) == 0);
+    EXPECT_TRUE(t.branchSupport(h["abe"]) == 1);
+}
+
+TEST(LedgerTrieTest, get_preferred)
+{
+    using namespace csf;
+    using Seq = Ledger::Seq;
+    // Empty
+    {
+        LedgerTrie const t;
+        EXPECT_TRUE(t.getPreferred(Seq{0}) == std::nullopt);
+        EXPECT_TRUE(t.getPreferred(Seq{2}) == std::nullopt);
+    }
+    // Genesis support is NOT empty
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        Ledger const genesis = h[""];
+        t.insert(genesis);
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{0})->id == genesis.id());
+        EXPECT_TRUE(t.remove(genesis));
+        EXPECT_TRUE(t.getPreferred(Seq{0}) == std::nullopt);
+        EXPECT_TRUE(!t.remove(genesis));
+    }
+    // Single node no children
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+    }
+    // Single node smaller child support
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"]);
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+    }
+    // Single node larger child
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"], 2);
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abcd"].id());
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abcd"].id());
+    }
+    // Single node smaller children support
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"]);
+        t.insert(h["abce"]);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+
+        t.insert(h["abc"]);
+
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+    // Single node larger children
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"], 2);
+        t.insert(h["abce"]);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+
+        t.insert(h["abcd"]);
+
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abcd"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abcd"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+    // Tie-breaker by id
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abcd"], 2);
+        t.insert(h["abce"], 2);
+
+        EXPECT_TRUE(h["abce"].id() > h["abcd"].id());
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abce"].id());
+
+        t.insert(h["abcd"]);
+        EXPECT_TRUE(h["abce"].id() > h["abcd"].id());
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abcd"].id());
+    }
+
+    // Tie-breaker not needed
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"]);
+        t.insert(h["abce"], 2);
+        // abce only has a margin of 1, but it owns the tie-breaker
+        EXPECT_TRUE(h["abce"].id() > h["abcd"].id());
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abce"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abce"].id());
+
+        // Switch support from abce to abcd, tie-breaker now needed
+        t.remove(h["abce"]);
+        t.insert(h["abcd"]);
+
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+
+    // Single node larger grand child
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcd"], 2);
+        t.insert(h["abcde"], 4);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abcde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abcde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["abcde"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+
+    // Too much uncommitted support from competing branches
+    {
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["abc"]);
+        t.insert(h["abcde"], 2);
+        t.insert(h["abcfg"], 2);
+        // 'de' and 'fg' are tied without 'abc' vote
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abc"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["abc"].id());
+
+        t.remove(h["abc"]);
+        t.insert(h["abcd"]);
+
+        // 'de' branch has 3 votes to 2, so earlier sequences see it as preferred
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abcde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["abcde"].id());
+
+        // However, if you validated a ledger with Seq 5, potentially on
+        // a different branch, you do not yet know if they chose abcd
+        // or abcf because of you, so abc remains preferred
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["abc"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+
+    // Changing largestSeq perspective changes preferred branch
+    {
+        /**
+         * Build the tree below with initial tip support annotated
+         *       A
+         *      / \
+         *   B(1)  C(1)
+         *  /  |   |
+         * H   D   F(1)
+         *     |
+         *     E(2)
+         *     |
+         *     G
+         */
+        LedgerTrie t;
+        LedgerHistoryHelper h;
+        t.insert(h["ab"]);
+        t.insert(h["ac"]);
+        t.insert(h["acf"]);
+        t.insert(h["abde"], 2);
+
+        // B has more branch support
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{1})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{2})->id == h["ab"].id());
+
+        // But if you last validated D,F or E, you do not yet know
+        // if someone used that validation to commit to B or C
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["a"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["a"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+
+        /**
+         * One of E advancing to G doesn't change anything
+         *       A
+         *      / \
+         *   B(1)  C(1)
+         *  /  |   |
+         * H   D   F(1)
+         *     |
+         *     E(1)
+         *     |
+         *     G(1)
+         */
+        t.remove(h["abde"]);
+        t.insert(h["abdeg"]);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{1})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{2})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["a"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["a"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["a"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+
+        /**
+         * C advancing to H does advance the seq 3 preferred ledger
+         *       A
+         *      / \
+         *   B(1)  C
+         *  /  |   |
+         * H(1)D   F(1)
+         *     |
+         *     E(1)
+         *     |
+         *     G(1)
+         */
+        t.remove(h["ac"]);
+        t.insert(h["abh"]);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{1})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{2})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["a"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["a"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+
+        /**
+         * F advancing to E also moves the preferred ledger forward
+         *       A
+         *      / \
+         *   B(1)  C
+         *  /  |   |
+         * H(1)D   F
+         *     |
+         *     E(2)
+         *     |
+         *     G(1)
+         */
+        t.remove(h["acf"]);
+        t.insert(h["abde"]);
+
+        // NOLINTBEGIN(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(t.getPreferred(Seq{1})->id == h["abde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{2})->id == h["abde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{3})->id == h["abde"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{4})->id == h["ab"].id());
+        EXPECT_TRUE(t.getPreferred(Seq{5})->id == h["ab"].id());
+        // NOLINTEND(bugprone-unchecked-optional-access)
+    }
+}
+
+TEST(LedgerTrieTest, root_related)
+{
+    using namespace csf;
+    // Since the root is a special node that breaks the no-single child
+    // invariant, do some tests that exercise it.
+
+    LedgerTrie t;
+    LedgerHistoryHelper h;
+    EXPECT_TRUE(!t.remove(h[""]));
+    EXPECT_TRUE(t.branchSupport(h[""]) == 0);
+    EXPECT_TRUE(t.tipSupport(h[""]) == 0);
+
+    t.insert(h["a"]);
+    EXPECT_TRUE(t.checkInvariants());
+    EXPECT_TRUE(t.branchSupport(h[""]) == 1);
+    EXPECT_TRUE(t.tipSupport(h[""]) == 0);
+
+    t.insert(h["e"]);
+    EXPECT_TRUE(t.checkInvariants());
+    EXPECT_TRUE(t.branchSupport(h[""]) == 2);
+    EXPECT_TRUE(t.tipSupport(h[""]) == 0);
+
+    EXPECT_TRUE(t.remove(h["e"]));
+    EXPECT_TRUE(t.checkInvariants());
+    EXPECT_TRUE(t.branchSupport(h[""]) == 1);
+    EXPECT_TRUE(t.tipSupport(h[""]) == 0);
+}
+
+TEST(LedgerTrieTest, stress)
+{
+    using namespace csf;
+    LedgerTrie t;
+    LedgerHistoryHelper h;
+
+    // Test quasi-randomly add/remove supporting for different ledgers
+    // from a branching history.
+
+    // Ledgers have sequence 1,2,3,4
+    std::uint32_t const depthConst = 4;
+    // Each ledger has 4 possible children
+    std::uint32_t const width = 4;
+
+    std::uint32_t const iterations = 10000;
+
+    // Use explicit seed to have same results for CI
+    // NOLINTNEXTLINE(bugprone-random-generator-seed): fixed seed for reproducible test
+    std::mt19937 gen{42};
+    std::uniform_int_distribution<> depthDist(0, depthConst - 1);
+    std::uniform_int_distribution<> widthDist(0, width - 1);
+    std::uniform_int_distribution<> flip(0, 1);
+    for (std::uint32_t i = 0; i < iterations; ++i)
+    {
+        // pick a random ledger history
+        std::string curr;
+        char const depth = depthDist(gen);
+        char offset = 0;
+        for (char d = 0; d < depth; ++d)
+        {
+            char const a = offset + widthDist(gen);
+            curr += a;
+            offset = (a + 1) * width;
+        }
+
+        // 50-50 to add remove
+        if (flip(gen) == 0)
+        {
+            t.insert(h[curr]);
+        }
+        else
+        {
+            t.remove(h[curr]);
+        }
+        EXPECT_TRUE(t.checkInvariants());
+        if (!(t.checkInvariants()))
+            return;
+    }
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/ScaleFreeSim.cpp b/src/tests/libxrpl/consensus/ScaleFreeSim.cpp
new file mode 100644
index 0000000000..2b07b29900
--- /dev/null
+++ b/src/tests/libxrpl/consensus/ScaleFreeSim.cpp
@@ -0,0 +1,100 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(ScaleFreeSimTest, DISABLED_scale_free_sim)
+{
+    using namespace std::chrono;
+    using namespace csf;
+
+    std::ostream& log = std::cout;
+
+    // Generate a quasi-random scale free network and simulate consensus
+    // as we vary transaction submission rates
+
+    int const n = 100;  // Peers
+
+    int const numUNLs = 15;  //  UNL lists
+    int const minUNLSize = n / 4, maxUNLSize = n / 2;
+
+    ConsensusParms const parms{};
+    Sim sim;
+    PeerGroup network = sim.createGroup(n);
+
+    // generate trust ranks
+    std::vector const ranks = sample(network.size(), PowerLawDistribution{1, 3}, sim.rng);
+
+    // generate scale-free trust graph
+    randomRankedTrust(
+        network, ranks, numUNLs, std::uniform_int_distribution<>{minUNLSize, maxUNLSize}, sim.rng);
+
+    // nodes with a trust line in either direction are network-connected
+    network.connectFromTrust(round(0.2 * parms.ledgerGRANULARITY));
+
+    // Initialize collectors to track statistics to report
+    TxCollector txCollector;
+    LedgerCollector ledgerCollector;
+    auto colls = makeCollectors(txCollector, ledgerCollector);
+    sim.collectors.add(colls);
+
+    // Initial round to set prior state
+    sim.run(1);
+
+    // Initialize timers
+    HeartbeatTimer heart(sim.scheduler, seconds(10s));
+
+    // Run for 10 minutes, submitting 100 tx/second
+    std::chrono::nanoseconds const simDuration = 10min;
+    std::chrono::nanoseconds const quiet = 10s;
+    Rate const rate{.count = 100, .duration = 1000ms};
+
+    // txs, start/stop/step, target
+    auto peerSelector = makeSelector(network.begin(), network.end(), ranks, sim.rng);
+    auto txSubmitter = makeSubmitter(
+        ConstantDistribution{rate.inv()},
+        sim.scheduler.now() + quiet,
+        sim.scheduler.now() + (simDuration - quiet),
+        peerSelector,
+        sim.scheduler,
+        sim.rng);
+
+    // run simulation for given duration
+    heart.start();
+    sim.run(simDuration);
+
+    EXPECT_TRUE(sim.branches() == 1);
+    EXPECT_TRUE(sim.synchronized());
+
+    // TODO: Clean up this formatting mess!!
+
+    log << "Peers: " << network.size() << std::endl;
+    log << "Simulated Duration: " << duration_cast(simDuration).count() << " ms"
+        << std::endl;
+    log << "Branches: " << sim.branches() << std::endl;
+    log << "Synchronized: " << (sim.synchronized() ? "Y" : "N") << std::endl;
+    log << std::endl;
+
+    txCollector.report(simDuration, log);
+    ledgerCollector.report(simDuration, log);
+    // Print summary?
+    // # forks?  # of LCLs?
+    // # peers
+    // # tx submitted
+    // # ledgers/sec etc.?
+}
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/consensus/Validations.cpp b/src/tests/libxrpl/consensus/Validations.cpp
new file mode 100644
index 0000000000..56964f56a6
--- /dev/null
+++ b/src/tests/libxrpl/consensus/Validations.cpp
@@ -0,0 +1,1030 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test::csf {
+
+namespace {
+
+beast::Journal
+journal()
+{
+    return beast::Journal{TestSink::instance()};
+}
+
+template 
+void
+expireValidations(ValidationStore& validations)
+{
+    auto j = journal();
+    validations.expire(j);
+}
+
+using clock_type = beast::AbstractClock const;
+
+// Helper to convert steady_clock to a reasonable NetClock
+// This allows a single manual clock in the unit tests
+NetClock::time_point
+toNetClock(clock_type const& c)
+{
+    // We don't care about the actual epochs, but do want the
+    // generated NetClock time to be well past its epoch to ensure
+    // any subtractions are positive
+    using namespace std::chrono;
+    return NetClock::time_point(
+        duration_cast(c.now().time_since_epoch() + 86400s));
+}
+
+// Represents a node that can issue validations
+class Node
+{
+    clock_type const& c_;
+    PeerID nodeID_;
+    bool trusted_ = true;
+    std::size_t signIdx_{1};
+    std::optional loadFee_;
+
+public:
+    Node(PeerID nodeID, clock_type const& c) : c_(c), nodeID_(nodeID)
+    {
+    }
+
+    void
+    untrust()
+    {
+        trusted_ = false;
+    }
+
+    void
+    trust()
+    {
+        trusted_ = true;
+    }
+
+    void
+    setLoadFee(std::uint32_t fee)
+    {
+        loadFee_ = fee;
+    }
+
+    [[nodiscard]] PeerID
+    nodeID() const
+    {
+        return nodeID_;
+    }
+
+    void
+    advanceKey()
+    {
+        signIdx_++;
+    }
+
+    [[nodiscard]] PeerKey
+    currKey() const
+    {
+        return std::make_pair(nodeID_, signIdx_);
+    }
+
+    [[nodiscard]] PeerKey
+    masterKey() const
+    {
+        return std::make_pair(nodeID_, 0);
+    }
+    [[nodiscard]] NetClock::time_point
+    now() const
+    {
+        return toNetClock(c_);
+    }
+
+    // Issue a new validation with given sequence number and id and
+    // with signing and seen times offset from the common clock
+    [[nodiscard]] Validation
+    validate(
+        Ledger::ID id,
+        Ledger::Seq seq,
+        NetClock::duration signOffset,
+        NetClock::duration seenOffset,
+        bool full) const
+    {
+        Validation v{
+            id, seq, now() + signOffset, now() + seenOffset, currKey(), nodeID_, full, loadFee_};
+        if (trusted_)
+            v.setTrusted();
+        return v;
+    }
+
+    [[nodiscard]] Validation
+    validate(Ledger ledger, NetClock::duration signOffset, NetClock::duration seenOffset) const
+    {
+        return validate(ledger.id(), ledger.seq(), signOffset, seenOffset, true);
+    }
+
+    [[nodiscard]] Validation
+    validate(Ledger ledger) const
+    {
+        return validate(
+            ledger.id(), ledger.seq(), NetClock::duration{0}, NetClock::duration{0}, true);
+    }
+
+    [[nodiscard]] Validation
+    partial(Ledger ledger) const
+    {
+        return validate(
+            ledger.id(), ledger.seq(), NetClock::duration{0}, NetClock::duration{0}, false);
+    }
+};
+
+// Generic Validations adaptor
+class Adaptor
+{
+    clock_type& c_;
+    LedgerOracle& oracle_;
+
+public:
+    // Non-locking mutex to avoid locks in generic Validations
+    struct Mutex
+    {
+        void
+        lock()
+        {
+        }
+
+        void
+        unlock()
+        {
+        }
+    };
+
+    using Validation = csf::Validation;
+    using Ledger = csf::Ledger;
+
+    Adaptor(clock_type& c, LedgerOracle& o) : c_{c}, oracle_{o}
+    {
+    }
+
+    [[nodiscard]] NetClock::time_point
+    now() const
+    {
+        return toNetClock(c_);
+    }
+
+    std::optional
+    acquire(Ledger::ID const& id)
+    {
+        return oracle_.lookup(id);
+    }
+};
+
+// Specialize generic Validations using the above types
+using TestValidations = Validations;
+
+// Gather the dependencies of TestValidations in a single class and provide
+// accessors for simplifying test logic
+class TestHarness
+{
+    ValidationParms p_;
+    beast::ManualClock clock_;
+    TestValidations tv_;
+    PeerID nextNodeId_{0};
+
+public:
+    explicit TestHarness(LedgerOracle& o) : tv_(p_, clock_, clock_, o)
+    {
+    }
+
+    ValStatus
+    add(Validation const& v)
+    {
+        return tv_.add(v.nodeID(), v);
+    }
+
+    TestValidations&
+    vals()
+    {
+        return tv_;
+    }
+
+    Node
+    makeNode()
+    {
+        return Node(nextNodeId_++, clock_);
+    }
+
+    ValidationParms
+    parms() const
+    {
+        return p_;
+    }
+
+    auto&
+    clock()
+    {
+        return clock_;
+    }
+};
+
+Ledger const kGenesisLedger{Ledger::MakeGenesis{}};
+
+}  // namespace
+
+TEST(ValidationsTest, add_validation)
+{
+    using namespace std::chrono_literals;
+
+    SCOPED_TRACE("Add validation");
+    LedgerHistoryHelper h;
+    Ledger const ledgerA = h["a"];
+    Ledger ledgerAB = h["ab"];
+    Ledger ledgerAZ = h["az"];
+    Ledger ledgerABC = h["abc"];
+    Ledger const ledgerABCD = h["abcd"];
+    Ledger const ledgerABCDE = h["abcde"];
+
+    {
+        TestHarness harness(h.oracle);
+        Node n = harness.makeNode();
+
+        auto const v = n.validate(ledgerA);
+
+        // Add a current validation
+        EXPECT_TRUE(ValStatus::Current == harness.add(v));
+
+        // Re-adding violates the increasing seq requirement for full
+        // validations
+        EXPECT_TRUE(ValStatus::BadSeq == harness.add(v));
+
+        harness.clock().advance(1s);
+
+        EXPECT_TRUE(ValStatus::Current == harness.add(n.validate(ledgerAB)));
+
+        // Test the node changing signing key
+
+        // Confirm old ledger on hand, but not new ledger
+        EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerAB.id()) == 1);
+        EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerABC.id()) == 0);
+
+        // Rotate signing keys
+        n.advanceKey();
+
+        harness.clock().advance(1s);
+
+        // Cannot re-do the same full validation sequence
+        EXPECT_TRUE(ValStatus::Conflicting == harness.add(n.validate(ledgerAB)));
+        // Cannot send the same partial validation sequence
+        EXPECT_TRUE(ValStatus::Conflicting == harness.add(n.partial(ledgerAB)));
+
+        // Now trusts the newest ledger too
+        harness.clock().advance(1s);
+        EXPECT_TRUE(ValStatus::Current == harness.add(n.validate(ledgerABC)));
+        EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerAB.id()) == 1);
+        EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerABC.id()) == 1);
+
+        // Processing validations out of order should ignore the older
+        // validation
+        harness.clock().advance(2s);
+        auto const valABCDE = n.validate(ledgerABCDE);
+
+        harness.clock().advance(4s);
+        auto const valABCD = n.validate(ledgerABCD);
+
+        EXPECT_TRUE(ValStatus::Current == harness.add(valABCD));
+
+        EXPECT_TRUE(ValStatus::Stale == harness.add(valABCDE));
+    }
+
+    {
+        // Process validations out of order with shifted times
+
+        TestHarness harness(h.oracle);
+        Node const n = harness.makeNode();
+
+        // Establish a new current validation
+        EXPECT_TRUE(ValStatus::Current == harness.add(n.validate(ledgerA)));
+
+        // Process a validation that has "later" seq but early sign time
+        EXPECT_TRUE(ValStatus::Stale == harness.add(n.validate(ledgerAB, -1s, -1s)));
+
+        // Process a validation that has a later seq and later sign
+        // time
+        EXPECT_TRUE(ValStatus::Current == harness.add(n.validate(ledgerABC, 1s, 1s)));
+    }
+
+    {
+        // Test stale on arrival validations
+        TestHarness harness(h.oracle);
+        Node const n = harness.makeNode();
+
+        EXPECT_TRUE(
+            ValStatus::Stale ==
+            harness.add(n.validate(ledgerA, -harness.parms().validationCurrentEarly, 0s)));
+
+        EXPECT_TRUE(
+            ValStatus::Stale ==
+            harness.add(n.validate(ledgerA, harness.parms().validationCurrentWall, 0s)));
+
+        EXPECT_TRUE(
+            ValStatus::Stale ==
+            harness.add(n.validate(ledgerA, 0s, harness.parms().validationCurrentLocal)));
+    }
+
+    {
+        // Test that full or partials cannot be sent for older sequence
+        // numbers, unless time-out has happened
+        for (bool doFull : {true, false})
+        {
+            TestHarness harness(h.oracle);
+            Node n = harness.makeNode();
+
+            auto process = [&](Ledger& lgr) {
+                if (doFull)
+                    return harness.add(n.validate(lgr));
+                return harness.add(n.partial(lgr));
+            };
+
+            EXPECT_TRUE(ValStatus::Current == process(ledgerABC));
+            harness.clock().advance(1s);
+            EXPECT_TRUE(ledgerAB.seq() < ledgerABC.seq());
+            EXPECT_TRUE(ValStatus::BadSeq == process(ledgerAB));
+
+            // If we advance far enough for AB to expire, we can fully
+            // validate or partially validate that sequence number again
+            EXPECT_TRUE(ValStatus::Conflicting == process(ledgerAZ));
+            harness.clock().advance(harness.parms().validationSetExpires + 1ms);
+            EXPECT_TRUE(ValStatus::Current == process(ledgerAZ));
+        }
+    }
+}
+
+TEST(ValidationsTest, on_stale)
+{
+    SCOPED_TRACE("Stale validation");
+    // Verify validation becomes stale based solely on time passing, but
+    // use different functions to trigger the check for staleness
+
+    LedgerHistoryHelper h;
+    Ledger ledgerA = h["a"];
+    Ledger const ledgerAB = h["ab"];
+
+    using Trigger = std::function;
+
+    std::vector const triggers = {
+        [&](TestValidations& vals) { vals.currentTrusted(); },
+        [&](TestValidations& vals) { vals.getCurrentNodeIDs(); },
+        [&](TestValidations& vals) { vals.getPreferred(kGenesisLedger); },
+        [&](TestValidations& vals) { vals.getNodesAfter(ledgerA, ledgerA.id()); }};
+    for (Trigger const& trigger : triggers)
+    {
+        TestHarness harness(h.oracle);
+        Node const n = harness.makeNode();
+
+        EXPECT_TRUE(ValStatus::Current == harness.add(n.validate(ledgerAB)));
+        trigger(harness.vals());
+        EXPECT_TRUE(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 1);
+        EXPECT_TRUE(
+            harness.vals().getPreferred(kGenesisLedger) ==
+            std::make_pair(ledgerAB.seq(), ledgerAB.id()));
+        harness.clock().advance(harness.parms().validationCurrentLocal);
+
+        // trigger check for stale
+        trigger(harness.vals());
+
+        EXPECT_TRUE(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 0);
+        EXPECT_TRUE(harness.vals().getPreferred(kGenesisLedger) == std::nullopt);
+    }
+}
+
+TEST(ValidationsTest, get_nodes_after)
+{
+    // Test getting number of nodes working on a validation descending
+    // a prescribed one. This count should only be for trusted nodes, but
+    // includes partial and full validations
+
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Get nodes after");
+
+    LedgerHistoryHelper h;
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerAB = h["ab"];
+    Ledger const ledgerABC = h["abc"];
+    Ledger const ledgerAD = h["ad"];
+
+    TestHarness harness(h.oracle);
+    Node const trustedNode1 = harness.makeNode();
+    Node const trustedNode2 = harness.makeNode();
+    Node const trustedNode3 = harness.makeNode();
+
+    Node notTrustedNode = harness.makeNode();
+    notTrustedNode.untrust();
+
+    // first round a,b,c agree, d has is partial
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode1.validate(ledgerA)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode2.validate(ledgerA)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerA)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode3.partial(ledgerA)));
+
+    for (Ledger const& ledger : {ledgerA, ledgerAB, ledgerABC, ledgerAD})
+        EXPECT_TRUE(harness.vals().getNodesAfter(ledger, ledger.id()) == 0);
+
+    harness.clock().advance(5s);
+
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode1.validate(ledgerAB)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode2.validate(ledgerABC)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerAB)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode3.partial(ledgerABC)));
+
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerA, ledgerA.id()) == 3);
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerAB, ledgerAB.id()) == 2);
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerABC, ledgerABC.id()) == 0);
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerAD, ledgerAD.id()) == 0);
+
+    // If given a ledger inconsistent with the id, is still able to check using slower method
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerAD, ledgerA.id()) == 1);
+    EXPECT_TRUE(harness.vals().getNodesAfter(ledgerAD, ledgerAB.id()) == 2);
+}
+
+TEST(ValidationsTest, current_trusted)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Current trusted validations");
+
+    LedgerHistoryHelper h;
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerB = h["b"];
+    Ledger const ledgerAC = h["ac"];
+
+    TestHarness harness(h.oracle);
+    Node const a = harness.makeNode();
+    Node b = harness.makeNode();
+    b.untrust();
+
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.validate(ledgerA)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(b.validate(ledgerB)));
+
+    // Only a is trusted
+    EXPECT_TRUE(harness.vals().currentTrusted().size() == 1);
+    EXPECT_TRUE(harness.vals().currentTrusted()[0].ledgerID() == ledgerA.id());
+    EXPECT_TRUE(harness.vals().currentTrusted()[0].seq() == ledgerA.seq());
+
+    harness.clock().advance(3s);
+
+    for (auto const& node : {a, b})
+        EXPECT_TRUE(ValStatus::Current == harness.add(node.validate(ledgerAC)));
+
+    // New validation for a
+    EXPECT_TRUE(harness.vals().currentTrusted().size() == 1);
+    EXPECT_TRUE(harness.vals().currentTrusted()[0].ledgerID() == ledgerAC.id());
+    EXPECT_TRUE(harness.vals().currentTrusted()[0].seq() == ledgerAC.seq());
+
+    // Pass enough time for it to go stale
+    harness.clock().advance(harness.parms().validationCurrentLocal);
+    EXPECT_TRUE(harness.vals().currentTrusted().empty());
+}
+
+TEST(ValidationsTest, get_current_public_keys)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Current public keys");
+
+    LedgerHistoryHelper h;
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerAC = h["ac"];
+
+    TestHarness harness(h.oracle);
+    Node a = harness.makeNode(), b = harness.makeNode();
+    b.untrust();
+
+    for (auto const& node : {a, b})
+        EXPECT_TRUE(ValStatus::Current == harness.add(node.validate(ledgerA)));
+
+    {
+        hash_set const expectedKeys = {a.nodeID(), b.nodeID()};
+        EXPECT_TRUE(harness.vals().getCurrentNodeIDs() == expectedKeys);
+    }
+
+    harness.clock().advance(3s);
+
+    // Change keys and issue partials
+    a.advanceKey();
+    b.advanceKey();
+
+    for (auto const& node : {a, b})
+        EXPECT_TRUE(ValStatus::Current == harness.add(node.partial(ledgerAC)));
+
+    {
+        hash_set const expectedKeys = {a.nodeID(), b.nodeID()};
+        EXPECT_TRUE(harness.vals().getCurrentNodeIDs() == expectedKeys);
+    }
+
+    // Pass enough time for them to go stale
+    harness.clock().advance(harness.parms().validationCurrentLocal);
+    EXPECT_TRUE(harness.vals().getCurrentNodeIDs().empty());
+}
+
+TEST(ValidationsTest, trusted_by_ledger_functions)
+{
+    // Test the Validations functions that calculate a value by ledger ID
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("By ledger functions");
+
+    // Several Validations functions return a set of values associated
+    // with trusted ledgers sharing the same ledger ID.  The tests below
+    // exercise this logic by saving the set of trusted Validations, and
+    // verifying that the Validations member functions all calculate the
+    // proper transformation of the available ledgers.
+
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+
+    Node a = harness.makeNode(), b = harness.makeNode(), c = harness.makeNode(),
+         d = harness.makeNode(), e = harness.makeNode();
+
+    c.untrust();
+    // Mix of load fees
+    a.setLoadFee(12);
+    b.setLoadFee(1);
+    c.setLoadFee(12);
+    e.setLoadFee(12);
+
+    hash_map, std::vector> trustedValidations;
+
+    //----------------------------------------------------------------------
+    // checkers
+    auto sorted = [](auto vec) {
+        std::sort(vec.begin(), vec.end());
+        return vec;
+    };
+    auto compare = [&]() {
+        for (auto& it : trustedValidations)
+        {
+            auto const& id = it.first.first;
+            auto const& seq = it.first.second;
+            auto const& expectedValidations = it.second;
+
+            EXPECT_TRUE(harness.vals().numTrustedForLedger(id) == expectedValidations.size());
+            EXPECT_TRUE(
+                sorted(harness.vals().getTrustedForLedger(id, seq)) == sorted(expectedValidations));
+
+            std::uint32_t const baseFee = 0;
+            std::vector expectedFees;
+            expectedFees.reserve(expectedValidations.size());
+            for (auto const& val : expectedValidations)
+            {
+                expectedFees.push_back(val.loadFee().value_or(baseFee));
+            }
+
+            EXPECT_TRUE(sorted(harness.vals().fees(id, baseFee)) == sorted(expectedFees));
+        }
+    };
+
+    //----------------------------------------------------------------------
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerB = h["b"];
+    Ledger const ledgerAC = h["ac"];
+
+    // Add a dummy ID to cover unknown ledger identifiers
+    trustedValidations[{Ledger::ID{100}, Ledger::Seq{100}}] = {};
+
+    // first round a,b,c agree
+    for (auto const& node : {a, b, c})
+    {
+        auto const val = node.validate(ledgerA);
+        EXPECT_TRUE(ValStatus::Current == harness.add(val));
+        if (val.trusted())
+            trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
+    }
+    // d disagrees
+    {
+        auto const val = d.validate(ledgerB);
+        EXPECT_TRUE(ValStatus::Current == harness.add(val));
+        trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
+    }
+    // e only issues partials
+    {
+        EXPECT_TRUE(ValStatus::Current == harness.add(e.partial(ledgerA)));
+    }
+
+    harness.clock().advance(5s);
+    // second round, a,b,c move to ledger 2
+    for (auto const& node : {a, b, c})
+    {
+        auto const val = node.validate(ledgerAC);
+        EXPECT_TRUE(ValStatus::Current == harness.add(val));
+        if (val.trusted())
+            trustedValidations[{val.ledgerID(), val.seq()}].emplace_back(val);
+    }
+    // d now thinks ledger 1, but cannot re-issue a previously used seq
+    // and attempting it should generate a conflict.
+    {
+        EXPECT_TRUE(ValStatus::Conflicting == harness.add(d.partial(ledgerA)));
+    }
+    // e only issues partials
+    {
+        EXPECT_TRUE(ValStatus::Current == harness.add(e.partial(ledgerAC)));
+    }
+
+    compare();
+}
+
+TEST(ValidationsTest, expire)
+{
+    // Verify expiring clears out validations stored by ledger
+    SCOPED_TRACE("Expire validations");
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const a = harness.makeNode();
+    constexpr Ledger::Seq kOne(1);
+    constexpr Ledger::Seq kTwo(2);
+
+    // simple cases
+    Ledger const ledgerA = h["a"];
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.validate(ledgerA)));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
+    expireValidations(harness.vals());
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
+    harness.clock().advance(harness.parms().validationSetExpires);
+    expireValidations(harness.vals());
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerA.id()) == 0);
+
+    // use setSeqToKeep to keep the validation from expire
+    Ledger const ledgerB = h["ab"];
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.validate(ledgerB)));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
+    harness.vals().setSeqToKeep(ledgerB.seq(), ledgerB.seq() + kOne);
+    harness.clock().advance(harness.parms().validationSetExpires);
+    expireValidations(harness.vals());
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerB.id()) == 1);
+    // change toKeep
+    harness.vals().setSeqToKeep(ledgerB.seq() + kOne, ledgerB.seq() + kTwo);
+    // advance clock slowly
+    int const loops =
+        harness.parms().validationSetExpires / harness.parms().validationFRESHNESS + 1;
+    for (int i = 0; i < loops; ++i)
+    {
+        harness.clock().advance(harness.parms().validationFRESHNESS);
+        expireValidations(harness.vals());
+    }
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerB.id()) == 0);
+
+    // Allow the validation with high seq to expire
+    Ledger const ledgerC = h["abc"];
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.validate(ledgerC)));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerC.id()) == 1);
+    harness.vals().setSeqToKeep(ledgerC.seq() - kOne, ledgerC.seq());
+    harness.clock().advance(harness.parms().validationSetExpires);
+    expireValidations(harness.vals());
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerC.id()) == 0);
+}
+
+TEST(ValidationsTest, flush)
+{
+    // Test final flush of validations
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Flush validations");
+
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const trustedNode1 = harness.makeNode();
+    Node const trustedNode2 = harness.makeNode();
+    Node notTrustedNode = harness.makeNode();
+    notTrustedNode.untrust();
+
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerAB = h["ab"];
+
+    hash_map expected;
+    for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode})
+    {
+        auto const val = node.validate(ledgerA);
+        EXPECT_TRUE(ValStatus::Current == harness.add(val));
+        expected.emplace(node.nodeID(), val);
+    }
+
+    // Send in a new validation for a, saving the new one into the expected
+    // map after setting the proper prior ledger ID it replaced
+    harness.clock().advance(1s);
+    auto newVal = trustedNode1.validate(ledgerAB);
+    EXPECT_TRUE(ValStatus::Current == harness.add(newVal));
+    expected.find(trustedNode1.nodeID())->second = newVal;
+}
+
+TEST(ValidationsTest, get_preferred_ledger)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Preferred Ledger");
+
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const trustedNode1 = harness.makeNode();
+    Node const trustedNode2 = harness.makeNode();
+    Node const trustedNode3 = harness.makeNode();
+
+    Node notTrustedNode = harness.makeNode();
+    notTrustedNode.untrust();
+
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerB = h["b"];
+    Ledger const ledgerAC = h["ac"];
+    Ledger const ledgerACD = h["acd"];
+
+    using Seq = Ledger::Seq;
+
+    auto pref = [](Ledger ledger) { return std::make_pair(ledger.seq(), ledger.id()); };
+
+    // Empty (no ledgers)
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA) == std::nullopt);
+
+    // Single ledger
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode1.validate(ledgerB)));
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA) == pref(ledgerB));
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerB) == pref(ledgerB));
+
+    // Minimum valid sequence
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA, Seq{10}) == ledgerA.id());
+
+    // Untrusted doesn't impact preferred ledger
+    // (ledgerB has tie-break over ledgerA)
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode2.validate(ledgerA)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(notTrustedNode.validate(ledgerA)));
+    EXPECT_TRUE(ledgerB.id() > ledgerA.id());
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA) == pref(ledgerB));
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerB) == pref(ledgerB));
+
+    // Partial does break ties
+    EXPECT_TRUE(ValStatus::Current == harness.add(trustedNode3.partial(ledgerA)));
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA) == pref(ledgerA));
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerB) == pref(ledgerA));
+
+    harness.clock().advance(5s);
+
+    // Parent of preferred-> stick with ledger
+    for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode, trustedNode3})
+        EXPECT_TRUE(ValStatus::Current == harness.add(node.validate(ledgerAC)));
+    // Parent of preferred stays put
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerA) == pref(ledgerA));
+    // Earlier different chain, switch
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerB) == pref(ledgerAC));
+    // Later on chain, stays where it is
+    EXPECT_TRUE(harness.vals().getPreferred(ledgerACD) == pref(ledgerACD));
+
+    // Any later grandchild or different chain is preferred
+    harness.clock().advance(5s);
+    for (auto const& node : {trustedNode1, trustedNode2, notTrustedNode, trustedNode3})
+        EXPECT_TRUE(ValStatus::Current == harness.add(node.validate(ledgerACD)));
+    for (auto const& ledger : {ledgerA, ledgerB, ledgerACD})
+        EXPECT_TRUE(harness.vals().getPreferred(ledger) == pref(ledgerACD));
+}
+
+TEST(ValidationsTest, get_preferred_lcl)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Get preferred LCL");
+
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const a = harness.makeNode();
+
+    Ledger const ledgerA = h["a"];
+    Ledger const ledgerB = h["b"];
+    Ledger const ledgerC = h["c"];
+
+    using ID = Ledger::ID;
+    using Seq = Ledger::Seq;
+
+    hash_map peerCounts;
+
+    // No trusted validations or counts sticks with current ledger
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerA.id());
+
+    ++peerCounts[ledgerB.id()];
+
+    // No trusted validations, rely on peer counts
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerB.id());
+
+    ++peerCounts[ledgerC.id()];
+    // No trusted validations, tied peers goes with larger ID
+    EXPECT_TRUE(ledgerC.id() > ledgerB.id());
+
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerC.id());
+
+    peerCounts[ledgerC.id()] += 1000;
+
+    // Single trusted always wins over peer counts
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.validate(ledgerA)));
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerA, Seq{0}, peerCounts) == ledgerA.id());
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerB, Seq{0}, peerCounts) == ledgerA.id());
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerC, Seq{0}, peerCounts) == ledgerA.id());
+
+    // Stick with current ledger if trusted validation ledger has too old
+    // of a sequence
+    EXPECT_TRUE(harness.vals().getPreferredLCL(ledgerB, Seq{2}, peerCounts) == ledgerB.id());
+}
+
+TEST(ValidationsTest, acquire_validated_ledger)
+{
+    using namespace std::chrono_literals;
+    SCOPED_TRACE("Acquire validated ledger");
+
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const a = harness.makeNode();
+    Node const b = harness.makeNode();
+
+    using ID = Ledger::ID;
+    using Seq = Ledger::Seq;
+
+    // Validate the ledger before it is actually available
+    Validation const val = a.validate(ID{2}, Seq{2}, 0s, 0s, true);
+
+    EXPECT_TRUE(ValStatus::Current == harness.add(val));
+    // Validation is available
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ID{2}) == 1);
+    // but ledger based data is not
+    EXPECT_TRUE(harness.vals().getNodesAfter(kGenesisLedger, ID{0}) == 0);
+    // Initial preferred branch falls back to the ledger we are trying to
+    // acquire
+    EXPECT_TRUE(harness.vals().getPreferred(kGenesisLedger) == std::make_pair(Seq{2}, ID{2}));
+
+    // After adding another unavailable validation, the preferred ledger
+    // breaks ties via higher ID
+    EXPECT_TRUE(ValStatus::Current == harness.add(b.validate(ID{3}, Seq{2}, 0s, 0s, true)));
+    EXPECT_TRUE(harness.vals().getPreferred(kGenesisLedger) == std::make_pair(Seq{2}, ID{3}));
+
+    // Create the ledger
+    Ledger const ledgerAB = h["ab"];
+    // Now it should be available
+    EXPECT_TRUE(harness.vals().getNodesAfter(kGenesisLedger, ID{0}) == 1);
+
+    // Create a validation that is not available
+    harness.clock().advance(5s);
+    Validation const val2 = a.validate(ID{4}, Seq{4}, 0s, 0s, true);
+    EXPECT_TRUE(ValStatus::Current == harness.add(val2));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ID{4}) == 1);
+    EXPECT_TRUE(
+        harness.vals().getPreferred(kGenesisLedger) ==
+        std::make_pair(ledgerAB.seq(), ledgerAB.id()));
+
+    // Another node requesting that ledger still doesn't change things
+    Validation const val3 = b.validate(ID{4}, Seq{4}, 0s, 0s, true);
+    EXPECT_TRUE(ValStatus::Current == harness.add(val3));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ID{4}) == 2);
+    EXPECT_TRUE(
+        harness.vals().getPreferred(kGenesisLedger) ==
+        std::make_pair(ledgerAB.seq(), ledgerAB.id()));
+
+    // Switch to validation that is available
+    harness.clock().advance(5s);
+    Ledger const ledgerABCDE = h["abcde"];
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.partial(ledgerABCDE)));
+    EXPECT_TRUE(ValStatus::Current == harness.add(b.partial(ledgerABCDE)));
+    EXPECT_TRUE(
+        harness.vals().getPreferred(kGenesisLedger) ==
+        std::make_pair(ledgerABCDE.seq(), ledgerABCDE.id()));
+}
+
+TEST(ValidationsTest, num_trusted_for_ledger)
+{
+    SCOPED_TRACE("NumTrustedForLedger");
+    LedgerHistoryHelper h;
+    TestHarness harness(h.oracle);
+    Node const a = harness.makeNode();
+    Node const b = harness.makeNode();
+    Ledger const ledgerA = h["a"];
+
+    EXPECT_TRUE(ValStatus::Current == harness.add(a.partial(ledgerA)));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerA.id()) == 0);
+
+    EXPECT_TRUE(ValStatus::Current == harness.add(b.validate(ledgerA)));
+    EXPECT_TRUE(harness.vals().numTrustedForLedger(ledgerA.id()) == 1);
+}
+
+TEST(ValidationsTest, seq_enforcer)
+{
+    SCOPED_TRACE("SeqEnforcer");
+    using Seq = Ledger::Seq;
+    using namespace std::chrono;
+
+    beast::ManualClock clock;
+    SeqEnforcer enforcer;
+
+    ValidationParms const p;
+
+    EXPECT_TRUE(enforcer(clock.now(), Seq{1}, p));
+    EXPECT_TRUE(enforcer(clock.now(), Seq{10}, p));
+    EXPECT_TRUE(!enforcer(clock.now(), Seq{5}, p));
+    EXPECT_TRUE(!enforcer(clock.now(), Seq{9}, p));
+    clock.advance(p.validationSetExpires - 1ms);
+    EXPECT_TRUE(!enforcer(clock.now(), Seq{1}, p));
+    clock.advance(2ms);
+    EXPECT_TRUE(enforcer(clock.now(), Seq{1}, p));
+}
+
+TEST(ValidationsTest, trust_changed)
+{
+    SCOPED_TRACE("TrustChanged");
+    using namespace std::chrono;
+
+    auto checker = [&](TestValidations& vals,
+                       hash_set const& listed,
+                       std::vector const& trustedVals) {
+        Ledger::ID const testID =
+            trustedVals.empty() ? kGenesisLedger.id() : trustedVals[0].ledgerID();
+        Ledger::Seq const testSeq =
+            trustedVals.empty() ? kGenesisLedger.seq() : trustedVals[0].seq();
+        EXPECT_TRUE(vals.currentTrusted() == trustedVals);
+        EXPECT_TRUE(vals.getCurrentNodeIDs() == listed);
+        EXPECT_TRUE(vals.getNodesAfter(kGenesisLedger, kGenesisLedger.id()) == trustedVals.size());
+        if (trustedVals.empty())
+        {
+            EXPECT_TRUE(vals.getPreferred(kGenesisLedger) == std::nullopt);
+        }
+        else
+        {
+            EXPECT_TRUE(vals.getPreferred(kGenesisLedger)->second == testID);
+        }
+        EXPECT_TRUE(vals.getTrustedForLedger(testID, testSeq) == trustedVals);
+        EXPECT_TRUE(vals.numTrustedForLedger(testID) == trustedVals.size());
+    };
+
+    {
+        // Trusted to untrusted
+        LedgerHistoryHelper h;
+        TestHarness harness(h.oracle);
+        Node const a = harness.makeNode();
+        Ledger const ledgerAB = h["ab"];
+        Validation const v = a.validate(ledgerAB);
+        EXPECT_TRUE(ValStatus::Current == harness.add(v));
+
+        hash_set const listed({a.nodeID()});
+        std::vector trustedVals({v});
+        checker(harness.vals(), listed, trustedVals);
+
+        trustedVals.clear();
+        harness.vals().trustChanged({}, {a.nodeID()});
+        checker(harness.vals(), listed, trustedVals);
+    }
+
+    {
+        // Untrusted to trusted
+        LedgerHistoryHelper h;
+        TestHarness harness(h.oracle);
+        Node a = harness.makeNode();
+        a.untrust();
+        Ledger const ledgerAB = h["ab"];
+        Validation const v = a.validate(ledgerAB);
+        EXPECT_TRUE(ValStatus::Current == harness.add(v));
+
+        hash_set const listed({a.nodeID()});
+        std::vector trustedVals;
+        checker(harness.vals(), listed, trustedVals);
+
+        trustedVals.push_back(v);
+        harness.vals().trustChanged({a.nodeID()}, {});
+        checker(harness.vals(), listed, trustedVals);
+    }
+
+    {
+        // Trusted but not acquired -> untrusted
+        LedgerHistoryHelper h;
+        TestHarness harness(h.oracle);
+        Node const a = harness.makeNode();
+        Validation const v = a.validate(Ledger::ID{2}, Ledger::Seq{2}, 0s, 0s, true);
+        EXPECT_TRUE(ValStatus::Current == harness.add(v));
+
+        hash_set const listed({a.nodeID()});
+        std::vector trustedVals({v});
+        auto& vals = harness.vals();
+        EXPECT_TRUE(vals.currentTrusted() == trustedVals);
+
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        EXPECT_TRUE(vals.getPreferred(kGenesisLedger)->second == v.ledgerID());
+        EXPECT_TRUE(vals.getNodesAfter(kGenesisLedger, kGenesisLedger.id()) == 0);
+
+        trustedVals.clear();
+        harness.vals().trustChanged({}, {a.nodeID()});
+        // make acquiring ledger available
+        h["ab"];
+        EXPECT_TRUE(vals.currentTrusted() == trustedVals);
+        EXPECT_TRUE(vals.getPreferred(kGenesisLedger) == std::nullopt);
+        EXPECT_TRUE(vals.getNodesAfter(kGenesisLedger, kGenesisLedger.id()) == 0);
+    }
+}
+
+}  // namespace xrpl::test::csf
diff --git a/src/tests/libxrpl/csf/BasicNetwork.cpp b/src/tests/libxrpl/csf/BasicNetwork.cpp
new file mode 100644
index 0000000000..a6fc2d90e6
--- /dev/null
+++ b/src/tests/libxrpl/csf/BasicNetwork.cpp
@@ -0,0 +1,122 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+namespace {
+
+struct Peer
+{
+    int id;
+    std::set set;
+
+    Peer(Peer const&) = default;
+    Peer(Peer&&) = default;
+
+    explicit Peer(int id) : id(id)
+    {
+    }
+
+    template 
+    void
+    start(csf::Scheduler& scheduler, Net& net)
+    {
+        using namespace std::chrono_literals;
+        auto t = scheduler.in(1s, [&] { set.insert(0); });
+        if (id == 0)
+        {
+            for (auto const link : net.links(this))
+            {
+                net.send(this, link.target, [&, to = link.target] { to->receive(net, this, 1); });
+            }
+        }
+        else
+        {
+            scheduler.cancel(t);
+        }
+    }
+
+    template 
+    void
+    receive(Net& net, Peer* from, int m)
+    {
+        set.insert(m);
+        ++m;
+        if (m < 5)
+        {
+            for (auto const link : net.links(this))
+            {
+                net.send(this, link.target, [&, mm = m, to = link.target] {
+                    to->receive(net, this, mm);
+                });
+            }
+        }
+    }
+};
+
+}  // namespace
+
+TEST(BasicNetworkTest, network)
+{
+    using namespace std::chrono_literals;
+    std::vector pv;
+    pv.emplace_back(0);
+    pv.emplace_back(1);
+    pv.emplace_back(2);
+    csf::Scheduler scheduler;
+    csf::BasicNetwork net(scheduler);
+    EXPECT_TRUE(!net.connect(&pv[0], &pv[0]));
+    EXPECT_TRUE(net.connect(&pv[0], &pv[1], 1s));
+    EXPECT_TRUE(net.connect(&pv[1], &pv[2], 1s));
+    EXPECT_TRUE(!net.connect(&pv[0], &pv[1]));
+    for (auto& peer : pv)
+        peer.start(scheduler, net);
+    EXPECT_TRUE(scheduler.stepFor(0s));
+    EXPECT_TRUE(scheduler.stepFor(1s));
+    EXPECT_TRUE(scheduler.step());
+    EXPECT_TRUE(!scheduler.step());
+    EXPECT_TRUE(!scheduler.stepFor(1s));
+    net.send(&pv[0], &pv[1], [] {});
+    net.send(&pv[1], &pv[0], [] {});
+    EXPECT_TRUE(net.disconnect(&pv[0], &pv[1]));
+    EXPECT_TRUE(!net.disconnect(&pv[0], &pv[1]));
+    for (;;)
+    {
+        auto const links = net.links(&pv[1]);
+        if (links.empty())
+            break;
+        EXPECT_TRUE(net.disconnect(&pv[1], links[0].target));
+    }
+    EXPECT_TRUE(pv[0].set == std::set({0, 2, 4}));
+    EXPECT_TRUE(pv[1].set == std::set({1, 3}));
+    EXPECT_TRUE(pv[2].set == std::set({2, 4}));
+}
+
+TEST(BasicNetworkTest, disconnect)
+{
+    using namespace std::chrono_literals;
+    csf::Scheduler scheduler;
+    csf::BasicNetwork net(scheduler);
+    EXPECT_TRUE(net.connect(0, 1, 1s));
+    EXPECT_TRUE(net.connect(0, 2, 2s));
+
+    std::set delivered;
+    net.send(0, 1, [&]() { delivered.insert(1); });
+    net.send(0, 2, [&]() { delivered.insert(2); });
+
+    scheduler.in(1000ms, [&]() { EXPECT_TRUE(net.disconnect(0, 2)); });
+    scheduler.in(1100ms, [&]() { EXPECT_TRUE(net.connect(0, 2)); });
+
+    scheduler.step();
+
+    // only the first message is delivered because the disconnect at 1 s
+    // purges all pending messages from 0 to 2
+    EXPECT_TRUE(delivered == std::set({1}));
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/csf/BasicNetwork.h b/src/tests/libxrpl/csf/BasicNetwork.h
similarity index 99%
rename from src/test/csf/BasicNetwork.h
rename to src/tests/libxrpl/csf/BasicNetwork.h
index 0428475504..2c68bd282d 100644
--- a/src/test/csf/BasicNetwork.h
+++ b/src/tests/libxrpl/csf/BasicNetwork.h
@@ -1,7 +1,7 @@
 #pragma once
 
-#include 
-#include 
+#include 
+#include 
 
 #include 
 
diff --git a/src/test/csf/CollectorRef.h b/src/tests/libxrpl/csf/CollectorRef.h
similarity index 97%
rename from src/test/csf/CollectorRef.h
rename to src/tests/libxrpl/csf/CollectorRef.h
index 3aef4d617f..b1da962f3d 100644
--- a/src/test/csf/CollectorRef.h
+++ b/src/tests/libxrpl/csf/CollectorRef.h
@@ -1,11 +1,11 @@
 #pragma once
 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
diff --git a/src/tests/libxrpl/csf/Digraph.cpp b/src/tests/libxrpl/csf/Digraph.cpp
new file mode 100644
index 0000000000..83c15ec06a
--- /dev/null
+++ b/src/tests/libxrpl/csf/Digraph.cpp
@@ -0,0 +1,72 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+TEST(DigraphTest, digraph)
+{
+    using namespace csf;
+    using Graph = Digraph;
+    Graph graph;
+
+    EXPECT_TRUE(!graph.connected('a', 'b'));
+    EXPECT_TRUE(!graph.edge('a', 'b'));
+    EXPECT_TRUE(!graph.disconnect('a', 'b'));
+
+    EXPECT_TRUE(graph.connect('a', 'b', "foobar"));
+    EXPECT_TRUE(graph.connected('a', 'b'));
+    EXPECT_TRUE(*graph.edge('a', 'b') == "foobar");  // NOLINT(bugprone-unchecked-optional-access)
+
+    EXPECT_TRUE(!graph.connect('a', 'b', "repeat"));
+    EXPECT_TRUE(graph.disconnect('a', 'b'));
+    EXPECT_TRUE(graph.connect('a', 'b', "repeat"));
+    EXPECT_TRUE(graph.connected('a', 'b'));
+    EXPECT_TRUE(*graph.edge('a', 'b') == "repeat");  // NOLINT(bugprone-unchecked-optional-access)
+
+    EXPECT_TRUE(graph.connect('a', 'c', "tree"));
+
+    {
+        std::vector> edges;
+
+        for (auto const& edge : graph.outEdges('a'))
+        {
+            edges.emplace_back(edge.source, edge.target, edge.data);
+        }
+
+        std::vector> expected;
+        expected.emplace_back('a', 'b', "repeat");
+        expected.emplace_back('a', 'c', "tree");
+        EXPECT_TRUE(edges == expected);
+        EXPECT_TRUE(graph.outDegree('a') == expected.size());
+    }
+
+    EXPECT_TRUE(graph.outEdges('r').size() == 0);
+    EXPECT_TRUE(graph.outDegree('r') == 0);
+    EXPECT_TRUE(graph.outDegree('c') == 0);
+
+    // only 'a' has out edges
+    EXPECT_TRUE(graph.outVertices().size() == 1);
+    std::vector const expected = {'b', 'c'};
+
+    EXPECT_TRUE((graph.outVertices('a') == expected));
+    EXPECT_TRUE(graph.outVertices('b').size() == 0);
+    EXPECT_TRUE(graph.outVertices('c').size() == 0);
+    EXPECT_TRUE(graph.outVertices('r').size() == 0);
+
+    std::stringstream ss;
+    graph.saveDot(ss, [](char v) { return v; });
+    std::string const expectedDot =
+        "digraph {\n"
+        "a -> b;\n"
+        "a -> c;\n"
+        "}\n";
+    EXPECT_TRUE(ss.str() == expectedDot);
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/csf/Digraph.h b/src/tests/libxrpl/csf/Digraph.h
similarity index 100%
rename from src/test/csf/Digraph.h
rename to src/tests/libxrpl/csf/Digraph.h
diff --git a/src/tests/libxrpl/csf/Histogram.cpp b/src/tests/libxrpl/csf/Histogram.cpp
new file mode 100644
index 0000000000..6de1cde593
--- /dev/null
+++ b/src/tests/libxrpl/csf/Histogram.cpp
@@ -0,0 +1,59 @@
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+TEST(HistogramTest, histogram)
+{
+    using namespace csf;
+    Histogram hist;
+
+    EXPECT_TRUE(hist.size() == 0);
+    EXPECT_TRUE(hist.numBins() == 0);
+    EXPECT_TRUE(hist.minValue() == 0);
+    EXPECT_TRUE(hist.maxValue() == 0);
+    EXPECT_TRUE(hist.avg() == 0);
+    EXPECT_TRUE(hist.percentile(0.0f) == hist.minValue());
+    EXPECT_TRUE(hist.percentile(0.5f) == 0);
+    EXPECT_TRUE(hist.percentile(0.9f) == 0);
+    EXPECT_TRUE(hist.percentile(1.0f) == hist.maxValue());
+
+    hist.insert(1);
+
+    EXPECT_TRUE(hist.size() == 1);
+    EXPECT_TRUE(hist.numBins() == 1);
+    EXPECT_TRUE(hist.minValue() == 1);
+    EXPECT_TRUE(hist.maxValue() == 1);
+    EXPECT_TRUE(hist.avg() == 1);
+    EXPECT_TRUE(hist.percentile(0.0f) == hist.minValue());
+    EXPECT_TRUE(hist.percentile(0.5f) == 1);
+    EXPECT_TRUE(hist.percentile(0.9f) == 1);
+    EXPECT_TRUE(hist.percentile(1.0f) == hist.maxValue());
+
+    hist.insert(9);
+
+    EXPECT_TRUE(hist.size() == 2);
+    EXPECT_TRUE(hist.numBins() == 2);
+    EXPECT_TRUE(hist.minValue() == 1);
+    EXPECT_TRUE(hist.maxValue() == 9);
+    EXPECT_TRUE(hist.avg() == 5);
+    EXPECT_TRUE(hist.percentile(0.0f) == hist.minValue());
+    EXPECT_TRUE(hist.percentile(0.5f) == 1);
+    EXPECT_TRUE(hist.percentile(0.9f) == 9);
+    EXPECT_TRUE(hist.percentile(1.0f) == hist.maxValue());
+
+    hist.insert(1);
+
+    EXPECT_TRUE(hist.size() == 3);
+    EXPECT_TRUE(hist.numBins() == 2);
+    EXPECT_TRUE(hist.minValue() == 1);
+    EXPECT_TRUE(hist.maxValue() == 9);
+    EXPECT_TRUE(hist.avg() == 11 / 3);
+    EXPECT_TRUE(hist.percentile(0.0f) == hist.minValue());
+    EXPECT_TRUE(hist.percentile(0.5f) == 1);
+    EXPECT_TRUE(hist.percentile(0.9f) == 9);
+    EXPECT_TRUE(hist.percentile(1.0f) == hist.maxValue());
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/csf/Histogram.h b/src/tests/libxrpl/csf/Histogram.h
similarity index 100%
rename from src/test/csf/Histogram.h
rename to src/tests/libxrpl/csf/Histogram.h
diff --git a/src/test/csf/Peer.h b/src/tests/libxrpl/csf/Peer.h
similarity index 98%
rename from src/test/csf/Peer.h
rename to src/tests/libxrpl/csf/Peer.h
index 79bffec9cb..d4b6f42bdb 100644
--- a/src/test/csf/Peer.h
+++ b/src/tests/libxrpl/csf/Peer.h
@@ -1,33 +1,32 @@
 #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 
diff --git a/src/test/csf/PeerGroup.h b/src/tests/libxrpl/csf/PeerGroup.h
similarity index 98%
rename from src/test/csf/PeerGroup.h
rename to src/tests/libxrpl/csf/PeerGroup.h
index 1c31209ef3..ff99b779a3 100644
--- a/src/test/csf/PeerGroup.h
+++ b/src/tests/libxrpl/csf/PeerGroup.h
@@ -1,9 +1,9 @@
 #pragma once
 
-#include 
-#include 
-#include 
-#include 
+#include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
diff --git a/src/test/csf/Proposal.h b/src/tests/libxrpl/csf/Proposal.h
similarity index 66%
rename from src/test/csf/Proposal.h
rename to src/tests/libxrpl/csf/Proposal.h
index ecf430ae8d..b2a97f9731 100644
--- a/src/test/csf/Proposal.h
+++ b/src/tests/libxrpl/csf/Proposal.h
@@ -1,10 +1,10 @@
 #pragma once
 
-#include 
-#include 
-#include 
+#include 
 
-#include 
+#include 
+#include 
+#include 
 
 namespace xrpl::test::csf {
 /**
diff --git a/src/test/csf/README.md b/src/tests/libxrpl/csf/README.md
similarity index 100%
rename from src/test/csf/README.md
rename to src/tests/libxrpl/csf/README.md
diff --git a/src/tests/libxrpl/csf/Scheduler.cpp b/src/tests/libxrpl/csf/Scheduler.cpp
new file mode 100644
index 0000000000..63871e0623
--- /dev/null
+++ b/src/tests/libxrpl/csf/Scheduler.cpp
@@ -0,0 +1,61 @@
+#include 
+
+#include 
+
+#include 
+
+namespace xrpl::test {
+
+TEST(SchedulerTest, scheduler)
+{
+    using namespace std::chrono_literals;
+    csf::Scheduler scheduler;
+    std::set seen;
+
+    scheduler.in(1s, [&] { seen.insert(1); });
+    scheduler.in(2s, [&] { seen.insert(2); });
+    auto token = scheduler.in(3s, [&] { seen.insert(3); });
+    scheduler.at(scheduler.now() + 4s, [&] { seen.insert(4); });
+    scheduler.at(scheduler.now() + 8s, [&] { seen.insert(8); });
+
+    auto start = scheduler.now();
+
+    // Process first event
+    EXPECT_TRUE(seen.empty());
+    EXPECT_TRUE(scheduler.stepOne());
+    EXPECT_TRUE(seen == std::set({1}));
+    EXPECT_TRUE(scheduler.now() == (start + 1s));
+
+    // No processing if stepping until current time
+    EXPECT_TRUE(scheduler.stepUntil(scheduler.now()));
+    EXPECT_TRUE(seen == std::set({1}));
+    EXPECT_TRUE(scheduler.now() == (start + 1s));
+
+    // Process next event
+    EXPECT_TRUE(scheduler.stepFor(1s));
+    EXPECT_TRUE(seen == std::set({1, 2}));
+    EXPECT_TRUE(scheduler.now() == (start + 2s));
+
+    // Don't process cancelled event, but advance clock
+    scheduler.cancel(token);
+    EXPECT_TRUE(scheduler.stepFor(1s));
+    EXPECT_TRUE(seen == std::set({1, 2}));
+    EXPECT_TRUE(scheduler.now() == (start + 3s));
+
+    // Process until 3 seen ints
+    EXPECT_TRUE(scheduler.stepWhile([&]() { return seen.size() < 3; }));
+    EXPECT_TRUE(seen == std::set({1, 2, 4}));
+    EXPECT_TRUE(scheduler.now() == (start + 4s));
+
+    // Process the rest
+    EXPECT_TRUE(scheduler.step());
+    EXPECT_TRUE(seen == std::set({1, 2, 4, 8}));
+    EXPECT_TRUE(scheduler.now() == (start + 8s));
+
+    // Process the rest again doesn't advance
+    EXPECT_TRUE(!scheduler.step());
+    EXPECT_TRUE(seen == std::set({1, 2, 4, 8}));
+    EXPECT_TRUE(scheduler.now() == (start + 8s));
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/csf/Scheduler.h b/src/tests/libxrpl/csf/Scheduler.h
similarity index 100%
rename from src/test/csf/Scheduler.h
rename to src/tests/libxrpl/csf/Scheduler.h
diff --git a/src/test/csf/Sim.h b/src/tests/libxrpl/csf/Sim.h
similarity index 94%
rename from src/test/csf/Sim.h
rename to src/tests/libxrpl/csf/Sim.h
index 94d26d5e06..774537138a 100644
--- a/src/test/csf/Sim.h
+++ b/src/tests/libxrpl/csf/Sim.h
@@ -1,16 +1,16 @@
 #pragma once
 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/SimTime.h b/src/tests/libxrpl/csf/SimTime.h
similarity index 100%
rename from src/test/csf/SimTime.h
rename to src/tests/libxrpl/csf/SimTime.h
diff --git a/src/test/csf/TrustGraph.h b/src/tests/libxrpl/csf/TrustGraph.h
similarity index 96%
rename from src/test/csf/TrustGraph.h
rename to src/tests/libxrpl/csf/TrustGraph.h
index d46a887364..8a804fcd5b 100644
--- a/src/test/csf/TrustGraph.h
+++ b/src/tests/libxrpl/csf/TrustGraph.h
@@ -1,9 +1,9 @@
 #pragma once
 
-#include 
-
 #include 
 
+#include 
+
 #include 
 #include 
 #include 
@@ -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/test/csf/Tx.h b/src/tests/libxrpl/csf/Tx.h
similarity index 100%
rename from src/test/csf/Tx.h
rename to src/tests/libxrpl/csf/Tx.h
diff --git a/src/test/csf/Validation.h b/src/tests/libxrpl/csf/Validation.h
similarity index 99%
rename from src/test/csf/Validation.h
rename to src/tests/libxrpl/csf/Validation.h
index 0b9fc94890..4325f96511 100644
--- a/src/test/csf/Validation.h
+++ b/src/tests/libxrpl/csf/Validation.h
@@ -1,10 +1,10 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/collectors.h b/src/tests/libxrpl/csf/collectors.h
similarity index 99%
rename from src/test/csf/collectors.h
rename to src/tests/libxrpl/csf/collectors.h
index f85854e5dd..05b63f592b 100644
--- a/src/test/csf/collectors.h
+++ b/src/tests/libxrpl/csf/collectors.h
@@ -1,13 +1,13 @@
 #pragma once
 
-#include 
-#include 
-#include 
-#include 
-#include 
-
 #include 
 
+#include 
+#include 
+#include 
+#include 
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/csf_graph.png b/src/tests/libxrpl/csf/csf_graph.png
similarity index 100%
rename from src/test/csf/csf_graph.png
rename to src/tests/libxrpl/csf/csf_graph.png
diff --git a/src/test/csf/csf_overview.png b/src/tests/libxrpl/csf/csf_overview.png
similarity index 100%
rename from src/test/csf/csf_overview.png
rename to src/tests/libxrpl/csf/csf_overview.png
diff --git a/src/test/csf/events.h b/src/tests/libxrpl/csf/events.h
similarity index 96%
rename from src/test/csf/events.h
rename to src/tests/libxrpl/csf/events.h
index 2cf4fd9e9b..4ff15ec987 100644
--- a/src/test/csf/events.h
+++ b/src/tests/libxrpl/csf/events.h
@@ -1,8 +1,8 @@
 #pragma once
 
-#include 
-#include 
-#include 
+#include 
+#include 
+#include 
 
 namespace xrpl::test::csf {
 
diff --git a/src/test/csf/impl/Sim.cpp b/src/tests/libxrpl/csf/impl/Sim.cpp
similarity index 93%
rename from src/test/csf/impl/Sim.cpp
rename to src/tests/libxrpl/csf/impl/Sim.cpp
index bf4706927e..28ac5f126a 100644
--- a/src/test/csf/impl/Sim.cpp
+++ b/src/tests/libxrpl/csf/impl/Sim.cpp
@@ -1,7 +1,7 @@
-#include 
+#include 
 
-#include 
-#include 
+#include 
+#include 
 
 #include 
 #include 
diff --git a/src/test/csf/impl/ledgers.cpp b/src/tests/libxrpl/csf/impl/ledgers.cpp
similarity index 98%
rename from src/test/csf/impl/ledgers.cpp
rename to src/tests/libxrpl/csf/impl/ledgers.cpp
index 46a3600307..ed1cd927c4 100644
--- a/src/test/csf/impl/ledgers.cpp
+++ b/src/tests/libxrpl/csf/impl/ledgers.cpp
@@ -1,11 +1,11 @@
-#include 
-
-#include 
+#include 
 
 #include 
 #include 
 #include 
 
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/ledgers.h b/src/tests/libxrpl/csf/ledgers.h
similarity index 99%
rename from src/test/csf/ledgers.h
rename to src/tests/libxrpl/csf/ledgers.h
index 09f5fa54de..ca4e44d5a6 100644
--- a/src/test/csf/ledgers.h
+++ b/src/tests/libxrpl/csf/ledgers.h
@@ -1,7 +1,5 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 #include 
@@ -9,6 +7,8 @@
 
 #include 
 
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/test/csf/random.h b/src/tests/libxrpl/csf/random.h
similarity index 92%
rename from src/test/csf/random.h
rename to src/tests/libxrpl/csf/random.h
index f8df253642..56838bb280 100644
--- a/src/test/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]);
     }
@@ -135,13 +136,13 @@ class PowerLawDistribution
 {
     double xmin_;
     double a_;
-    double inv_;
+    double inv_{1.0 / (1.0 - a_)};
     std::uniform_real_distribution uf_{0, 1};
 
 public:
     using result_type = double;
 
-    PowerLawDistribution(double xmin, double a) : xmin_{xmin}, a_{a}, inv_(1.0 / (1.0 - a_))
+    PowerLawDistribution(double xmin, double a) : xmin_{xmin}, a_{a}
     {
     }
 
diff --git a/src/test/csf/submitters.h b/src/tests/libxrpl/csf/submitters.h
similarity index 96%
rename from src/test/csf/submitters.h
rename to src/tests/libxrpl/csf/submitters.h
index 160d0bcd9f..4f27f0f665 100644
--- a/src/test/csf/submitters.h
+++ b/src/tests/libxrpl/csf/submitters.h
@@ -1,8 +1,8 @@
 #pragma once
 
-#include 
-#include 
-#include 
+#include 
+#include 
+#include 
 
 #include 
 #include 
diff --git a/src/test/csf/timers.h b/src/tests/libxrpl/csf/timers.h
similarity index 96%
rename from src/test/csf/timers.h
rename to src/tests/libxrpl/csf/timers.h
index 4f13b21b25..e89ea33698 100644
--- a/src/test/csf/timers.h
+++ b/src/tests/libxrpl/csf/timers.h
@@ -1,7 +1,7 @@
 #pragma once
 
-#include 
-#include 
+#include 
+#include 
 
 #include 
 #include 
diff --git a/src/tests/libxrpl/helpers/CaptureSink.h b/src/tests/libxrpl/helpers/CaptureSink.h
new file mode 100644
index 0000000000..9918d13f9e
--- /dev/null
+++ b/src/tests/libxrpl/helpers/CaptureSink.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class CaptureSink : public beast::Journal::Sink
+{
+    mutable std::mutex mutex_;
+    std::stringstream strm_;
+
+public:
+    explicit CaptureSink(beast::Severity threshold = beast::Severity::Debug)
+        : Sink{threshold, false}
+    {
+    }
+
+    void
+    write(beast::Severity level, std::string const& text) override
+    {
+        if (level < threshold())
+            return;
+        writeAlways(level, text);
+    }
+
+    void
+    writeAlways(beast::Severity /*level*/, std::string const& text) override
+    {
+        // Journal sinks may be written to concurrently (e.g. from a backend's background workers),
+        // so serialize access to strm_. write() funnels into writeAlways(), so the lock lives here
+        // only: locking in both would self-deadlock on this non-recursive mutex.
+        std::scoped_lock const lock(mutex_);
+        strm_ << text << '\n';
+    }
+
+    [[nodiscard]] std::string
+    messages() const
+    {
+        // Returns a snapshot of the captured output. Takes the lock so the read is safe even if a
+        // writer is still active.
+        std::scoped_lock const lock(mutex_);
+        return strm_.str();
+    }
+};
+
+}  // namespace xrpl::test
diff --git a/src/tests/libxrpl/helpers/TestFamily.h b/src/tests/libxrpl/helpers/TestFamily.h
index 1a11d3bb68..8a599ab4da 100644
--- a/src/tests/libxrpl/helpers/TestFamily.h
+++ b/src/tests/libxrpl/helpers/TestFamily.h
@@ -29,11 +29,11 @@ namespace xrpl::test {
 class TestFamily : public Family
 {
 private:
-    std::unique_ptr db_;
+    std::unique_ptr db_;
     TestStopwatch clock_;
     std::shared_ptr fbCache_;
     std::shared_ptr tnCache_;
-    NodeStore::DummyScheduler scheduler_;
+    node_store::DummyScheduler scheduler_;
     beast::Journal j_;
 
 public:
@@ -51,16 +51,16 @@ public:
         Section config;
         config.set(Keys::kType, "memory");
         config.set(Keys::kPath, "TestFamily");
-        db_ = NodeStore::Manager::instance().makeDatabase(megabytes(4), scheduler_, 1, config, j);
+        db_ = node_store::Manager::instance().makeDatabase(megabytes(4), scheduler_, 1, config, j);
     }
 
-    NodeStore::Database&
+    node_store::Database&
     db() override
     {
         return *db_;
     }
 
-    [[nodiscard]] NodeStore::Database const&
+    [[nodiscard]] node_store::Database const&
     db() const override
     {
         return *db_;
diff --git a/src/tests/libxrpl/helpers/TestServiceRegistry.h b/src/tests/libxrpl/helpers/TestServiceRegistry.h
index 5475b54dc6..e763c8bde4 100644
--- a/src/tests/libxrpl/helpers/TestServiceRegistry.h
+++ b/src/tests/libxrpl/helpers/TestServiceRegistry.h
@@ -213,14 +213,14 @@ public:
         throw std::logic_error("TestServiceRegistry::peerReservations() not implemented");
     }
 
-    Resource::Manager&
+    resource::Manager&
     getResourceManager() override
     {
         throw std::logic_error("TestServiceRegistry::getResourceManager() not implemented");
     }
 
     // Storage services
-    NodeStore::Database&
+    node_store::Database&
     getNodeStore() override
     {
         throw std::logic_error("TestServiceRegistry::getNodeStore() not implemented");
diff --git a/src/tests/libxrpl/main.cpp b/src/tests/libxrpl/main.cpp
index 5142bbe08a..f9114bffc4 100644
--- a/src/tests/libxrpl/main.cpp
+++ b/src/tests/libxrpl/main.cpp
@@ -1,8 +1,9 @@
+#include 
 #include 
 
 int
 main(int argc, char** argv)
 {
-    ::testing::InitGoogleTest(&argc, argv);
+    ::testing::InitGoogleMock(&argc, argv);
     return RUN_ALL_TESTS();
 }
diff --git a/src/tests/libxrpl/nodestore/Backend.cpp b/src/tests/libxrpl/nodestore/Backend.cpp
new file mode 100644
index 0000000000..3bd36ced8d
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/Backend.cpp
@@ -0,0 +1,182 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::node_store {
+
+namespace {
+
+std::vector
+backendTypes()
+{
+    std::vector types{"nudb"};
+#if XRPL_ROCKSDB_AVAILABLE
+    types.emplace_back("rocksdb");
+#endif
+#ifdef XRPL_ENABLE_SQLITE_BACKEND_TESTS
+    types.emplace_back("sqlite");
+#endif
+    return types;
+}
+
+// Run work(i) for every i in [0, n) spread across numThreads threads, handing
+// out indices via a shared atomic counter (mirrors the old Timing_test
+// parallel-for so the N items are partitioned, not duplicated).
+template 
+void
+parallelFor(std::size_t n, std::size_t numThreads, Work work)
+{
+    std::atomic next{0};
+    auto const runner = [&] {
+        for (std::size_t i = next++; i < n; i = next++)
+            work(i);
+    };
+
+    auto threads = std::views::iota(std::size_t{0}, numThreads) |
+        std::views::transform([&](std::size_t) { return std::thread{runner}; }) |
+        std::ranges::to();
+
+    std::ranges::for_each(threads, &std::thread::join);
+}
+
+}  // namespace
+
+class BackendTypeTest : public ::testing::TestWithParam
+{
+protected:
+    void
+    SetUp() override
+    {
+        params_.set("type", GetParam());
+        params_.set("path", tempDir_.path());
+
+        beast::xor_shift_engine rng(kSeedValue);
+        batch_ = createPredictableBatch(kNumObjects, rng());
+    }
+
+    std::unique_ptr
+    makeOpenBackend()
+    {
+        auto backend = Manager::instance().makeBackend(params_, megabytes(4), scheduler_, journal_);
+        backend->open();
+        return backend;
+    }
+
+    DummyScheduler scheduler_;
+    TempDir const tempDir_;
+    beast::Journal const journal_{TestSink::instance()};
+    Section params_;
+    Batch batch_;
+};
+
+TEST_P(BackendTypeTest, store_and_fetch)
+{
+    auto backend = makeOpenBackend();
+    storeBatch(*backend, batch_);
+
+    {
+        SCOPED_TRACE("read in original order");
+        auto const copy = fetchCopyOfBatch(*backend, batch_);
+        EXPECT_EQ(batch_, copy);
+    }
+
+    {
+        SCOPED_TRACE("read in shuffled order");
+        beast::xor_shift_engine rng(kSeedValue);
+        std::shuffle(batch_.begin(), batch_.end(), rng);
+        auto const copy = fetchCopyOfBatch(*backend, batch_);
+        EXPECT_EQ(batch_, copy);
+    }
+}
+
+TEST_P(BackendTypeTest, persists_after_reopen)
+{
+    {
+        auto backend = makeOpenBackend();
+        storeBatch(*backend, batch_);
+    }
+
+    // re-open a fresh backend instance over the same path
+    auto backend = makeOpenBackend();
+    auto copy = fetchCopyOfBatch(*backend, batch_);
+    std::ranges::sort(batch_, LessThan{});
+    std::ranges::sort(copy, LessThan{});
+    EXPECT_EQ(batch_, copy);
+}
+
+// missing-key path. Replaces the correctness half of Timing_test::doMissing
+// (and the missing branch of doMixed): every fetch on an empty backend must
+// report Status::NotFound.
+TEST_P(BackendTypeTest, fetch_missing)
+{
+    auto backend = makeOpenBackend();
+    // deliberately do NOT store batch_ — every key must be absent
+    fetchMissing(*backend, batch_);
+}
+
+// concurrent store/fetch correctness. Replaces the correctness half of the
+// multi-threaded Timing_test workloads (which only ran manually, never in CI):
+// many threads store disjoint objects, then many threads fetch and verify each
+// round-trips. Doubles as a thread-safety smoke test for the backend.
+TEST_P(BackendTypeTest, concurrent_store_and_fetch)
+{
+    // The SQLite backend is not designed for concurrent writers (and the old
+    // Timing_test only exercised nudb/rocksdb under threads).
+    if (GetParam() == "sqlite")
+        GTEST_SKIP() << "sqlite backend is not exercised under concurrency";
+
+    for (auto const numThreads : {4uz, 8uz})
+    {
+        SCOPED_TRACE("threads=" + std::to_string(numThreads));
+
+        auto backend = makeOpenBackend();
+
+        // concurrent stores of disjoint objects
+        parallelFor(batch_.size(), numThreads, [&](std::size_t i) { backend->store(batch_[i]); });
+
+        // concurrent fetches, each verifying its object round-trips. Worker
+        // threads only touch an atomic counter; the EXPECT runs on the main
+        // thread after join to avoid relying on cross-thread assertion support.
+        std::atomic mismatches{0};
+        parallelFor(batch_.size(), numThreads, [&](std::size_t i) {
+            std::shared_ptr result;
+            if (backend->fetch(batch_[i]->getHash(), &result) != Status::Ok || !result ||
+                !isSame(result, batch_[i]))
+            {
+                ++mismatches;
+            }
+        });
+        EXPECT_EQ(mismatches.load(), 0u);
+
+        backend->close();
+    }
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    BackendTypes,
+    BackendTypeTest,
+    ::testing::ValuesIn(backendTypes()),
+    [](::testing::TestParamInfo const& info) { return info.param; });
+
+}  // namespace xrpl::node_store
diff --git a/src/tests/libxrpl/nodestore/Basics.cpp b/src/tests/libxrpl/nodestore/Basics.cpp
new file mode 100644
index 0000000000..5bb902af0d
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/Basics.cpp
@@ -0,0 +1,41 @@
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl::node_store {
+
+TEST(NodeStoreBasics, predictable_batches)
+{
+    auto const batch1 = createPredictableBatch(kNumObjectsToTest, kSeedValue);
+    auto const batch2 = createPredictableBatch(kNumObjectsToTest, kSeedValue);
+    EXPECT_EQ(batch1, batch2);
+
+    auto const batch3 = createPredictableBatch(kNumObjectsToTest, kSeedValue + 1);
+    EXPECT_NE(batch1, batch3);
+}
+
+TEST(NodeStoreBasics, blob_encoding)
+{
+    auto const batch = createPredictableBatch(kNumObjectsToTest, kSeedValue);
+    for (std::size_t i = 0; i < batch.size(); ++i)
+    {
+        SCOPED_TRACE("blob index=" + std::to_string(i));
+        EncodedBlob const encoded(batch[i]);
+        DecodedBlob decoded(encoded.getKey(), encoded.getData(), encoded.getSize());
+        EXPECT_TRUE(decoded.wasOk());
+        if (decoded.wasOk())
+        {
+            std::shared_ptr const object(decoded.createObject());
+            EXPECT_TRUE(isSame(batch[i], object));
+        }
+    }
+}
+
+}  // namespace xrpl::node_store
diff --git a/src/tests/libxrpl/nodestore/Codec.cpp b/src/tests/libxrpl/nodestore/Codec.cpp
new file mode 100644
index 0000000000..f31878f3c5
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/Codec.cpp
@@ -0,0 +1,146 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+using namespace xrpl;
+using namespace xrpl::node_store;
+
+namespace {
+
+// v1 inner-node layout: 16 hashes of 32 bytes each
+constexpr std::size_t kHashCount = 16;
+constexpr std::size_t kHashSize = 32;
+
+std::vector
+makeInnerNode(std::size_t nonEmptySlots)
+{
+    using namespace nudb::detail;
+
+    static constexpr std::size_t kInnerNodeSize = 525;
+
+    std::array hashes{};
+    for (auto slot = 0uz; slot < nonEmptySlots; ++slot)
+    {
+        for (auto byte = 0uz; byte < kHashSize; ++byte)
+        {
+            std::size_t const offset = (slot * kHashSize) + byte;
+            hashes[offset] = static_cast((offset % 255) + 1);
+        }
+    }
+
+    std::vector blob(kInnerNodeSize);
+    ostream os(blob.data(), blob.size());
+    write(os, 0);  // index
+    write(os, 0);  // unused
+    write(os, static_cast(NodeObjectType::Unknown));
+    write(os, static_cast(HashPrefix::InnerNode));
+    write(os, hashes.data(), hashes.size());
+
+    return blob;
+}
+
+std::uint8_t
+codecType(std::pair const& compressed)
+{
+    return static_cast(compressed.first)[0];
+}
+
+}  // namespace
+
+// All 16 hash slots populated - "full v1 inner node"
+TEST(Codec, inner_node_full_roundtrip)
+{
+    static constexpr std::uint8_t kTypeInnerNodeFull = 3;
+
+    auto const blob = makeInnerNode(kHashCount);
+
+    nudb::detail::buffer compressBuf;
+    auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf);
+
+    EXPECT_EQ(codecType(compressed), kTypeInnerNodeFull);
+    EXPECT_EQ(compressed.second, sizeVarint(kTypeInnerNodeFull) + (kHashCount * kHashSize));
+
+    nudb::detail::buffer decompressBuf;
+    auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf);
+
+    EXPECT_EQ(restored.second, blob.size());
+    EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0);
+}
+
+// Some hash slots empty - "compressed v1 inner node"
+TEST(Codec, inner_node_compressed_roundtrip)
+{
+    static constexpr std::uint8_t kTypeInnerNodeCompressed = 2;
+    static constexpr std::size_t kNonEmpty = 5;
+    auto const blob = makeInnerNode(kNonEmpty);
+
+    nudb::detail::buffer compressBuf;
+    auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf);
+
+    EXPECT_EQ(codecType(compressed), kTypeInnerNodeCompressed);
+    EXPECT_EQ(
+        compressed.second,
+        sizeVarint(kTypeInnerNodeCompressed) + sizeof(std::uint16_t) + (kNonEmpty * kHashSize));
+    EXPECT_LT(compressed.second, blob.size());
+
+    nudb::detail::buffer decompressBuf;
+    auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf);
+
+    EXPECT_EQ(restored.second, blob.size());
+    EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0);
+}
+
+// Anything that is not a v1 inner node - lz4 compressed
+TEST(Codec, lz4_roundtrip)
+{
+    // A payload that is deliberately not a v1 inner node (any size other than 525), filled with a
+    // short repeating pattern so lz4 actually shrinks it.
+    static constexpr std::size_t kNonInnerNodeSize = 1000;
+    static constexpr std::size_t kBytePatternPeriod = 7;
+    static constexpr std::uint8_t kTypeLz4 = 1;
+
+    std::vector blob(kNonInnerNodeSize);
+    for (auto i = 0uz; i < blob.size(); ++i)
+        blob[i] = static_cast(i % kBytePatternPeriod);
+
+    nudb::detail::buffer compressBuf;
+    auto const compressed = nodeobjectCompress(blob.data(), blob.size(), compressBuf);
+
+    EXPECT_EQ(codecType(compressed), kTypeLz4);
+
+    nudb::detail::buffer decompressBuf;
+    auto const restored = nodeobjectDecompress(compressed.first, compressed.second, decompressBuf);
+
+    EXPECT_EQ(restored.second, blob.size());
+    EXPECT_EQ(std::memcmp(restored.first, blob.data(), blob.size()), 0);
+}
+
+// An uncompressed blob is never produced by the compressor but must still decode: leading varint 0
+// followed by the raw payload.
+TEST(Codec, uncompressed_passthrough)
+{
+    static constexpr std::uint8_t kTypeUncompressed = 0;
+    static constexpr auto payload = std::to_array({0xde, 0xad, 0xbe, 0xef, 0x2a});
+
+    std::vector blob;
+    blob.push_back(kTypeUncompressed);  // leading varint type tag
+    blob.insert(blob.end(), payload.begin(), payload.end());
+
+    nudb::detail::buffer decompressBuf;
+    auto const restored = nodeobjectDecompress(blob.data(), blob.size(), decompressBuf);
+
+    EXPECT_EQ(restored.second, payload.size());
+    EXPECT_EQ(std::memcmp(restored.first, payload.data(), payload.size()), 0);
+}
diff --git a/src/tests/libxrpl/nodestore/Database.cpp b/src/tests/libxrpl/nodestore/Database.cpp
new file mode 100644
index 0000000000..a3f7340f62
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/Database.cpp
@@ -0,0 +1,248 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::node_store {
+
+namespace {
+
+std::vector
+allBackends()
+{
+#if XRPL_ROCKSDB_AVAILABLE
+    return {"memory", "nudb", "rocksdb"};
+#else
+    return {"memory", "nudb"};
+#endif
+}
+
+std::vector
+persistentBackends()
+{
+    std::vector types{"nudb"};
+#if XRPL_ROCKSDB_AVAILABLE
+    types.emplace_back("rocksdb");
+#endif
+    return types;
+}
+
+std::vector
+importBackends()
+{
+    std::vector types{"nudb"};
+#if XRPL_ROCKSDB_AVAILABLE
+    types.emplace_back("rocksdb");
+#endif
+#ifdef XRPL_ENABLE_SQLITE_BACKEND_TESTS
+    types.emplace_back("sqlite");
+#endif
+    return types;
+}
+
+}  // namespace
+
+// Shared setup for the parameterized Database tests: builds the node params,
+// journal and a predictable batch per test, mirroring Backend.cpp's fixture.
+class NodeStoreDatabaseTestBase : public ::testing::TestWithParam
+{
+protected:
+    void
+    SetUp() override
+    {
+        nodeParams_.set("type", GetParam());
+        nodeParams_.set("path", nodeDb_.path());
+
+        beast::xor_shift_engine rng(kSeedValue);
+        batch_ = createPredictableBatch(kNumObjects, rng());
+    }
+
+    std::unique_ptr
+    makeDatabase()
+    {
+        return Manager::instance().makeDatabase(megabytes(4), scheduler_, 2, nodeParams_, journal_);
+    }
+
+    DummyScheduler scheduler_;
+    TempDir const nodeDb_;
+    beast::Journal const journal_{TestSink::instance()};
+    Section nodeParams_;
+    Batch batch_;
+};
+
+class NodeStoreDatabaseTest : public NodeStoreDatabaseTestBase
+{
+};
+
+class NodeStoreDatabasePersistenceTest : public NodeStoreDatabaseTestBase
+{
+};
+
+TEST_P(NodeStoreDatabaseTest, store_and_fetch)
+{
+    auto db = makeDatabase();
+
+    storeBatch(*db, batch_);
+
+    {
+        SCOPED_TRACE("read in original order");
+        auto const copy = fetchCopyOfBatch(*db, batch_);
+        EXPECT_EQ(batch_, copy);
+    }
+
+    {
+        SCOPED_TRACE("read in shuffled order");
+        beast::xor_shift_engine rng(kSeedValue);
+        std::shuffle(batch_.begin(), batch_.end(), rng);
+        auto const copy = fetchCopyOfBatch(*db, batch_);
+        EXPECT_EQ(batch_, copy);
+    }
+}
+
+TEST_P(NodeStoreDatabasePersistenceTest, round_trip)
+{
+    {
+        auto db = makeDatabase();
+        storeBatch(*db, batch_);
+    }
+
+    // re-open without the ephemeral db
+    auto db = makeDatabase();
+
+    auto copy = fetchCopyOfBatch(*db, batch_);
+    std::ranges::sort(batch_, LessThan{});
+    std::ranges::sort(copy, LessThan{});
+    EXPECT_EQ(batch_, copy);
+}
+
+// missing-key path at the Database layer. Mirrors Backend's fetch_missing —
+// fetching keys that were never stored must return nullptr (NotFound).
+TEST_P(NodeStoreDatabaseTest, fetch_missing)
+{
+    auto db = makeDatabase();
+
+    // never store: every key must be absent
+    fetchMissing(*db, batch_);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    NodeStoreBackends,
+    NodeStoreDatabaseTest,
+    ::testing::ValuesIn(allBackends()),
+    [](::testing::TestParamInfo const& info) { return info.param; });
+
+INSTANTIATE_TEST_SUITE_P(
+    PersistentBackends,
+    NodeStoreDatabasePersistenceTest,
+    ::testing::ValuesIn(persistentBackends()),
+    [](::testing::TestParamInfo const& info) { return info.param; });
+
+TEST(NodeStoreDatabase, memory_earliest_seq)
+{
+    DummyScheduler scheduler;
+    TempDir const nodeDb;
+    Section nodeParams;
+    nodeParams.set("type", "memory");
+    nodeParams.set("path", nodeDb.path());
+
+    beast::Journal const journal(TestSink::instance());
+
+    // default earliest ledger sequence
+    {
+        auto db = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
+        EXPECT_EQ(db->earliestLedgerSeq(), kXrpLedgerEarliestSeq);
+    }
+
+    // invalid earliest_seq value
+    {
+        nodeParams.set("earliest_seq", "0");
+        try
+        {
+            auto db =
+                Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
+            FAIL() << "expected runtime_error for earliest_seq=0";
+        }
+        catch (std::runtime_error const& e)
+        {
+            EXPECT_STREQ(e.what(), "Invalid earliest_seq");
+        }
+    }
+
+    // valid earliest_seq value
+    {
+        nodeParams.set("earliest_seq", "1");
+        auto db = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, nodeParams, journal);
+        EXPECT_EQ(db->earliestLedgerSeq(), 1u);
+    }
+}
+
+class DatabaseImportTest : public ::testing::TestWithParam
+{
+};
+
+TEST_P(DatabaseImportTest, same_backend)
+{
+    auto const type = GetParam();
+
+    DummyScheduler scheduler;
+    beast::Journal const journal(TestSink::instance());
+
+    TempDir const srcDir;
+    Section srcParams;
+    srcParams.set("type", type);
+    srcParams.set("path", srcDir.path());
+
+    auto batch = createPredictableBatch(kNumObjects, kSeedValue);
+
+    // write to source db
+    {
+        auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal);
+        storeBatch(*src, batch);
+    }
+
+    Batch copy;
+    {
+        // re-open source and import into a fresh destination
+        auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal);
+
+        TempDir const destDir;
+        Section destParams;
+        destParams.set("type", type);
+        destParams.set("path", destDir.path());
+
+        auto dest =
+            Manager::instance().makeDatabase(megabytes(4), scheduler, 2, destParams, journal);
+
+        dest->importDatabase(*src);
+        copy = fetchCopyOfBatch(*dest, batch);
+    }
+
+    std::ranges::sort(batch, LessThan{});
+    std::ranges::sort(copy, LessThan{});
+    EXPECT_EQ(batch, copy);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    ImportBackends,
+    DatabaseImportTest,
+    ::testing::ValuesIn(importBackends()),
+    [](::testing::TestParamInfo const& info) { return info.param; });
+
+}  // namespace xrpl::node_store
diff --git a/src/tests/libxrpl/nodestore/NuDBFactory.cpp b/src/tests/libxrpl/nodestore/NuDBFactory.cpp
new file mode 100644
index 0000000000..7240f08256
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/NuDBFactory.cpp
@@ -0,0 +1,297 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::node_store {
+
+namespace {
+
+Section
+makeSection(std::string const& path, std::string const& blockSize = "")
+{
+    Section params;
+    params.set("type", "nudb");
+    params.set("path", path);
+    if (!blockSize.empty())
+        params.set("nudb_block_size", blockSize);
+    return params;
+}
+
+void
+runRoundTrip(Section const& params, std::size_t expectedBlocksize)
+{
+    DummyScheduler scheduler;
+    beast::Journal const journal(TestSink::instance());
+    auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+
+    ASSERT_TRUE(backend);
+    ASSERT_EQ(backend->getBlockSize(), expectedBlocksize);
+    backend->open();
+    ASSERT_TRUE(backend->isOpen());
+
+    auto const batch = createPredictableBatch(10, 12345);
+    storeBatch(*backend, batch);
+
+    auto const copy = fetchCopyOfBatch(*backend, batch);
+
+    backend->close();
+    EXPECT_EQ(batch, copy);
+}
+
+}  // namespace
+
+TEST(NuDBFactory, default_block_size)
+{
+    TempDir const tempDir;
+    auto const params = makeSection(tempDir.path());
+    ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096));
+}
+
+TEST(NuDBFactory, valid_block_sizes)
+{
+    auto const kValidSizes = std::to_array({4096, 8192, 16384, 32768});
+    for (auto const size : kValidSizes)
+    {
+        SCOPED_TRACE("size=" + std::to_string(size));
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), std::to_string(size));
+        ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, size));
+    }
+
+    // empty value is ignored by config parser; default (4096) is used
+    {
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), "");
+        ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096));
+    }
+}
+
+TEST(NuDBFactory, invalid_block_sizes)
+{
+    std::vector const kInvalidSizes = {
+        "2048",     // too small
+        "1024",     // too small
+        "65536",    // too large
+        "131072",   // too large
+        "5000",     // not power of 2
+        "6000",     // not power of 2
+        "10000",    // not power of 2
+        "0",        // zero
+        "-1",       // negative
+        "abc",      // non-numeric
+        "4k",       // invalid format
+        "4096.5"};  // decimal
+
+    for (auto const& size : kInvalidSizes)
+    {
+        SCOPED_TRACE("size='" + size + "'");
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), size);
+        EXPECT_THROW(runRoundTrip(params, 4096), std::exception);
+    }
+
+    // whitespace handling — lexical_cast may or may not strip; treat as invalid
+    std::vector const kWhitespaceSizes = {"4096 ", " 4096"};
+    for (auto const& size : kWhitespaceSizes)
+    {
+        SCOPED_TRACE("size='" + size + "'");
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), size);
+        EXPECT_THROW(runRoundTrip(params, 4096), std::exception);
+    }
+}
+
+TEST(NuDBFactory, log_messages)
+{
+    // valid custom block size emits info log
+    {
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), "8192");
+        test::CaptureSink sink(beast::Severity::Info);
+        beast::Journal const journal(sink);
+
+        DummyScheduler scheduler;
+        [[maybe_unused]] auto backend =
+            Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+
+        EXPECT_TRUE(sink.messages().contains("Using custom NuDB block size: 8192"));
+    }
+
+    // invalid block size throws with informative message
+    {
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), "5000");
+        test::CaptureSink sink(beast::Severity::Warning);
+        beast::Journal const journal(sink);
+        DummyScheduler scheduler;
+        try
+        {
+            auto backend =
+                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+            FAIL() << "expected exception for invalid block size 5000";
+        }
+        catch (std::exception const& e)
+        {
+            std::string const what{e.what()};
+            EXPECT_TRUE(what.contains("Invalid nudb_block_size: 5000"));
+            EXPECT_TRUE(what.contains("Must be power of 2 between 4096 and 32768"));
+        }
+    }
+
+    // non-numeric value throws
+    {
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), "invalid");
+        test::CaptureSink sink(beast::Severity::Warning);
+        beast::Journal const journal(sink);
+        DummyScheduler scheduler;
+        try
+        {
+            auto backend =
+                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+            FAIL() << "expected exception for non-numeric block size";
+        }
+        catch (std::exception const& e)
+        {
+            std::string const what{e.what()};
+            EXPECT_TRUE(what.contains("Invalid nudb_block_size value: invalid"));
+        }
+    }
+}
+
+TEST(NuDBFactory, power_of_two_validation)
+{
+    std::vector> const kCASES = {
+        {"4095", false},    // just below minimum
+        {"4096", true},     // minimum valid
+        {"4097", false},    // not power of 2
+        {"8192", true},     // valid power of 2
+        {"8193", false},    // not power of 2
+        {"16384", true},    // valid power of 2
+        {"32768", true},    // maximum valid
+        {"32769", false},   // just above maximum
+        {"65536", false}};  // power of 2 but too large
+
+    for (auto const& [size, shouldWork] : kCASES)
+    {
+        SCOPED_TRACE("size=" + size + " shouldWork=" + (shouldWork ? "true" : "false"));
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), size);
+        test::CaptureSink sink(beast::Severity::Warning);
+        beast::Journal const journal(sink);
+        DummyScheduler scheduler;
+        try
+        {
+            auto backend =
+                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+            EXPECT_TRUE(shouldWork);
+        }
+        catch (std::exception const& e)
+        {
+            // A throw is only expected for sizes that should NOT work; if a
+            // valid size throws, fail here instead of silently matching the
+            // message below (which would mask the regression).
+            EXPECT_FALSE(shouldWork);
+            std::string const what{e.what()};
+            EXPECT_TRUE(what.contains("Invalid nudb_block_size"));
+        }
+    }
+}
+
+TEST(NuDBFactory, both_constructor_variants)
+{
+    TempDir const tempDir;
+    auto const params = makeSection(tempDir.path(), "16384");
+    DummyScheduler scheduler;
+    beast::Journal const journal(TestSink::instance());
+
+    auto backend1 = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+    EXPECT_NE(backend1, nullptr);
+    ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 16384));
+
+    // Test second constructor (with nudb::context)
+    // Note: This would require access to nudb::context, which might not be
+    // easily testable without more complex setup. For now, we test that
+    // the factory can create backends with the first constructor.
+}
+
+TEST(NuDBFactory, configuration_parsing)
+{
+    // basic valid format emits success log
+    {
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), "8192");
+        test::CaptureSink sink(beast::Severity::Info);
+        beast::Journal const journal(sink);
+        DummyScheduler scheduler;
+        [[maybe_unused]] auto backend =
+            Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+        EXPECT_TRUE(sink.messages().contains("Using custom NuDB block size"));
+    }
+
+    // Test whitespace handling separately since lexical_cast behavior may vary
+    std::vector const kWhitespaceFormats = {" 8192", "8192 "};
+    for (auto const& format : kWhitespaceFormats)
+    {
+        SCOPED_TRACE("format='" + format + "'");
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), format);
+        test::CaptureSink sink(beast::Severity::Debug);
+        beast::Journal const journal(sink);
+        DummyScheduler scheduler;
+        EXPECT_ANY_THROW(Manager::instance().makeBackend(params, megabytes(4), scheduler, journal));
+    }
+}
+
+TEST(NuDBFactory, data_persistence)
+{
+    std::vector const kBlockSizes = {"4096", "8192", "16384", "32768"};
+    for (auto const& size : kBlockSizes)
+    {
+        SCOPED_TRACE("size=" + size);
+        TempDir const tempDir;
+        auto const params = makeSection(tempDir.path(), size);
+        DummyScheduler scheduler;
+        beast::Journal const journal(TestSink::instance());
+
+        // Create test data
+        auto const batch = createPredictableBatch(50, 54321);
+
+        // Store data
+        {
+            auto backend =
+                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+            backend->open();
+            storeBatch(*backend, batch);
+            backend->close();
+        }
+
+        // Retrieve data in new backend instance
+        {
+            auto backend =
+                Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
+            backend->open();
+            auto const copy = fetchCopyOfBatch(*backend, batch);
+            EXPECT_EQ(batch, copy);
+            backend->close();
+        }
+    }
+}
+
+}  // namespace xrpl::node_store
diff --git a/src/tests/libxrpl/nodestore/TestBase.h b/src/tests/libxrpl/nodestore/TestBase.h
new file mode 100644
index 0000000000..5a262ac7bb
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/TestBase.h
@@ -0,0 +1,169 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::node_store {
+
+constexpr std::size_t kMinPayloadBytes = 1;
+constexpr std::size_t kMaxPayloadBytes = 2000;
+constexpr int kNumObjectsToTest = 2000;
+constexpr int kNumObjects = 2000;
+constexpr std::uint64_t kSeedValue = 50;
+
+struct LessThan
+{
+    bool
+    operator()(std::shared_ptr const& lhs, std::shared_ptr const& rhs)
+        const noexcept
+    {
+        return lhs->getHash() < rhs->getHash();
+    }
+};
+
+[[nodiscard]] inline bool
+isSame(std::shared_ptr const& lhs, std::shared_ptr const& rhs)
+{
+    return (lhs->getType() == rhs->getType()) && (lhs->getHash() == rhs->getHash()) &&
+        (lhs->getData() == rhs->getData());
+}
+
+[[nodiscard]] inline Batch
+createPredictableBatch(std::size_t numObjects, std::uint64_t seed)
+{
+    Batch batch;
+    batch.reserve(numObjects);
+
+    beast::xor_shift_engine rng(seed);
+
+    for (auto i = 0uz; i < numObjects; ++i)
+    {
+        NodeObjectType const type = [&] {
+            switch (randInt(rng, 3))
+            {
+                case 0:
+                    return NodeObjectType::Ledger;
+                case 1:
+                    return NodeObjectType::AccountNode;
+                case 2:
+                    return NodeObjectType::TransactionNode;
+                case 3:
+                default:
+                    return NodeObjectType::Unknown;
+            }
+        }();
+
+        uint256 hash;
+        beast::rngfill(hash.begin(), hash.size(), rng);
+
+        Blob blob(randInt(rng, kMinPayloadBytes, kMaxPayloadBytes));
+        beast::rngfill(blob.data(), blob.size(), rng);
+
+        batch.emplace_back(NodeObject::createObject(type, std::move(blob), hash));
+    }
+
+    return batch;
+}
+
+inline void
+storeBatch(Backend& backend, Batch const& batch)
+{
+    for (auto const& obj : batch)
+        backend.store(obj);
+}
+
+[[nodiscard]] inline Batch
+fetchCopyOfBatch(Backend& backend, Batch const& batch)
+{
+    Batch copy;
+    copy.reserve(batch.size());
+
+    for (auto i = 0uz; i < batch.size(); ++i)
+    {
+        SCOPED_TRACE("fetchCopyOfBatch index=" + std::to_string(i));
+        std::shared_ptr object;
+        Status const status = backend.fetch(batch[i]->getHash(), &object);
+        EXPECT_EQ(status, Status::Ok);
+        if (status == Status::Ok)
+        {
+            EXPECT_NE(object, nullptr);
+            copy.emplace_back(object);
+        }
+    }
+    return copy;
+}
+
+inline void
+fetchMissing(Backend& backend, Batch const& batch)
+{
+    for (auto i = 0uz; i < batch.size(); ++i)
+    {
+        SCOPED_TRACE("fetchMissing index=" + std::to_string(i));
+        std::shared_ptr object;
+        Status const status = backend.fetch(batch[i]->getHash(), &object);
+        EXPECT_EQ(status, Status::NotFound);
+    }
+}
+
+inline void
+storeBatch(Database& db, Batch const& batch)
+{
+    for (auto const& obj : batch)
+    {
+        Blob data(obj->getData());
+        db.store(obj->getType(), std::move(data), obj->getHash(), db.earliestLedgerSeq());
+    }
+}
+
+[[nodiscard]] inline Batch
+fetchCopyOfBatch(Database& db, Batch const& batch)
+{
+    Batch copy;
+    copy.reserve(batch.size());
+
+    for (auto const& obj : batch)
+    {
+        std::shared_ptr const result = db.fetchNodeObject(obj->getHash(), 0);
+        if (result != nullptr)
+            copy.emplace_back(result);
+    }
+    return copy;
+}
+
+inline void
+fetchMissing(Database& db, Batch const& batch)
+{
+    for (auto i = 0uz; i < batch.size(); ++i)
+    {
+        SCOPED_TRACE("fetchMissing(Database) index=" + std::to_string(i));
+        EXPECT_EQ(db.fetchNodeObject(batch[i]->getHash(), 0), nullptr);
+    }
+}
+
+}  // namespace xrpl::node_store
+
+namespace xrpl {
+
+[[nodiscard]] inline bool
+operator==(node_store::Batch const& lhs, node_store::Batch const& rhs)
+{
+    return std::ranges::equal(lhs, rhs, node_store::isSame);
+}
+
+}  // namespace xrpl
diff --git a/src/tests/libxrpl/nodestore/Varint.cpp b/src/tests/libxrpl/nodestore/Varint.cpp
new file mode 100644
index 0000000000..3652fd5631
--- /dev/null
+++ b/src/tests/libxrpl/nodestore/Varint.cpp
@@ -0,0 +1,46 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+using namespace xrpl::node_store;
+
+TEST(Varint, encode_decode)
+{
+    static constexpr auto kValues = std::to_array({
+        0,
+        1,
+        2,
+        126,
+        127,
+        128,
+        253,
+        254,
+        255,
+        16127,
+        16128,
+        16129,
+        0xff,
+        0xffff,
+        0xffffffff,
+        0xffffffffffffUL,
+        std::numeric_limits::max(),
+    });
+
+    for (auto const value : kValues)
+    {
+        std::array::kMax> buffer{};
+        auto const bytesWritten = writeVarint(buffer.data(), value);
+        EXPECT_GT(bytesWritten, 0u);
+        EXPECT_EQ(bytesWritten, sizeVarint(value));
+
+        std::size_t decoded = 0;
+        auto const bytesRead = readVarint(buffer.data(), bytesWritten, decoded);
+        EXPECT_EQ(bytesRead, bytesWritten);
+        EXPECT_EQ(value, decoded);
+    }
+}
diff --git a/src/tests/libxrpl/peerfinder/Livecache.cpp b/src/tests/libxrpl/peerfinder/Livecache.cpp
new file mode 100644
index 0000000000..298b09e04e
--- /dev/null
+++ b/src/tests/libxrpl/peerfinder/Livecache.cpp
@@ -0,0 +1,294 @@
+#include 
+
+#include 
+#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::peer_finder {
+namespace {
+
+class LivecacheTest : public ::testing::Test
+{
+protected:
+    static beast::Journal
+    journal()
+    {
+        return beast::Journal{TestSink::instance()};
+    }
+
+    static beast::ip::Endpoint
+    endpoint(std::uint16_t index, bool v4 = true)
+    {
+        auto const port = static_cast(10000 + index);
+
+        if (v4)
+        {
+            auto bytes = beast::ip::AddressV4::bytes_type{
+                {54,
+                 static_cast((index / 256) % 256),
+                 static_cast(index % 256),
+                 1}};
+            return beast::ip::Endpoint{beast::ip::Address{beast::ip::AddressV4{bytes}}, port};
+        }
+
+        auto bytes = beast::ip::AddressV6::bytes_type{
+            {0x20,
+             0x01,
+             0x0d,
+             0xb8,
+             0,
+             0,
+             0,
+             0,
+             0,
+             0,
+             0,
+             0,
+             0,
+             static_cast((index / 256) % 256),
+             static_cast(index % 256),
+             1}};
+        return beast::ip::Endpoint{beast::ip::Address{beast::ip::AddressV6{bytes}}, port};
+    }
+
+    void
+    addEndpoint(beast::ip::Endpoint const& ep, std::uint32_t hops = 0)
+    {
+        cache_.insert(Endpoint{ep, hops});
+    }
+
+    TestStopwatch clock_;
+    Livecache<> cache_{clock_, journal()};
+};
+
+}  // namespace
+
+TEST_F(LivecacheTest, basic_insert)
+{
+    EXPECT_TRUE(cache_.empty());
+
+    for (auto i = 0; i < 10; ++i)
+        addEndpoint(endpoint(i, true));
+
+    EXPECT_FALSE(cache_.empty());
+    EXPECT_EQ(cache_.size(), 10u);
+
+    for (auto i = 10; i < 20; ++i)
+        addEndpoint(endpoint(i, false));
+
+    EXPECT_FALSE(cache_.empty());
+    EXPECT_EQ(cache_.size(), 20u);
+}
+
+TEST_F(LivecacheTest, insert_update_keeps_lowest_hop_count)
+{
+    auto const ep1 = Endpoint{endpoint(1), 2};
+    cache_.insert(ep1);
+    ASSERT_EQ(cache_.size(), 1u);
+    EXPECT_EQ((cache_.hops.begin() + 2)->begin()->hops, 2u);
+
+    auto const ep2 = Endpoint{ep1.address, 4};
+    cache_.insert(ep2);
+    EXPECT_EQ(cache_.size(), 1u);
+    EXPECT_EQ((cache_.hops.begin() + 2)->begin()->hops, 2u);
+
+    auto const ep3 = Endpoint{ep1.address, 2};
+    cache_.insert(ep3);
+    EXPECT_EQ(cache_.size(), 1u);
+    EXPECT_EQ((cache_.hops.begin() + 2)->begin()->hops, 2u);
+
+    auto const ep4 = Endpoint{ep1.address, 1};
+    cache_.insert(ep4);
+    EXPECT_EQ(cache_.size(), 1u);
+    EXPECT_EQ((cache_.hops.begin() + 1)->begin()->hops, 1u);
+}
+
+TEST_F(LivecacheTest, hop_iterators_support_const_reverse_and_move_back)
+{
+    auto const ep1 = Endpoint{endpoint(1), 1};
+    auto const ep2 = Endpoint{endpoint(2), 1};
+    cache_.insert(ep1);
+    cache_.insert(ep2);
+
+    auto hop = *(cache_.hops.begin() + 1);
+    ASSERT_NE(hop.begin(), hop.end());
+    ASSERT_NE(hop.cbegin(), hop.cend());
+    ASSERT_NE(hop.rbegin(), hop.rend());
+    ASSERT_NE(hop.crbegin(), hop.crend());
+
+    auto const firstAddress = hop.begin()->address;
+    hop.moveBack(hop.begin());
+    EXPECT_EQ(hop.rbegin()->address, firstAddress);
+
+    auto const& constHops = cache_.hops;
+    EXPECT_NE(constHops.begin(), constHops.end());
+    EXPECT_NE(constHops.cbegin(), constHops.cend());
+    EXPECT_NE(constHops.rbegin(), constHops.rend());
+    EXPECT_NE(constHops.crbegin(), constHops.crend());
+
+    auto const constHop = *(constHops.cbegin() + 1);
+    EXPECT_EQ(std::distance(constHop.begin(), constHop.end()), 2);
+    EXPECT_EQ(std::distance(constHop.cbegin(), constHop.cend()), 2);
+    EXPECT_EQ(std::distance(constHop.rbegin(), constHop.rend()), 2);
+    EXPECT_EQ(std::distance(constHop.crbegin(), constHop.crend()), 2);
+}
+
+TEST_F(LivecacheTest, on_write_reports_entries_and_expiration)
+{
+    cache_.insert(Endpoint{endpoint(1), 1});
+    cache_.insert(Endpoint{endpoint(2), tuning::kMaxHops + 1});
+
+    JsonPropertyStream stream;
+    {
+        beast::PropertyStream::Map map(stream);
+        cache_.onWrite(map);
+    }
+
+    auto const& top = stream.top();
+    EXPECT_EQ(top["size"].asUInt(), 2u);
+    EXPECT_FALSE(top["hist"].asString().empty());
+    ASSERT_TRUE(top.isMember("entries"));
+    ASSERT_EQ(top["entries"].size(), 2u);
+    auto const& entry = top["entries"][json::UInt{0}];
+    EXPECT_TRUE(entry.isMember("hops"));
+    EXPECT_TRUE(entry.isMember("address"));
+    EXPECT_TRUE(entry.isMember("expires"));
+}
+
+TEST_F(LivecacheTest, expire_removes_entries_after_ttl)
+{
+    using namespace std::chrono_literals;
+
+    cache_.insert(Endpoint{endpoint(1), 1});
+    ASSERT_EQ(cache_.size(), 1u);
+
+    cache_.expire();
+    EXPECT_EQ(cache_.size(), 1u);
+
+    clock_.advance(tuning::kLiveCacheSecondsToLive - 1s);
+    cache_.expire();
+    EXPECT_EQ(cache_.size(), 1u);
+
+    clock_.advance(1s);
+    cache_.expire();
+    EXPECT_TRUE(cache_.empty());
+}
+
+TEST_F(LivecacheTest, expire_removes_multiple_entries_after_ttl)
+{
+    using namespace std::chrono_literals;
+
+    cache_.insert(Endpoint{endpoint(1), 1});
+    cache_.insert(Endpoint{endpoint(2), 2});
+
+    clock_.advance(tuning::kLiveCacheSecondsToLive);
+    cache_.expire();
+    EXPECT_TRUE(cache_.empty());
+}
+
+TEST_F(LivecacheTest, histogram_counts_all_entries)
+{
+    constexpr auto kNumEndpoints = 40;
+
+    for (auto i = 0; i < kNumEndpoints; ++i)
+    {
+        addEndpoint(endpoint(static_cast(i)), xrpl::randInt());
+    }
+
+    auto const histogram = cache_.hops.histogram();
+    ASSERT_FALSE(histogram.empty());
+
+    std::vector values;
+    boost::split(values, histogram, boost::algorithm::is_any_of(","));
+
+    auto sum = 0;
+    for (auto const& value : values)
+    {
+        auto const count = boost::lexical_cast(boost::trim_copy(value));
+        sum += count;
+        EXPECT_GE(count, 0);
+    }
+    EXPECT_EQ(sum, kNumEndpoints);
+}
+
+TEST_F(LivecacheTest, shuffle_preserves_bucket_contents)
+{
+    for (auto i = 0; i < 100; ++i)
+    {
+        addEndpoint(endpoint(static_cast(i)), xrpl::randInt(tuning::kMaxHops + 1));
+    }
+
+    using AtHop = std::vector;
+    using AllHops = std::array;
+
+    auto const compareEndpoint = [](Endpoint const& lhs, Endpoint const& rhs) {
+        return rhs.hops < lhs.hops || (rhs.hops == lhs.hops && rhs.address < lhs.address);
+    };
+    auto const sameEndpoint = [](Endpoint const& lhs, Endpoint const& rhs) {
+        return lhs.hops == rhs.hops && lhs.address == rhs.address;
+    };
+    auto const sameEndpoints =
+        [&sameEndpoint](std::vector const& lhs, std::vector const& rhs) {
+            return lhs.size() == rhs.size() &&
+                std::equal(lhs.begin(), lhs.end(), rhs.begin(), sameEndpoint);
+        };
+
+    AllHops before;
+    AllHops beforeSorted;
+    for (auto i = std::make_pair(0, cache_.hops.begin()); i.second != cache_.hops.end();
+         ++i.first, ++i.second)
+    {
+        std::ranges::copy(*i.second, std::back_inserter(before[i.first]));
+        std::ranges::copy(*i.second, std::back_inserter(beforeSorted[i.first]));
+        std::ranges::sort(beforeSorted[i.first], compareEndpoint);
+    }
+
+    cache_.hops.shuffle();
+
+    AllHops after;
+    AllHops afterSorted;
+    for (auto i = std::make_pair(0, cache_.hops.begin()); i.second != cache_.hops.end();
+         ++i.first, ++i.second)
+    {
+        std::ranges::copy(*i.second, std::back_inserter(after[i.first]));
+        std::ranges::copy(*i.second, std::back_inserter(afterSorted[i.first]));
+        std::ranges::sort(afterSorted[i.first], compareEndpoint);
+    }
+
+    auto allBucketsKeptOriginalOrder = true;
+    for (auto i = 0u; i < before.size(); ++i)
+    {
+        EXPECT_EQ(before[i].size(), after[i].size());
+        allBucketsKeptOriginalOrder =
+            allBucketsKeptOriginalOrder && sameEndpoints(before[i], after[i]);
+        EXPECT_TRUE(sameEndpoints(beforeSorted[i], afterSorted[i]));
+    }
+    EXPECT_FALSE(allBucketsKeptOriginalOrder);
+}
+
+}  // namespace xrpl::peer_finder
diff --git a/src/tests/libxrpl/peerfinder/PeerFinder.cpp b/src/tests/libxrpl/peerfinder/PeerFinder.cpp
new file mode 100644
index 0000000000..3a52bbb5aa
--- /dev/null
+++ b/src/tests/libxrpl/peerfinder/PeerFinder.cpp
@@ -0,0 +1,1270 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#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::peer_finder {
+namespace {
+
+using ::testing::_;
+using ::testing::NiceMock;
+using ::testing::Return;
+
+beast::Journal
+journal()
+{
+    return beast::Journal{TestSink::instance()};
+}
+
+beast::ip::Endpoint
+endpoint(std::string const& value)
+{
+    return beast::ip::Endpoint::fromString(value);
+}
+
+class MockStore : public Store
+{
+public:
+    MOCK_METHOD(std::size_t, load, (Store::load_callback const& cb), (override));
+    MOCK_METHOD(void, save, (std::vector const& entries), (override));
+};
+
+class CapturingStore : public Store
+{
+public:
+    std::vector entriesToLoad;
+    std::vector> saves;
+
+    std::size_t
+    load(Store::load_callback const& cb) override
+    {
+        for (auto const& entry : entriesToLoad)
+            cb(entry.endpoint, entry.valence);
+        return entriesToLoad.size();
+    }
+
+    void
+    save(std::vector const& entries) override
+    {
+        saves.push_back(entries);
+    }
+};
+
+Store::Entry
+storeEntry(beast::ip::Endpoint const& endpoint, int valence)
+{
+    Store::Entry entry;
+    entry.endpoint = endpoint;
+    entry.valence = valence;
+    return entry;
+}
+
+void
+allowEmptyStore(MockStore& store)
+{
+    ON_CALL(store, load(_)).WillByDefault(Return(0));
+    ON_CALL(store, save(_)).WillByDefault([](std::vector const&) {});
+}
+
+class MockChecker
+{
+public:
+    MOCK_METHOD(void, stop, ());
+    MOCK_METHOD(void, wait, ());
+    MOCK_METHOD(void, recordAsyncConnect, (beast::ip::Endpoint const& ep));
+
+    boost::system::error_code nextError;
+    bool completeAsync = true;
+    std::vector asyncConnects;
+
+    template 
+    void
+    asyncConnect(beast::ip::Endpoint const& ep, Handler&& handler)
+    {
+        asyncConnects.push_back(ep);
+        recordAsyncConnect(ep);
+        if (completeAsync)
+            std::forward(handler)(nextError);
+    }
+};
+
+class TestSource : public Source
+{
+public:
+    explicit TestSource(std::string name) : name_(std::move(name))
+    {
+    }
+
+    std::string const&
+    name() override
+    {
+        return name_;
+    }
+
+    void
+    cancel() override
+    {
+        ++cancelCount;
+    }
+
+    void
+    fetch(Results& results, beast::Journal) override
+    {
+        ++fetchCount;
+        results = resultsToFetch;
+    }
+
+    Results resultsToFetch;
+    int fetchCount = 0;
+    int cancelCount = 0;
+
+private:
+    std::string name_;
+};
+
+class DefaultCancelSource : public Source
+{
+public:
+    std::string const&
+    name() override
+    {
+        return name_;
+    }
+
+    void
+    fetch(Results& results, beast::Journal) override
+    {
+        results = resultsToFetch;
+    }
+
+    Results resultsToFetch;
+
+private:
+    std::string name_{"default"};
+};
+
+class PeerFinderTest : public ::testing::Test
+{
+public:
+    PeerFinderTest()
+    {
+        allowEmptyStore(store_);
+    }
+
+protected:
+    void
+    configure(std::size_t ipLimit = 2)
+    {
+        Config config;
+        config.autoConnect = false;
+        config.listeningPort = 1024;
+        config.ipLimit = static_cast(ipLimit);
+        logic_.config(config);
+    }
+
+    NiceMock store_;
+    NiceMock checker_;
+    TestStopwatch clock_;
+    Logic> logic_{clock_, store_, checker_, journal()};
+};
+
+int
+savedValence(std::vector const& entries, beast::ip::Endpoint const& endpoint)
+{
+    for (auto const& entry : entries)
+    {
+        if (entry.endpoint == endpoint)
+            return entry.valence;
+    }
+
+    ADD_FAILURE() << "missing saved endpoint " << endpoint.toString();
+    return 0;
+}
+
+TEST_F(PeerFinderTest, backoff_limits_repeated_connection_attempts)
+{
+    auto constexpr kSECONDS = 10000;
+
+    logic_.addFixedPeer("test", endpoint("65.0.0.1:5"));
+    configure();
+
+    std::size_t attempts = 0;
+    for (std::size_t i = 0; i < kSECONDS; ++i)
+    {
+        auto const list = logic_.autoconnect();
+        if (!list.empty())
+        {
+            ASSERT_EQ(list.size(), 1u);
+            auto const [slot, result] = logic_.newOutboundSlot(list.front());
+            ASSERT_NE(slot, nullptr);
+            ASSERT_EQ(result, Result::Success);
+            EXPECT_TRUE(logic_.onConnected(slot, endpoint("65.0.0.2:5")));
+            logic_.onClosed(slot);
+            ++attempts;
+        }
+        clock_.advance(std::chrono::seconds(1));
+        logic_.oncePerSecond();
+    }
+
+    EXPECT_LT(attempts, 20u);
+}
+
+TEST_F(PeerFinderTest, activated_peer_backoff_allows_at_most_one_attempt_per_minute)
+{
+    auto constexpr kSECONDS = 10000;
+
+    logic_.addFixedPeer("test", endpoint("65.0.0.1:5"));
+    configure();
+
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+
+    std::size_t attempts = 0;
+    for (std::size_t i = 0; i < kSECONDS; ++i)
+    {
+        auto const list = logic_.autoconnect();
+        if (!list.empty())
+        {
+            ASSERT_EQ(list.size(), 1u);
+            auto const [slot, result] = logic_.newOutboundSlot(list.front());
+            ASSERT_NE(slot, nullptr);
+            ASSERT_EQ(result, Result::Success);
+            ASSERT_TRUE(logic_.onConnected(slot, endpoint("65.0.0.2:5")));
+            ASSERT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+            logic_.onClosed(slot);
+            ++attempts;
+        }
+        clock_.advance(std::chrono::seconds(1));
+        logic_.oncePerSecond();
+    }
+
+    EXPECT_LE(attempts, (kSECONDS + 59u) / 60u);
+}
+
+TEST_F(PeerFinderTest, duplicate_inbound_slot_is_rejected_for_existing_outbound_slot)
+{
+    configure();
+
+    auto const remote = endpoint("65.0.0.1:5");
+    auto const [slot1, result1] = logic_.newOutboundSlot(remote);
+    ASSERT_NE(slot1, nullptr);
+    EXPECT_EQ(result1, Result::Success);
+    EXPECT_EQ(logic_.connectedAddresses.count(remote.address()), 1u);
+
+    auto const local = endpoint("65.0.0.2:1024");
+    auto const [slot2, result2] = logic_.newInboundSlot(local, remote);
+    EXPECT_EQ(logic_.connectedAddresses.count(remote.address()), 1u);
+    EXPECT_EQ(result2, Result::DuplicatePeer);
+    EXPECT_EQ(slot2, nullptr);
+
+    if (slot2)
+        logic_.onClosed(slot2);
+    logic_.onClosed(slot1);
+}
+
+TEST_F(PeerFinderTest, duplicate_outbound_slot_is_rejected_for_existing_inbound_slot)
+{
+    configure();
+
+    auto const remote = endpoint("65.0.0.1:5");
+    auto const local = endpoint("65.0.0.2:1024");
+
+    auto const [slot1, result1] = logic_.newInboundSlot(local, remote);
+    ASSERT_NE(slot1, nullptr);
+    EXPECT_EQ(result1, Result::Success);
+    EXPECT_EQ(logic_.connectedAddresses.count(remote.address()), 1u);
+
+    auto const [slot2, result2] = logic_.newOutboundSlot(remote);
+    EXPECT_EQ(result2, Result::DuplicatePeer);
+    EXPECT_EQ(logic_.connectedAddresses.count(remote.address()), 1u);
+    EXPECT_EQ(slot2, nullptr);
+
+    if (slot2)
+        logic_.onClosed(slot2);
+    logic_.onClosed(slot1);
+}
+
+TEST_F(PeerFinderTest, peer_limit_exceeded_rejects_additional_inbound_slot)
+{
+    configure();
+
+    auto const local = endpoint("65.0.0.2:1024");
+    auto const [slot, result] = logic_.newInboundSlot(local, endpoint("55.104.0.2:1025"));
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+
+    auto const [slot1, result1] = logic_.newInboundSlot(local, endpoint("55.104.0.2:1026"));
+    ASSERT_NE(slot1, nullptr);
+    EXPECT_EQ(result1, Result::Success);
+
+    auto const [slot2, result2] = logic_.newInboundSlot(local, endpoint("55.104.0.2:1027"));
+    EXPECT_EQ(result2, Result::IpLimitExceeded);
+    EXPECT_EQ(slot2, nullptr);
+
+    if (slot2)
+        logic_.onClosed(slot2);
+    logic_.onClosed(slot1);
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, activate_rejects_duplicate_public_key)
+{
+    configure();
+
+    auto const local = endpoint("65.0.0.2:1024");
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+
+    auto const [slot, result] = logic_.newOutboundSlot(endpoint("55.104.0.2:1025"));
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+
+    auto const [slot2, result2] = logic_.newOutboundSlot(endpoint("55.104.0.2:1026"));
+    ASSERT_NE(slot2, nullptr);
+    EXPECT_EQ(result2, Result::Success);
+
+    EXPECT_TRUE(logic_.onConnected(slot, local));
+    EXPECT_TRUE(logic_.onConnected(slot2, local));
+
+    EXPECT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+    EXPECT_EQ(logic_.activate(slot2, publicKey, false), Result::DuplicatePeer);
+
+    logic_.onClosed(slot);
+
+    EXPECT_EQ(logic_.activate(slot2, publicKey, false), Result::Success);
+    logic_.onClosed(slot2);
+}
+
+TEST_F(PeerFinderTest, activate_rejects_inbound_when_inbound_connections_are_disabled)
+{
+    configure();
+
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+    auto const local = endpoint("65.0.0.2:1024");
+
+    auto const [slot, result] = logic_.newInboundSlot(local, endpoint("55.104.0.2:1025"));
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+
+    EXPECT_EQ(logic_.activate(slot, publicKey, false), Result::InboundDisabled);
+
+    {
+        Config config;
+        config.autoConnect = false;
+        config.listeningPort = 1024;
+        config.ipLimit = 2;
+        config.inPeers = 1;
+        logic_.config(config);
+    }
+
+    EXPECT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+
+    auto const [slot2, result2] = logic_.newInboundSlot(local, endpoint("55.104.0.2:1026"));
+    ASSERT_NE(slot2, nullptr);
+    EXPECT_EQ(result2, Result::Success);
+
+    PublicKey const publicKey2(randomKeyPair(KeyType::Secp256k1).first);
+    EXPECT_EQ(logic_.activate(slot2, publicKey2, false), Result::Full);
+
+    logic_.onClosed(slot2);
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, add_fixed_peer_rejects_endpoint_without_port)
+{
+    EXPECT_THROW(logic_.addFixedPeer("test", endpoint("65.0.0.2")), std::runtime_error);
+}
+
+TEST_F(PeerFinderTest, on_connected_rejects_self_connection)
+{
+    auto const local = endpoint("65.0.0.2:1234");
+    auto const [slot, result] = logic_.newOutboundSlot(local);
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+
+    EXPECT_FALSE(logic_.onConnected(slot, local));
+    logic_.onClosed(slot);
+}
+
+TEST(PeerFinderResult, converts_all_result_values_to_strings)
+{
+    EXPECT_EQ(to_string(Result::InboundDisabled), "inbound disabled");
+    EXPECT_EQ(to_string(Result::DuplicatePeer), "peer already connected");
+    EXPECT_EQ(to_string(Result::IpLimitExceeded), "ip limit exceeded");
+    EXPECT_EQ(to_string(Result::Full), "slots full");
+    EXPECT_EQ(to_string(Result::Success), "success");
+    EXPECT_EQ(to_string(static_cast(-1)), "unknown");
+}
+
+TEST(PeerFinderEndpoint, orders_by_address)
+{
+    Endpoint const high{endpoint("65.0.0.2:10002"), 1};
+    Endpoint const low{endpoint("65.0.0.1:10001"), 2};
+    std::vector endpoints{high, low};
+
+    std::ranges::sort(
+        endpoints, [](Endpoint const& lhs, Endpoint const& rhs) { return lhs < rhs; });
+
+    EXPECT_EQ(endpoints.front().address, low.address);
+    EXPECT_EQ(endpoints.back().address, high.address);
+}
+
+TEST(PeerFinderCounts, tracks_slot_states_and_capacity)
+{
+    TestStopwatch clock;
+    Counts counts;
+    Config config;
+    config.outPeers = 1;
+    config.inPeers = 1;
+    config.wantIncoming = true;
+    counts.onConfig(config);
+
+    EXPECT_EQ(counts.outMax(), 1);
+    EXPECT_EQ(counts.inMax(), 1);
+    EXPECT_EQ(counts.inboundSlotsFree(), 1);
+    EXPECT_EQ(counts.outboundSlotsFree(), 1);
+    EXPECT_EQ(counts.totalActive(), 0);
+    EXPECT_FALSE(counts.isConnectedToNetwork());
+    EXPECT_EQ(counts.attemptsNeeded(), tuning::kMaxConnectAttempts);
+    EXPECT_EQ(counts.stateString(), "0/1 out, 0/1 in, 0 connecting, 0 closing");
+
+    SlotImp inbound(endpoint("65.0.0.1:10001"), endpoint("65.0.0.2:10002"), false, clock);
+    counts.add(inbound);
+    EXPECT_EQ(counts.acceptCount(), 1);
+    EXPECT_TRUE(counts.canActivate(inbound));
+    counts.remove(inbound);
+    EXPECT_EQ(counts.acceptCount(), 0);
+
+    inbound.activate(clock.now());
+    counts.add(inbound);
+    EXPECT_EQ(counts.inboundActive(), 1);
+    EXPECT_EQ(counts.totalActive(), 1);
+    EXPECT_EQ(counts.inboundSlotsFree(), 0);
+
+    SlotImp const extraInbound(
+        endpoint("65.0.0.3:10003"), endpoint("65.0.0.4:10004"), false, clock);
+    EXPECT_FALSE(counts.canActivate(extraInbound));
+    counts.remove(inbound);
+
+    SlotImp outbound(endpoint("65.0.0.5:10005"), false, clock);
+    counts.add(outbound);
+    EXPECT_EQ(counts.attempts(), 1);
+    EXPECT_EQ(counts.connectCount(), 1);
+    EXPECT_EQ(counts.attemptsNeeded(), tuning::kMaxConnectAttempts - 1);
+    counts.remove(outbound);
+
+    outbound.state(Slot::State::Connected);
+    EXPECT_TRUE(counts.canActivate(outbound));
+    outbound.activate(clock.now());
+    counts.add(outbound);
+    EXPECT_EQ(counts.outActive(), 1);
+    EXPECT_EQ(counts.outboundSlotsFree(), 0);
+
+    SlotImp extraOutbound(endpoint("65.0.0.6:10006"), false, clock);
+    extraOutbound.state(Slot::State::Connected);
+    EXPECT_FALSE(counts.canActivate(extraOutbound));
+
+    SlotImp fixedOutbound(endpoint("65.0.0.7:10007"), true, clock);
+    fixedOutbound.state(Slot::State::Connected);
+    EXPECT_TRUE(counts.canActivate(fixedOutbound));
+    fixedOutbound.activate(clock.now());
+    counts.add(fixedOutbound);
+    EXPECT_EQ(counts.fixed(), 1u);
+    EXPECT_EQ(counts.fixedActive(), 1u);
+    counts.remove(fixedOutbound);
+
+    SlotImp reservedOutbound(endpoint("65.0.0.8:10008"), false, clock);
+    reservedOutbound.reserved(true);
+    reservedOutbound.state(Slot::State::Connected);
+    EXPECT_TRUE(counts.canActivate(reservedOutbound));
+    reservedOutbound.activate(clock.now());
+    counts.add(reservedOutbound);
+
+    JsonPropertyStream stream;
+    {
+        beast::PropertyStream::Map map(stream);
+        counts.onWrite(map);
+    }
+    EXPECT_TRUE(stream.top().isMember("accept"));
+    EXPECT_TRUE(stream.top().isMember("connect"));
+    EXPECT_TRUE(stream.top().isMember("close"));
+    EXPECT_TRUE(stream.top().isMember("reserved"));
+    EXPECT_TRUE(stream.top().isMember("total"));
+    counts.remove(reservedOutbound);
+    counts.remove(outbound);
+
+    SlotImp closing(endpoint("65.0.0.9:10009"), endpoint("65.0.0.10:10010"), false, clock);
+    closing.state(Slot::State::Closing);
+    counts.add(closing);
+    EXPECT_EQ(counts.closingCount(), 1);
+    counts.remove(closing);
+
+    Counts saturatedAttempts;
+    saturatedAttempts.onConfig(config);
+    std::vector> attempts;
+    for (int i = 0; i < tuning::kMaxConnectAttempts; ++i)
+    {
+        attempts.push_back(
+            std::make_unique(
+                endpoint("65.1.0." + std::to_string(i + 1) + ":" + std::to_string(11000 + i)),
+                false,
+                clock));
+        saturatedAttempts.add(*attempts.back());
+    }
+    EXPECT_EQ(saturatedAttempts.attempts(), tuning::kMaxConnectAttempts);
+    EXPECT_EQ(saturatedAttempts.attemptsNeeded(), 0u);
+
+    Config disconnected;
+    disconnected.outPeers = 0;
+    counts.onConfig(disconnected);
+    EXPECT_TRUE(counts.isConnectedToNetwork());
+}
+
+TEST(PeerFinderHandouts, filters_redirect_slot_and_connect_targets)
+{
+    TestStopwatch clock;
+    auto const remote = endpoint("65.0.0.2:10002");
+    auto const slot = std::make_shared(endpoint("65.0.0.1:10001"), remote, false, clock);
+
+    RedirectHandouts redirects(slot);
+    EXPECT_EQ(redirects.slot(), slot);
+    EXPECT_TRUE(redirects.list().empty());
+    EXPECT_FALSE(redirects.full());
+    EXPECT_FALSE(redirects.tryInsert(Endpoint{endpoint("65.0.0.3:10003"), tuning::kMaxHops + 1}));
+    EXPECT_FALSE(redirects.tryInsert(Endpoint{endpoint("65.0.0.3:10003"), 0}));
+    EXPECT_FALSE(redirects.tryInsert(Endpoint{remote.atPort(12000), 1}));
+    EXPECT_TRUE(redirects.tryInsert(Endpoint{endpoint("65.0.0.3:10003"), 1}));
+    EXPECT_FALSE(redirects.tryInsert(Endpoint{endpoint("65.0.0.3:12000"), 1}));
+    EXPECT_EQ(redirects.list().size(), 1u);
+
+    SlotHandouts slotHandouts(slot);
+    EXPECT_EQ(slotHandouts.slot(), slot);
+    EXPECT_FALSE(slotHandouts.full());
+    EXPECT_FALSE(
+        slotHandouts.tryInsert(Endpoint{endpoint("65.0.0.4:10004"), tuning::kMaxHops + 1}));
+    EXPECT_FALSE(slotHandouts.tryInsert(Endpoint{remote.atPort(12001), 1}));
+
+    auto const recent = endpoint("65.0.0.5:10005");
+    slot->recent.insert(recent, 2);
+    EXPECT_FALSE(slotHandouts.tryInsert(Endpoint{recent, 2}));
+    EXPECT_TRUE(slotHandouts.tryInsert(Endpoint{endpoint("65.0.0.6:10006"), 2}));
+    EXPECT_FALSE(slotHandouts.tryInsert(Endpoint{endpoint("65.0.0.6:12000"), 2}));
+    slotHandouts.insert(Endpoint{endpoint("65.0.0.7:10007"), 1});
+    EXPECT_EQ(slotHandouts.list().size(), 2u);
+
+    ConnectHandouts::Squelches squelches(clock);
+    ConnectHandouts connects(2, squelches);
+    EXPECT_TRUE(connects.empty());
+    EXPECT_TRUE(connects.tryInsert(endpoint("65.0.0.8:10008")));
+    EXPECT_FALSE(connects.empty());
+    EXPECT_FALSE(connects.tryInsert(endpoint("65.0.0.8:12000")));
+    EXPECT_TRUE(connects.tryInsert(Endpoint{endpoint("65.0.0.9:10009"), 1}));
+    EXPECT_TRUE(connects.full());
+    EXPECT_FALSE(connects.tryInsert(endpoint("65.0.0.10:10010")));
+    EXPECT_EQ(connects.list().size(), 2u);
+
+    ConnectHandouts squelched(1, squelches);
+    EXPECT_FALSE(squelched.tryInsert(endpoint("65.0.0.9:12000")));
+}
+
+TEST(PeerFinderHandouts, distributes_livecache_entries)
+{
+    TestStopwatch clock;
+    Livecache<> cache(clock, journal());
+    cache.insert(Endpoint{endpoint("65.0.0.10:10010"), 1});
+    cache.insert(Endpoint{endpoint("65.0.0.11:10011"), 2});
+
+    auto const slot1 = std::make_shared(
+        endpoint("65.0.0.1:10001"), endpoint("65.0.0.2:10002"), false, clock);
+    auto const slot2 = std::make_shared(
+        endpoint("65.0.0.3:10003"), endpoint("65.0.0.4:10004"), false, clock);
+    std::vector targets;
+    targets.emplace_back(slot1);
+    targets.emplace_back(slot2);
+
+    handout(targets.begin(), targets.end(), cache.hops.begin(), cache.hops.end());
+
+    EXPECT_FALSE(targets.front().list().empty());
+    EXPECT_FALSE(targets.back().list().empty());
+
+    for (std::uint32_t i = 0; i < tuning::kNumberOfEndpoints; ++i)
+        targets.front().insert(Endpoint{endpoint("65.1.0." + std::to_string(i + 1) + ":12000"), 1});
+
+    handout(targets.begin(), targets.begin() + 1, cache.hops.begin(), cache.hops.end());
+    EXPECT_TRUE(targets.front().full());
+}
+
+TEST_F(PeerFinderTest, preprocess_filters_invalid_duplicate_and_extra_self_endpoints)
+{
+    auto const local = endpoint("65.0.0.1:10001");
+    auto const remote = endpoint("65.0.0.2:10002");
+    auto const slot = std::make_shared(local, remote, false, clock_);
+    Endpoints endpoints{
+        Endpoint{endpoint("65.0.0.3:10003"), tuning::kMaxHops + 1},
+        Endpoint{endpoint("0.0.0.0:2459"), 0},
+        Endpoint{endpoint("0.0.0.0:2460"), 0},
+        Endpoint{endpoint("10.0.0.1:10004"), 1},
+        Endpoint{endpoint("65.0.0.5"), 1},
+        Endpoint{endpoint("65.0.0.6:10006"), 1},
+        Endpoint{endpoint("65.0.0.6:10006"), 2}};
+
+    logic_.preprocess(slot, endpoints);
+
+    ASSERT_EQ(endpoints.size(), 2u);
+    EXPECT_EQ(endpoints.front().address, remote.atPort(2459));
+    EXPECT_EQ(endpoints.front().hops, 1u);
+    EXPECT_EQ(endpoints.back().address, endpoint("65.0.0.6:10006"));
+    EXPECT_EQ(endpoints.back().hops, 2u);
+}
+
+TEST_F(PeerFinderTest, on_endpoints_checks_neighbor_before_caching_it)
+{
+    Config config;
+    config.autoConnect = false;
+    config.listeningPort = 1024;
+    config.ipLimit = 2;
+    config.inPeers = 1;
+    logic_.config(config);
+
+    auto const local = endpoint("65.0.0.1:10001");
+    auto const remote = endpoint("55.104.0.2:1025");
+    auto const [slot, result] = logic_.newInboundSlot(local, remote);
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+    ASSERT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+
+    Endpoints const advertised{Endpoint{endpoint("0.0.0.0:2459"), 0}};
+    logic_.onEndpoints(slot, advertised);
+
+    ASSERT_EQ(checker_.asyncConnects.size(), 1u);
+    EXPECT_EQ(checker_.asyncConnects.front(), remote.atPort(2459));
+    EXPECT_EQ(slot->listeningPort(), std::optional{2459});
+    EXPECT_TRUE(slot->checked);
+    EXPECT_TRUE(slot->canAccept);
+    EXPECT_TRUE(logic_.livecache.empty());
+
+    clock_.advance(tuning::kSecondsPerMessage);
+    logic_.onEndpoints(slot, advertised);
+    EXPECT_EQ(logic_.livecache.size(), 1u);
+    EXPECT_EQ(logic_.bootcache.size(), 1u);
+
+    logic_.onEndpoints(slot, Endpoints{Endpoint{endpoint("65.0.0.9:10009"), 1}});
+    EXPECT_EQ(logic_.livecache.size(), 1u);
+
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, on_endpoints_skips_failed_neighbor_connectivity_checks)
+{
+    Config config;
+    config.autoConnect = false;
+    config.listeningPort = 1024;
+    config.ipLimit = 2;
+    config.inPeers = 1;
+    logic_.config(config);
+
+    checker_.nextError = boost::asio::error::host_unreachable;
+    auto const local = endpoint("65.0.0.1:10001");
+    auto const remote = endpoint("55.104.0.3:1025");
+    auto const [slot, result] = logic_.newInboundSlot(local, remote);
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+    ASSERT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+
+    Endpoints const advertised{Endpoint{endpoint("0.0.0.0:2459"), 0}};
+    logic_.onEndpoints(slot, advertised);
+    EXPECT_TRUE(slot->checked);
+    EXPECT_FALSE(slot->canAccept);
+
+    clock_.advance(tuning::kSecondsPerMessage);
+    logic_.onEndpoints(slot, advertised);
+    EXPECT_TRUE(logic_.livecache.empty());
+
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, on_endpoints_waits_for_pending_connectivity_check)
+{
+    Config config;
+    config.autoConnect = false;
+    config.listeningPort = 1024;
+    config.ipLimit = 2;
+    config.inPeers = 1;
+    logic_.config(config);
+
+    checker_.completeAsync = false;
+    auto const local = endpoint("65.0.0.1:10001");
+    auto const remote = endpoint("55.104.0.4:1025");
+    auto const [slot, result] = logic_.newInboundSlot(local, remote);
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+    ASSERT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+
+    Endpoints const advertised{Endpoint{endpoint("0.0.0.0:2459"), 0}};
+    logic_.onEndpoints(slot, advertised);
+    EXPECT_TRUE(slot->connectivityCheckInProgress);
+
+    clock_.advance(tuning::kSecondsPerMessage);
+    logic_.onEndpoints(slot, advertised);
+    EXPECT_EQ(checker_.asyncConnects.size(), 1u);
+    EXPECT_TRUE(logic_.livecache.empty());
+
+    checker_.completeAsync = true;
+    logic_.checkComplete(remote, remote.atPort(2459), boost::asio::error::operation_aborted);
+    slot->connectivityCheckInProgress = false;
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, builds_endpoint_messages_and_redirects_from_livecache)
+{
+    Config config;
+    config.autoConnect = false;
+    config.wantIncoming = true;
+    config.listeningPort = 2459;
+    config.inPeers = 2;
+    config.outPeers = 2;
+    config.ipLimit = 2;
+    logic_.config(config);
+
+    auto const remote = endpoint("55.104.0.5:1025");
+    auto const live = endpoint("65.0.0.10:10010");
+    logic_.livecache.insert(Endpoint{live, 1});
+
+    auto const [slot, result] = logic_.newOutboundSlot(remote);
+    ASSERT_NE(slot, nullptr);
+    EXPECT_EQ(result, Result::Success);
+    ASSERT_TRUE(logic_.onConnected(slot, endpoint("65.0.0.1:10001")));
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+    ASSERT_EQ(logic_.activate(slot, publicKey, false), Result::Success);
+
+    auto const messages = logic_.buildEndpointsForPeers();
+    ASSERT_EQ(messages.size(), 1u);
+    auto const& sent = messages.front().second;
+    EXPECT_TRUE(std::ranges::any_of(sent, [](Endpoint const& ep) { return ep.hops == 0; }));
+    EXPECT_TRUE(
+        std::ranges::any_of(sent, [&live](Endpoint const& ep) { return ep.address == live; }));
+    EXPECT_TRUE(logic_.buildEndpointsForPeers().empty());
+
+    auto const redirects = logic_.redirect(slot);
+    EXPECT_FALSE(redirects.empty());
+
+    logic_.onClosed(slot);
+}
+
+TEST_F(PeerFinderTest, autoconnect_uses_livecache_then_bootcache)
+{
+    Config config;
+    config.autoConnect = true;
+    config.wantIncoming = false;
+    config.outPeers = 1;
+    config.inPeers = 0;
+    config.ipLimit = 1;
+    logic_.config(config);
+
+    auto const live = endpoint("65.0.0.11:10011");
+    logic_.livecache.insert(Endpoint{live, 1});
+    auto const liveAddresses = logic_.autoconnect();
+    ASSERT_EQ(liveAddresses.size(), 1u);
+    EXPECT_EQ(liveAddresses.front(), live);
+
+    auto const boot = endpoint("65.0.0.12:10012");
+    EXPECT_TRUE(logic_.bootcache.insertStatic(boot));
+    auto const bootAddresses = logic_.autoconnect();
+    ASSERT_EQ(bootAddresses.size(), 1u);
+    EXPECT_EQ(bootAddresses.front(), boot);
+}
+
+TEST_F(PeerFinderTest, sources_redirects_status_and_validation_paths_are_exercised)
+{
+    auto const source = std::make_shared("static");
+    source->resultsToFetch.addresses = {endpoint("65.0.0.13:10013")};
+    logic_.addStaticSource(source);
+    EXPECT_EQ(source->fetchCount, 1);
+    EXPECT_EQ(logic_.bootcache.size(), 1u);
+
+    auto const failing = std::make_shared("failing");
+    failing->resultsToFetch.error = boost::asio::error::host_unreachable;
+    logic_.fetch(failing);
+    EXPECT_EQ(failing->fetchCount, 1);
+
+    auto const dynamic = std::make_shared("dynamic");
+    logic_.addSource(dynamic);
+    ASSERT_EQ(logic_.sources.size(), 1u);
+    EXPECT_EQ(logic_.sources.front(), dynamic);
+
+    std::vector redirects{
+        {boost::asio::ip::make_address("65.0.0.14"), 10014},
+        {boost::asio::ip::make_address("65.0.0.15"), 10015}};
+    logic_.onRedirects(redirects.begin(), redirects.end(), redirects.front());
+    EXPECT_EQ(logic_.bootcache.size(), 3u);
+
+    EXPECT_FALSE(logic_.isValidAddress(endpoint("0.0.0.0:10016")));
+    EXPECT_FALSE(logic_.isValidAddress(endpoint("10.0.0.1:10017")));
+    EXPECT_FALSE(logic_.isValidAddress(endpoint("65.0.0.16")));
+    EXPECT_TRUE(logic_.isValidAddress(endpoint("65.0.0.16:10016")));
+
+    JsonPropertyStream stream;
+    {
+        beast::PropertyStream::Map map(stream);
+        logic_.onWrite(map);
+    }
+    EXPECT_TRUE(stream.top().isMember("peers"));
+    EXPECT_TRUE(stream.top().isMember("counts"));
+    EXPECT_TRUE(stream.top().isMember("config"));
+    EXPECT_TRUE(stream.top().isMember("livecache"));
+    EXPECT_TRUE(stream.top().isMember("bootcache"));
+
+    DefaultCancelSource defaultCancel;
+    Source::Results results;
+    EXPECT_TRUE(results.addresses.empty());
+    defaultCancel.cancel();
+    defaultCancel.fetch(results, journal());
+
+    logic_.fetchSource = dynamic;
+    logic_.stop();
+    EXPECT_TRUE(logic_.stopping);
+    EXPECT_EQ(dynamic->cancelCount, 1);
+
+    auto const ignored = std::make_shared("ignored");
+    logic_.fetch(ignored);
+    EXPECT_EQ(ignored->fetchCount, 0);
+
+    logic_.checkComplete(
+        endpoint("65.0.0.18:10018"), endpoint("65.0.0.19:10019"), boost::system::error_code{});
+}
+
+TEST(PeerFinderBootcache, loads_unique_entries_and_clears_cache)
+{
+    CapturingStore store;
+    TestStopwatch clock;
+    auto const ep1 = endpoint("65.0.0.1:10001");
+    auto const ep2 = endpoint("65.0.0.2:10002");
+    store.entriesToLoad = {storeEntry(ep1, 3), storeEntry(ep2, -2), storeEntry(ep1, 4)};
+
+    Bootcache cache(store, clock, journal());
+    cache.load();
+
+    EXPECT_FALSE(cache.empty());
+    EXPECT_EQ(cache.size(), 2u);
+    EXPECT_EQ(*cache.begin(), ep1);
+    EXPECT_EQ(*cache.cbegin(), ep1);
+    EXPECT_NE(cache.begin(), cache.end());
+    EXPECT_NE(cache.cbegin(), cache.cend());
+
+    cache.clear();
+    EXPECT_TRUE(cache.empty());
+    EXPECT_EQ(cache.begin(), cache.end());
+}
+
+TEST(PeerFinderBootcache, records_connection_outcomes_and_persists_pending_updates)
+{
+    CapturingStore store;
+    TestStopwatch clock;
+    auto const ep1 = endpoint("65.0.0.1:10001");
+    auto const ep2 = endpoint("65.0.0.2:10002");
+    auto const ep3 = endpoint("65.0.0.3:10003");
+    auto const ep4 = endpoint("65.0.0.4:10004");
+
+    {
+        Bootcache cache(store, clock, journal());
+
+        EXPECT_TRUE(cache.insert(ep1));
+        EXPECT_FALSE(cache.insert(ep1));
+
+        cache.onSuccess(ep1);
+        EXPECT_TRUE(cache.insertStatic(ep1));
+        EXPECT_FALSE(cache.insertStatic(ep1));
+
+        EXPECT_TRUE(cache.insertStatic(ep2));
+        cache.onSuccess(ep3);
+        cache.onFailure(ep3);
+        cache.onFailure(ep4);
+
+        EXPECT_EQ(cache.size(), 4u);
+
+        JsonPropertyStream stream;
+        {
+            beast::PropertyStream::Map map(stream);
+            cache.onWrite(map);
+        }
+        EXPECT_TRUE(stream.top().isMember("entries"));
+        EXPECT_EQ(stream.top()["entries"].size(), 4u);
+    }
+
+    ASSERT_EQ(store.saves.size(), 1u);
+    auto const& saved = store.saves.front();
+    ASSERT_EQ(saved.size(), 4u);
+    EXPECT_EQ(savedValence(saved, ep1), Bootcache::kStaticValence);
+    EXPECT_EQ(savedValence(saved, ep2), Bootcache::kStaticValence);
+    EXPECT_EQ(savedValence(saved, ep3), -1);
+    EXPECT_EQ(savedValence(saved, ep4), -1);
+}
+
+TEST(PeerFinderBootcache, periodic_activity_saves_after_cooldown)
+{
+    using namespace std::chrono_literals;
+
+    CapturingStore store;
+    TestStopwatch clock;
+
+    {
+        Bootcache cache(store, clock, journal());
+        EXPECT_TRUE(cache.insert(endpoint("65.0.0.1:10001")));
+
+        cache.periodicActivity();
+        EXPECT_TRUE(store.saves.empty());
+
+        clock.advance(tuning::kBootcacheCooldownTime + 1s);
+        cache.periodicActivity();
+        ASSERT_EQ(store.saves.size(), 1u);
+
+        cache.periodicActivity();
+        EXPECT_EQ(store.saves.size(), 1u);
+    }
+
+    EXPECT_EQ(store.saves.size(), 1u);
+}
+
+TEST(PeerFinderBootcache, prunes_when_cache_exceeds_limit)
+{
+    CapturingStore store;
+    TestStopwatch clock;
+    Bootcache cache(store, clock, journal());
+
+    for (std::uint16_t i = 0; i <= tuning::kBootcacheSize; ++i)
+    {
+        EXPECT_TRUE(cache.insert(endpoint(
+            "65.0." + std::to_string((i / 256) % 256) + "." + std::to_string(i % 256) + ":" +
+            std::to_string(10000 + i))));
+    }
+
+    EXPECT_LE(cache.size(), tuning::kBootcacheSize);
+}
+
+TEST(PeerFinderEndpoint, clamps_hops_to_overflow_bucket)
+{
+    auto const address = endpoint("65.0.0.1:10001");
+    Endpoint const ep(address, tuning::kMaxHops + 10);
+
+    EXPECT_EQ(ep.address, address);
+    EXPECT_EQ(ep.hops, tuning::kMaxHops + 1);
+}
+
+TEST(PeerFinderSlotImp, tracks_state_and_recent_endpoints)
+{
+    using State = Slot::State;
+    using namespace std::chrono_literals;
+
+    TestStopwatch clock;
+    auto const local = endpoint("65.0.0.1:10000");
+    auto const remote = endpoint("65.0.0.2:10001");
+    SlotImp inbound(local, remote, true, clock);
+
+    EXPECT_TRUE(inbound.inbound());
+    EXPECT_TRUE(inbound.fixed());
+    EXPECT_FALSE(inbound.reserved());
+    EXPECT_EQ(inbound.state(), State::Accept);
+    EXPECT_EQ(inbound.remoteEndpoint(), remote);
+    EXPECT_EQ(inbound.localEndpoint(), std::optional{local});
+    EXPECT_FALSE(inbound.publicKey());
+    EXPECT_FALSE(inbound.listeningPort());
+    EXPECT_FALSE(inbound.checked);
+    EXPECT_FALSE(inbound.canAccept);
+    EXPECT_FALSE(inbound.connectivityCheckInProgress);
+
+    auto const newLocal = endpoint("65.0.0.3:10002");
+    auto const newRemote = endpoint("65.0.0.4:10003");
+    PublicKey const publicKey(randomKeyPair(KeyType::Secp256k1).first);
+
+    inbound.localEndpoint(newLocal);
+    inbound.remoteEndpoint(newRemote);
+    inbound.publicKey(publicKey);
+    inbound.reserved(true);
+    inbound.setListeningPort(2459);
+
+    EXPECT_EQ(inbound.localEndpoint(), std::optional{newLocal});
+    EXPECT_EQ(inbound.remoteEndpoint(), newRemote);
+    EXPECT_EQ(inbound.publicKey(), std::optional{publicKey});
+    EXPECT_TRUE(inbound.reserved());
+    EXPECT_EQ(inbound.listeningPort(), std::optional{2459});
+    EXPECT_FALSE(inbound.prefix().empty());
+
+    inbound.state(State::Closing);
+    EXPECT_EQ(inbound.state(), State::Closing);
+
+    SlotImp outbound(remote, false, clock);
+    EXPECT_FALSE(outbound.inbound());
+    EXPECT_FALSE(outbound.fixed());
+    EXPECT_EQ(outbound.state(), State::Connect);
+    EXPECT_TRUE(outbound.checked);
+    EXPECT_TRUE(outbound.canAccept);
+
+    outbound.state(State::Connected);
+    outbound.activate(clock.now());
+    EXPECT_EQ(outbound.state(), State::Active);
+    EXPECT_EQ(outbound.whenAcceptEndpoints, clock.now());
+
+    auto const recent = endpoint("65.0.0.5:10004");
+    EXPECT_FALSE(outbound.recent.filter(recent, 2));
+
+    outbound.recent.insert(recent, 2);
+    EXPECT_TRUE(outbound.recent.filter(recent, 2));
+    EXPECT_TRUE(outbound.recent.filter(recent, 3));
+    EXPECT_FALSE(outbound.recent.filter(recent, 1));
+
+    outbound.recent.insert(recent, 4);
+    EXPECT_FALSE(outbound.recent.filter(recent, 1));
+
+    outbound.recent.insert(recent, 1);
+    EXPECT_TRUE(outbound.recent.filter(recent, 1));
+    EXPECT_FALSE(outbound.recent.filter(recent, 0));
+
+    clock.advance(tuning::kLiveCacheSecondsToLive + 1s);
+    outbound.expire();
+    EXPECT_FALSE(outbound.recent.filter(recent, 1));
+}
+
+TEST(PeerFinderConfig, writes_property_stream_and_compares_verify_endpoints)
+{
+    Config config;
+    config.maxPeers = 42;
+    config.outPeers = 12;
+    config.inPeers = 30;
+    config.peerPrivate = false;
+    config.wantIncoming = true;
+    config.autoConnect = false;
+    config.listeningPort = 2459;
+    config.features = "feature";
+    config.ipLimit = 4;
+    config.verifyEndpoints = false;
+
+    JsonPropertyStream stream;
+    {
+        beast::PropertyStream::Map map(stream);
+        config.onWrite(map);
+    }
+
+    auto const& json = stream.top();
+    EXPECT_EQ(json["max_peers"].asUInt(), config.maxPeers);
+    EXPECT_EQ(json["out_peers"].asUInt(), config.outPeers);
+    EXPECT_TRUE(json.isMember("want_incoming"));
+    EXPECT_TRUE(json.isMember("auto_connect"));
+    EXPECT_EQ(json["port"].asUInt(), config.listeningPort);
+    EXPECT_EQ(json["features"].asString(), config.features);
+    EXPECT_EQ(json["ip_limit"].asInt(), config.ipLimit);
+    EXPECT_TRUE(json.isMember("verify_endpoints"));
+
+    Config same = config;
+    EXPECT_EQ(config, same);
+    same.verifyEndpoints = true;
+    EXPECT_NE(config, same);
+}
+
+TEST(PeerFinderConfig, validator_and_standalone_settings_disable_auto_connect)
+{
+    PeerLimitConfig const limits{.maxPeers = 50, .inPeers = {}, .outPeers = {}};
+
+    Config const config = Config::makeConfig(false, true, limits, 2459, true, 7, false);
+
+    EXPECT_TRUE(config.peerPrivate);
+    EXPECT_FALSE(config.autoConnect);
+    EXPECT_FALSE(config.verifyEndpoints);
+    EXPECT_EQ(config.ipLimit, 7);
+}
+
+TEST(PeerFinderConfig, calculates_outbound_peers_and_clamps_ip_limits)
+{
+    Config config;
+    config.maxPeers = 1;
+    EXPECT_EQ(config.calcOutPeers(), tuning::kMinOutCount);
+
+    config.maxPeers = 100;
+    EXPECT_EQ(config.calcOutPeers(), 15u);
+
+    config.inPeers = 1;
+    config.ipLimit = 0;
+    config.applyTuning();
+    EXPECT_EQ(config.ipLimit, 1);
+
+    Config explicitLimit;
+    explicitLimit.inPeers = 8;
+    explicitLimit.ipLimit = 99;
+    explicitLimit.applyTuning();
+    EXPECT_EQ(explicitLimit.ipLimit, 4);
+
+    Config largeInbound;
+    largeInbound.inPeers = 200;
+    largeInbound.ipLimit = 0;
+    largeInbound.applyTuning();
+    EXPECT_EQ(largeInbound.ipLimit, 7);
+}
+
+TEST(PeerFinderConfig, applies_legacy_and_explicit_peer_limits)
+{
+    struct ConfigCase
+    {
+        std::string name;
+        std::optional maxPeers;
+        std::optional maxIn;
+        std::optional maxOut;
+        std::uint16_t port;
+        std::uint16_t expectedOut;
+        std::uint16_t expectedIn;
+        std::uint16_t expectedIpLimit;
+    };
+
+    std::vector const cases{
+        {.name = "legacy no config",
+         .maxPeers = {},
+         .maxIn = {},
+         .maxOut = {},
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 11,
+         .expectedIpLimit = 2},
+        {.name = "legacy max_peers 0",
+         .maxPeers = 0,
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 11,
+         .expectedIpLimit = 2},
+        {.name = "legacy max_peers 5",
+         .maxPeers = 5,
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 0,
+         .expectedIpLimit = 1},
+        {.name = "legacy max_peers 20",
+         .maxPeers = 20,
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 10,
+         .expectedIpLimit = 2},
+        {.name = "legacy max_peers 100",
+         .maxPeers = 100,
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 15,
+         .expectedIn = 85,
+         .expectedIpLimit = 6},
+        {.name = "legacy max_peers 20, private",
+         .maxPeers = 20,
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 0,
+         .expectedOut = 20,
+         .expectedIn = 0,
+         .expectedIpLimit = 1},
+        {.name = "new in 100/out 10",
+         .maxPeers = {},
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 100,
+         .expectedIpLimit = 6},
+        {.name = "new in 0/out 10",
+         .maxPeers = {},
+         .maxIn = 0,
+         .maxOut = 10,
+         .port = 4000,
+         .expectedOut = 10,
+         .expectedIn = 0,
+         .expectedIpLimit = 1},
+        {.name = "new in 100/out 10, private",
+         .maxPeers = {},
+         .maxIn = 100,
+         .maxOut = 10,
+         .port = 0,
+         .expectedOut = 10,
+         .expectedIn = 0,
+         .expectedIpLimit = 6}};
+
+    for (auto const& testCase : cases)
+    {
+        SCOPED_TRACE(testCase.name);
+
+        PeerLimitConfig const limits{
+            .maxPeers = testCase.maxPeers, .inPeers = testCase.maxIn, .outPeers = testCase.maxOut};
+
+        Config const config =
+            Config::makeConfig(false, false, limits, testCase.port, false, 0, true);
+
+        Counts counts;
+        counts.onConfig(config);
+        EXPECT_EQ(counts.outMax(), testCase.expectedOut);
+        EXPECT_EQ(counts.inMax(), testCase.expectedIn);
+        EXPECT_EQ(config.ipLimit, testCase.expectedIpLimit);
+
+        NiceMock store;
+        allowEmptyStore(store);
+        NiceMock checker;
+        TestStopwatch clock;
+        Logic> logic(clock, store, checker, journal());
+        logic.config(config);
+
+        EXPECT_EQ(logic.config(), config);
+    }
+}
+
+TEST(PeerFinderConfig, rejects_incomplete_or_out_of_range_peer_limits)
+{
+    std::vector const configs{
+        {.maxPeers = {}, .inPeers = 100, .outPeers = {}},
+        {.maxPeers = {}, .inPeers = {}, .outPeers = 100},
+        {.maxPeers = {}, .inPeers = 100, .outPeers = 5},
+        {.maxPeers = {}, .inPeers = 1001, .outPeers = 10},
+        {.maxPeers = {}, .inPeers = 10, .outPeers = 1001}};
+
+    for (auto const& limits : configs)
+    {
+        EXPECT_THROW(
+            Config::makeConfig(false, false, limits, 4000, false, 0, true), std::exception);
+    }
+}
+
+}  // namespace
+}  // namespace xrpl::peer_finder
diff --git a/src/tests/libxrpl/protocol/ApiVersion.cpp b/src/tests/libxrpl/protocol/ApiVersion.cpp
new file mode 100644
index 0000000000..5bb6a158cf
--- /dev/null
+++ b/src/tests/libxrpl/protocol/ApiVersion.cpp
@@ -0,0 +1,26 @@
+#include 
+
+#include 
+
+using namespace xrpl;
+
+TEST(ApiVersion, 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);
+}
+
+// Update when we change versions
+TEST(ApiVersion, 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);
+}
diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp
new file mode 100644
index 0000000000..f4e6e60cc9
--- /dev/null
+++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp
@@ -0,0 +1,60 @@
+#include 
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+using namespace xrpl;
+
+namespace {
+
+// Built from raw bytes rather than base58 so the test does not depend on
+// hand-computed checksums.
+AccountID
+account(std::string_view hex)
+{
+    AccountID id;
+    EXPECT_TRUE(id.parseHex(hex));
+    return id;
+}
+
+}  // namespace
+
+// getText() builds its string from eight substitutions of the same type, so a
+// transposed pair would still compile and still type check. Pin the output so
+// the field/value pairing is actually verified.
+TEST(STXChainBridge, getTextPairsEachFieldWithItsValue)
+{
+    auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314");
+    auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201");
+
+    auto const lockingIssue = xrpIssue();
+    Issue const issuingIssue{toCurrency("USD"), issuingDoor};
+
+    STXChainBridge const bridge{lockingDoor, lockingIssue, issuingDoor, issuingIssue};
+
+    std::string const expected = "{ LockingChainDoor = " + toBase58(lockingDoor) +
+        ", LockingChainIssue = " + lockingIssue.getText() +
+        ", IssuingChainDoor = " + toBase58(issuingDoor) +
+        ", IssuingChainIssue = " + issuingIssue.getText() + " }";
+
+    EXPECT_EQ(bridge.getText(), expected);
+}
+
+TEST(STXChainBridge, getTextOnADefaultBridge)
+{
+    STXChainBridge const bridge;
+    auto const text = bridge.getText();
+
+    // The outer braces are literal, and the four field names appear in
+    // declaration order regardless of the values.
+    EXPECT_TRUE(text.starts_with("{ LockingChainDoor = "));
+    EXPECT_TRUE(text.ends_with(" }"));
+    EXPECT_LT(text.find("LockingChainIssue"), text.find("IssuingChainDoor"));
+    EXPECT_LT(text.find("IssuingChainDoor"), text.find("IssuingChainIssue"));
+}
diff --git a/src/tests/libxrpl/protocol/Serializer.cpp b/src/tests/libxrpl/protocol/Serializer.cpp
new file mode 100644
index 0000000000..fc5742444a
--- /dev/null
+++ b/src/tests/libxrpl/protocol/Serializer.cpp
@@ -0,0 +1,49 @@
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+using namespace xrpl;
+
+TEST(Serializer, add32_roundtrip)
+{
+    static constexpr auto kValues = std::to_array({
+        std::numeric_limits::min(),
+        -1,
+        0,
+        1,
+        std::numeric_limits::max(),
+    });
+
+    for (std::int32_t const value : kValues)
+    {
+        Serializer s;
+        s.add32(value);
+        EXPECT_EQ(s.size(), 4);
+        SerialIter sit(s.slice());
+        EXPECT_EQ(sit.geti32(), value);
+    }
+}
+
+TEST(Serializer, add64_roundtrip)
+{
+    static constexpr auto kValues = std::to_array({
+        std::numeric_limits::min(),
+        -1,
+        0,
+        1,
+        std::numeric_limits::max(),
+    });
+
+    for (std::int64_t const value : kValues)
+    {
+        Serializer s;
+        s.add64(value);
+        EXPECT_EQ(s.size(), 8);
+        SerialIter sit(s.slice());
+        EXPECT_EQ(sit.geti64(), value);
+    }
+}
diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
index 8dc5960ee0..974d0e81d7 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp
@@ -32,7 +32,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     auto const previousTxnIDValue = canonical_UINT256();
     auto const previousTxnLgrSeqValue = canonical_UINT32();
     auto const domainIDValue = canonical_UINT256();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
     auto const referenceHoldingValue = canonical_UINT256();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
@@ -53,7 +53,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     builder.setLockedAmount(lockedAmountValue);
     builder.setMPTokenMetadata(mPTokenMetadataValue);
     builder.setDomainID(domainIDValue);
-    builder.setMutableFlags(mutableFlagsValue);
+    builder.setImmutableFlags(immutableFlagsValue);
     builder.setReferenceHolding(referenceHoldingValue);
     builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
     builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
@@ -153,11 +153,11 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
-        auto const actualOpt = entry.getMutableFlags();
+        auto const& expected = immutableFlagsValue;
+        auto const actualOpt = entry.getImmutableFlags();
         ASSERT_TRUE(actualOpt.has_value());
-        expectEqualField(expected, *actualOpt, "sfMutableFlags");
-        EXPECT_TRUE(entry.hasMutableFlags());
+        expectEqualField(expected, *actualOpt, "sfImmutableFlags");
+        EXPECT_TRUE(entry.hasImmutableFlags());
     }
 
     {
@@ -217,7 +217,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     auto const previousTxnIDValue = canonical_UINT256();
     auto const previousTxnLgrSeqValue = canonical_UINT32();
     auto const domainIDValue = canonical_UINT256();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
     auto const referenceHoldingValue = canonical_UINT256();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
@@ -237,7 +237,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     sle->at(sfPreviousTxnID) = previousTxnIDValue;
     sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue;
     sle->at(sfDomainID) = domainIDValue;
-    sle->at(sfMutableFlags) = mutableFlagsValue;
+    sle->at(sfImmutableFlags) = immutableFlagsValue;
     sle->at(sfReferenceHolding) = referenceHoldingValue;
     sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue;
     sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue;
@@ -391,16 +391,16 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
+        auto const& expected = immutableFlagsValue;
 
-        auto const fromSleOpt = entryFromSle.getMutableFlags();
-        auto const fromBuilderOpt = entryFromBuilder.getMutableFlags();
+        auto const fromSleOpt = entryFromSle.getImmutableFlags();
+        auto const fromBuilderOpt = entryFromBuilder.getImmutableFlags();
 
         ASSERT_TRUE(fromSleOpt.has_value());
         ASSERT_TRUE(fromBuilderOpt.has_value());
 
-        expectEqualField(expected, *fromSleOpt, "sfMutableFlags");
-        expectEqualField(expected, *fromBuilderOpt, "sfMutableFlags");
+        expectEqualField(expected, *fromSleOpt, "sfImmutableFlags");
+        expectEqualField(expected, *fromBuilderOpt, "sfImmutableFlags");
     }
 
     {
@@ -531,8 +531,8 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getMPTokenMetadata().has_value());
     EXPECT_FALSE(entry.hasDomainID());
     EXPECT_FALSE(entry.getDomainID().has_value());
-    EXPECT_FALSE(entry.hasMutableFlags());
-    EXPECT_FALSE(entry.getMutableFlags().has_value());
+    EXPECT_FALSE(entry.hasImmutableFlags());
+    EXPECT_FALSE(entry.getImmutableFlags().has_value());
     EXPECT_FALSE(entry.hasReferenceHolding());
     EXPECT_FALSE(entry.getReferenceHolding().has_value());
     EXPECT_FALSE(entry.hasIssuerEncryptionKey());
diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
index 2697924d37..26dde55563 100644
--- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp
@@ -35,6 +35,10 @@ TEST(VaultTests, BuilderSettersRoundTrip)
     auto const shareMPTIDValue = canonical_UINT192();
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const scaleValue = canonical_UINT8();
+    auto const lEVersionValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     VaultBuilder builder{
         previousTxnIDValue,
@@ -54,6 +58,10 @@ TEST(VaultTests, BuilderSettersRoundTrip)
     builder.setAssetsMaximum(assetsMaximumValue);
     builder.setLossUnrealized(lossUnrealizedValue);
     builder.setScale(scaleValue);
+    builder.setLEVersion(lEVersionValue);
+    builder.setVaultKind(vaultKindValue);
+    builder.setSubscriptionDate(subscriptionDateValue);
+    builder.setRedemptionDate(redemptionDateValue);
 
     builder.setLedgerIndex(index);
     builder.setFlags(0x1u);
@@ -166,6 +174,38 @@ TEST(VaultTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(entry.hasScale());
     }
 
+    {
+        auto const& expected = lEVersionValue;
+        auto const actualOpt = entry.getLEVersion();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfLEVersion");
+        EXPECT_TRUE(entry.hasLEVersion());
+    }
+
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = entry.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+        EXPECT_TRUE(entry.hasVaultKind());
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = entry.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+        EXPECT_TRUE(entry.hasSubscriptionDate());
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = entry.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value());
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+        EXPECT_TRUE(entry.hasRedemptionDate());
+    }
+
     EXPECT_TRUE(entry.hasLedgerIndex());
     auto const ledgerIndex = entry.getLedgerIndex();
     ASSERT_TRUE(ledgerIndex.has_value());
@@ -194,6 +234,10 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
     auto const shareMPTIDValue = canonical_UINT192();
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const scaleValue = canonical_UINT8();
+    auto const lEVersionValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     auto sle = std::make_shared(Vault::entryType, index);
 
@@ -212,6 +256,10 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
     sle->at(sfShareMPTID) = shareMPTIDValue;
     sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
     sle->at(sfScale) = scaleValue;
+    sle->at(sfLEVersion) = lEVersionValue;
+    sle->at(sfVaultKind) = vaultKindValue;
+    sle->at(sfSubscriptionDate) = subscriptionDateValue;
+    sle->at(sfRedemptionDate) = redemptionDateValue;
 
     VaultBuilder builderFromSle{sle};
     EXPECT_TRUE(builderFromSle.validate());
@@ -390,6 +438,58 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
         expectEqualField(expected, *fromBuilderOpt, "sfScale");
     }
 
+    {
+        auto const& expected = lEVersionValue;
+
+        auto const fromSleOpt = entryFromSle.getLEVersion();
+        auto const fromBuilderOpt = entryFromBuilder.getLEVersion();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfLEVersion");
+        expectEqualField(expected, *fromBuilderOpt, "sfLEVersion");
+    }
+
+    {
+        auto const& expected = vaultKindValue;
+
+        auto const fromSleOpt = entryFromSle.getVaultKind();
+        auto const fromBuilderOpt = entryFromBuilder.getVaultKind();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfVaultKind");
+        expectEqualField(expected, *fromBuilderOpt, "sfVaultKind");
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+
+        auto const fromSleOpt = entryFromSle.getSubscriptionDate();
+        auto const fromBuilderOpt = entryFromBuilder.getSubscriptionDate();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfSubscriptionDate");
+        expectEqualField(expected, *fromBuilderOpt, "sfSubscriptionDate");
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+
+        auto const fromSleOpt = entryFromSle.getRedemptionDate();
+        auto const fromBuilderOpt = entryFromBuilder.getRedemptionDate();
+
+        ASSERT_TRUE(fromSleOpt.has_value());
+        ASSERT_TRUE(fromBuilderOpt.has_value());
+
+        expectEqualField(expected, *fromSleOpt, "sfRedemptionDate");
+        expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate");
+    }
+
     EXPECT_EQ(entryFromSle.getKey(), index);
     EXPECT_EQ(entryFromBuilder.getKey(), index);
 }
@@ -472,5 +572,13 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(entry.getLossUnrealized().has_value());
     EXPECT_FALSE(entry.hasScale());
     EXPECT_FALSE(entry.getScale().has_value());
+    EXPECT_FALSE(entry.hasLEVersion());
+    EXPECT_FALSE(entry.getLEVersion().has_value());
+    EXPECT_FALSE(entry.hasVaultKind());
+    EXPECT_FALSE(entry.getVaultKind().has_value());
+    EXPECT_FALSE(entry.hasSubscriptionDate());
+    EXPECT_FALSE(entry.getSubscriptionDate().has_value());
+    EXPECT_FALSE(entry.hasRedemptionDate());
+    EXPECT_FALSE(entry.getRedemptionDate().has_value());
 }
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
index 5b0a8c9146..043ab0a252 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     LoanBrokerCoverWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     LoanBrokerCoverWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp
index f7151fc749..8228188950 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp
@@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip)
     auto const maximumAmountValue = canonical_UINT64();
     auto const mPTokenMetadataValue = canonical_VL();
     auto const domainIDValue = canonical_UINT256();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
 
     MPTokenIssuanceCreateBuilder builder{
         accountValue,
@@ -48,7 +48,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip)
     builder.setMaximumAmount(maximumAmountValue);
     builder.setMPTokenMetadata(mPTokenMetadataValue);
     builder.setDomainID(domainIDValue);
-    builder.setMutableFlags(mutableFlagsValue);
+    builder.setImmutableFlags(immutableFlagsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -107,11 +107,11 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
-        auto const actualOpt = tx.getMutableFlags();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present";
-        expectEqualField(expected, *actualOpt, "sfMutableFlags");
-        EXPECT_TRUE(tx.hasMutableFlags());
+        auto const& expected = immutableFlagsValue;
+        auto const actualOpt = tx.getImmutableFlags();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present";
+        expectEqualField(expected, *actualOpt, "sfImmutableFlags");
+        EXPECT_TRUE(tx.hasImmutableFlags());
     }
 
 }
@@ -135,7 +135,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip)
     auto const maximumAmountValue = canonical_UINT64();
     auto const mPTokenMetadataValue = canonical_VL();
     auto const domainIDValue = canonical_UINT256();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
 
     // Build an initial transaction
     MPTokenIssuanceCreateBuilder initialBuilder{
@@ -149,7 +149,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip)
     initialBuilder.setMaximumAmount(maximumAmountValue);
     initialBuilder.setMPTokenMetadata(mPTokenMetadataValue);
     initialBuilder.setDomainID(domainIDValue);
-    initialBuilder.setMutableFlags(mutableFlagsValue);
+    initialBuilder.setImmutableFlags(immutableFlagsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -204,10 +204,10 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
-        auto const actualOpt = rebuiltTx.getMutableFlags();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present";
-        expectEqualField(expected, *actualOpt, "sfMutableFlags");
+        auto const& expected = immutableFlagsValue;
+        auto const actualOpt = rebuiltTx.getImmutableFlags();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present";
+        expectEqualField(expected, *actualOpt, "sfImmutableFlags");
     }
 
 }
@@ -275,8 +275,8 @@ TEST(TransactionsMPTokenIssuanceCreateTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getMPTokenMetadata().has_value());
     EXPECT_FALSE(tx.hasDomainID());
     EXPECT_FALSE(tx.getDomainID().has_value());
-    EXPECT_FALSE(tx.hasMutableFlags());
-    EXPECT_FALSE(tx.getMutableFlags().has_value());
+    EXPECT_FALSE(tx.hasImmutableFlags());
+    EXPECT_FALSE(tx.getImmutableFlags().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
index e7b34590b2..af696ce47b 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp
@@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
     auto const domainIDValue = canonical_UINT256();
     auto const mPTokenMetadataValue = canonical_VL();
     auto const transferFeeValue = canonical_UINT16();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
 
@@ -50,7 +50,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
     builder.setDomainID(domainIDValue);
     builder.setMPTokenMetadata(mPTokenMetadataValue);
     builder.setTransferFee(transferFeeValue);
-    builder.setMutableFlags(mutableFlagsValue);
+    builder.setImmutableFlags(immutableFlagsValue);
     builder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
     builder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
 
@@ -109,11 +109,11 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
-        auto const actualOpt = tx.getMutableFlags();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present";
-        expectEqualField(expected, *actualOpt, "sfMutableFlags");
-        EXPECT_TRUE(tx.hasMutableFlags());
+        auto const& expected = immutableFlagsValue;
+        auto const actualOpt = tx.getImmutableFlags();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present";
+        expectEqualField(expected, *actualOpt, "sfImmutableFlags");
+        EXPECT_TRUE(tx.hasImmutableFlags());
     }
 
     {
@@ -153,7 +153,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
     auto const domainIDValue = canonical_UINT256();
     auto const mPTokenMetadataValue = canonical_VL();
     auto const transferFeeValue = canonical_UINT16();
-    auto const mutableFlagsValue = canonical_UINT32();
+    auto const immutableFlagsValue = canonical_UINT32();
     auto const issuerEncryptionKeyValue = canonical_VL();
     auto const auditorEncryptionKeyValue = canonical_VL();
 
@@ -169,7 +169,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
     initialBuilder.setDomainID(domainIDValue);
     initialBuilder.setMPTokenMetadata(mPTokenMetadataValue);
     initialBuilder.setTransferFee(transferFeeValue);
-    initialBuilder.setMutableFlags(mutableFlagsValue);
+    initialBuilder.setImmutableFlags(immutableFlagsValue);
     initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue);
     initialBuilder.setAuditorEncryptionKey(auditorEncryptionKeyValue);
 
@@ -225,10 +225,10 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip)
     }
 
     {
-        auto const& expected = mutableFlagsValue;
-        auto const actualOpt = rebuiltTx.getMutableFlags();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present";
-        expectEqualField(expected, *actualOpt, "sfMutableFlags");
+        auto const& expected = immutableFlagsValue;
+        auto const actualOpt = rebuiltTx.getImmutableFlags();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present";
+        expectEqualField(expected, *actualOpt, "sfImmutableFlags");
     }
 
     {
@@ -310,8 +310,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getMPTokenMetadata().has_value());
     EXPECT_FALSE(tx.hasTransferFee());
     EXPECT_FALSE(tx.getTransferFee().has_value());
-    EXPECT_FALSE(tx.hasMutableFlags());
-    EXPECT_FALSE(tx.getMutableFlags().has_value());
+    EXPECT_FALSE(tx.hasImmutableFlags());
+    EXPECT_FALSE(tx.getImmutableFlags().has_value());
     EXPECT_FALSE(tx.hasIssuerEncryptionKey());
     EXPECT_FALSE(tx.getIssuerEncryptionKey().has_value());
     EXPECT_FALSE(tx.hasAuditorEncryptionKey());
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp
index dce8cfca3f..c5bc41c6e6 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp
@@ -31,9 +31,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
     // Transaction-specific field values
     auto const counterpartySponsorValue = canonical_ACCOUNT();
     auto const sponseeValue = canonical_ACCOUNT();
-    auto const feeAmountValue = canonical_AMOUNT();
+    auto const feeAmountDeltaValue = canonical_AMOUNT();
     auto const maxFeeValue = canonical_AMOUNT();
-    auto const remainingOwnerCountValue = canonical_UINT32();
+    auto const remainingOwnerCountDeltaValue = canonical_INT32();
 
     SponsorshipSetBuilder builder{
         accountValue,
@@ -44,9 +44,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setCounterpartySponsor(counterpartySponsorValue);
     builder.setSponsee(sponseeValue);
-    builder.setFeeAmount(feeAmountValue);
+    builder.setFeeAmountDelta(feeAmountDeltaValue);
     builder.setMaxFee(maxFeeValue);
-    builder.setRemainingOwnerCount(remainingOwnerCountValue);
+    builder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -81,11 +81,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
     }
 
     {
-        auto const& expected = feeAmountValue;
-        auto const actualOpt = tx.getFeeAmount();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present";
-        expectEqualField(expected, *actualOpt, "sfFeeAmount");
-        EXPECT_TRUE(tx.hasFeeAmount());
+        auto const& expected = feeAmountDeltaValue;
+        auto const actualOpt = tx.getFeeAmountDelta();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present";
+        expectEqualField(expected, *actualOpt, "sfFeeAmountDelta");
+        EXPECT_TRUE(tx.hasFeeAmountDelta());
     }
 
     {
@@ -97,11 +97,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
     }
 
     {
-        auto const& expected = remainingOwnerCountValue;
-        auto const actualOpt = tx.getRemainingOwnerCount();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present";
-        expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount");
-        EXPECT_TRUE(tx.hasRemainingOwnerCount());
+        auto const& expected = remainingOwnerCountDeltaValue;
+        auto const actualOpt = tx.getRemainingOwnerCountDelta();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present";
+        expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta");
+        EXPECT_TRUE(tx.hasRemainingOwnerCountDelta());
     }
 
 }
@@ -122,9 +122,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
     // Transaction-specific field values
     auto const counterpartySponsorValue = canonical_ACCOUNT();
     auto const sponseeValue = canonical_ACCOUNT();
-    auto const feeAmountValue = canonical_AMOUNT();
+    auto const feeAmountDeltaValue = canonical_AMOUNT();
     auto const maxFeeValue = canonical_AMOUNT();
-    auto const remainingOwnerCountValue = canonical_UINT32();
+    auto const remainingOwnerCountDeltaValue = canonical_INT32();
 
     // Build an initial transaction
     SponsorshipSetBuilder initialBuilder{
@@ -135,9 +135,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setCounterpartySponsor(counterpartySponsorValue);
     initialBuilder.setSponsee(sponseeValue);
-    initialBuilder.setFeeAmount(feeAmountValue);
+    initialBuilder.setFeeAmountDelta(feeAmountDeltaValue);
     initialBuilder.setMaxFee(maxFeeValue);
-    initialBuilder.setRemainingOwnerCount(remainingOwnerCountValue);
+    initialBuilder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -171,10 +171,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
     }
 
     {
-        auto const& expected = feeAmountValue;
-        auto const actualOpt = rebuiltTx.getFeeAmount();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present";
-        expectEqualField(expected, *actualOpt, "sfFeeAmount");
+        auto const& expected = feeAmountDeltaValue;
+        auto const actualOpt = rebuiltTx.getFeeAmountDelta();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present";
+        expectEqualField(expected, *actualOpt, "sfFeeAmountDelta");
     }
 
     {
@@ -185,10 +185,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
     }
 
     {
-        auto const& expected = remainingOwnerCountValue;
-        auto const actualOpt = rebuiltTx.getRemainingOwnerCount();
-        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present";
-        expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount");
+        auto const& expected = remainingOwnerCountDeltaValue;
+        auto const actualOpt = rebuiltTx.getRemainingOwnerCountDelta();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present";
+        expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta");
     }
 
 }
@@ -250,12 +250,12 @@ TEST(TransactionsSponsorshipSetTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getCounterpartySponsor().has_value());
     EXPECT_FALSE(tx.hasSponsee());
     EXPECT_FALSE(tx.getSponsee().has_value());
-    EXPECT_FALSE(tx.hasFeeAmount());
-    EXPECT_FALSE(tx.getFeeAmount().has_value());
+    EXPECT_FALSE(tx.hasFeeAmountDelta());
+    EXPECT_FALSE(tx.getFeeAmountDelta().has_value());
     EXPECT_FALSE(tx.hasMaxFee());
     EXPECT_FALSE(tx.getMaxFee().has_value());
-    EXPECT_FALSE(tx.hasRemainingOwnerCount());
-    EXPECT_FALSE(tx.getRemainingOwnerCount().has_value());
+    EXPECT_FALSE(tx.hasRemainingOwnerCountDelta());
+    EXPECT_FALSE(tx.getRemainingOwnerCountDelta().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
index 9c1e14f6f4..592d40a6f6 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp
@@ -36,6 +36,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const dataValue = canonical_VL();
     auto const scaleValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     VaultCreateBuilder builder{
         accountValue,
@@ -51,6 +54,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
     builder.setWithdrawalPolicy(withdrawalPolicyValue);
     builder.setData(dataValue);
     builder.setScale(scaleValue);
+    builder.setVaultKind(vaultKindValue);
+    builder.setSubscriptionDate(subscriptionDateValue);
+    builder.setRedemptionDate(redemptionDateValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -122,6 +128,30 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasScale());
     }
 
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = tx.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present";
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+        EXPECT_TRUE(tx.hasVaultKind());
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = tx.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+        EXPECT_TRUE(tx.hasSubscriptionDate());
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = tx.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+        EXPECT_TRUE(tx.hasRedemptionDate());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -145,6 +175,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
     auto const withdrawalPolicyValue = canonical_UINT8();
     auto const dataValue = canonical_VL();
     auto const scaleValue = canonical_UINT8();
+    auto const vaultKindValue = canonical_UINT8();
+    auto const subscriptionDateValue = canonical_UINT32();
+    auto const redemptionDateValue = canonical_UINT32();
 
     // Build an initial transaction
     VaultCreateBuilder initialBuilder{
@@ -160,6 +193,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
     initialBuilder.setWithdrawalPolicy(withdrawalPolicyValue);
     initialBuilder.setData(dataValue);
     initialBuilder.setScale(scaleValue);
+    initialBuilder.setVaultKind(vaultKindValue);
+    initialBuilder.setSubscriptionDate(subscriptionDateValue);
+    initialBuilder.setRedemptionDate(redemptionDateValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -226,6 +262,27 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfScale");
     }
 
+    {
+        auto const& expected = vaultKindValue;
+        auto const actualOpt = rebuiltTx.getVaultKind();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present";
+        expectEqualField(expected, *actualOpt, "sfVaultKind");
+    }
+
+    {
+        auto const& expected = subscriptionDateValue;
+        auto const actualOpt = rebuiltTx.getSubscriptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfSubscriptionDate");
+    }
+
+    {
+        auto const& expected = redemptionDateValue;
+        auto const actualOpt = rebuiltTx.getRedemptionDate();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present";
+        expectEqualField(expected, *actualOpt, "sfRedemptionDate");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -295,6 +352,12 @@ TEST(TransactionsVaultCreateTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getData().has_value());
     EXPECT_FALSE(tx.hasScale());
     EXPECT_FALSE(tx.getScale().has_value());
+    EXPECT_FALSE(tx.hasVaultKind());
+    EXPECT_FALSE(tx.getVaultKind().has_value());
+    EXPECT_FALSE(tx.hasSubscriptionDate());
+    EXPECT_FALSE(tx.getSubscriptionDate().has_value());
+    EXPECT_FALSE(tx.hasRedemptionDate());
+    EXPECT_FALSE(tx.getRedemptionDate().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
index 4067a6551d..518957d47b 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     VaultWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     VaultWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsVaultWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/resource/Logic.cpp b/src/tests/libxrpl/resource/Logic.cpp
index 1f935ebf4b..b38ca2e051 100644
--- a/src/tests/libxrpl/resource/Logic.cpp
+++ b/src/tests/libxrpl/resource/Logic.cpp
@@ -17,11 +17,14 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
+#include 
 #include 
+#include 
 
-namespace xrpl::Resource {
+namespace xrpl::resource {
 
 class ResourceManagerTest : public ::testing::Test
 {
@@ -54,9 +57,10 @@ protected:
 
     //--------------------------------------------------------------------------
 
-    static void
-    populateGossip(Gossip& gossip)
+    static Gossip
+    makeGossip()
     {
+        Gossip gossip;
         std::uint8_t const v(10 + randInt(9));
         std::uint8_t const n(10 + randInt(9));
         gossip.items.reserve(n);
@@ -64,15 +68,16 @@ protected:
         {
             Gossip::Item item;
             item.balance = 100 + randInt(499);
-            beast::IP::AddressV4::bytes_type const d = {{
+            beast::ip::AddressV4::bytes_type const d = {{
                 192,
                 0,
                 2,
                 static_cast(v + i),
             }};
-            item.address = beast::IP::Endpoint{beast::IP::AddressV4{d}};
-            gossip.items.push_back(item);
+            item.address = beast::ip::Endpoint{beast::ip::AddressV4{d}};
+            gossip.items.push_back(std::move(item));
         }
+        return gossip;
     }
 };
 
@@ -81,14 +86,14 @@ TEST_F(ResourceManagerTest, limited_warn_drop)
     TestLogic logic{j_};
 
     Charge const fee{kDropThreshold + 1};
-    beast::IP::Endpoint const addr{beast::IP::Endpoint::fromString("192.0.2.2")};
+    beast::ip::Endpoint const addr{beast::ip::Endpoint::fromString("192.0.2.2")};
 
     {
         Consumer c{logic.newInboundEndpoint(addr)};
 
         // Create load until we get a warning
-        int n = 10000;
-        bool warned = false;
+        auto n = 10000;
+        auto warned = false;
 
         while (--n >= 0)
         {
@@ -97,7 +102,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop)
                 warned = true;
                 break;
             }
-            ++logic.clock();
+            logic.advance();
         }
 
         ASSERT_TRUE(warned) << "Loop count exceeded without warning";
@@ -113,7 +118,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop)
                 EXPECT_TRUE(c.disconnect(j_));
                 break;
             }
-            ++logic.clock();
+            logic.advance();
         }
 
         ASSERT_TRUE(dropped) << "Loop count exceeded without dropping";
@@ -135,7 +140,7 @@ TEST_F(ResourceManagerTest, limited_warn_drop)
         auto n = kSecondsUntilExpiration + 1s;
         while (--n > 0s)
         {
-            ++logic.clock();
+            logic.advance();
             logic.periodicActivity();
             Consumer const c{logic.newInboundEndpoint(addr)};
             if (c.disposition() != Disposition::Drop)
@@ -153,7 +158,7 @@ TEST_F(ResourceManagerTest, unlimited_warn_drop)
     TestLogic logic{j_};
 
     Charge const fee{kDropThreshold + 1};
-    beast::IP::Endpoint const addr{beast::IP::Endpoint::fromString("192.0.2.2")};
+    beast::ip::Endpoint const addr{beast::ip::Endpoint::fromString("192.0.2.2")};
     Consumer c{logic.newUnlimitedEndpoint(addr)};
 
     // Create load until we get a warning
@@ -167,7 +172,7 @@ TEST_F(ResourceManagerTest, unlimited_warn_drop)
             warned = true;
             break;
         }
-        ++logic.clock();
+        logic.advance();
     }
 
     EXPECT_FALSE(warned) << "Should loop forever with no warning";
@@ -175,15 +180,17 @@ TEST_F(ResourceManagerTest, unlimited_warn_drop)
 
 TEST_F(ResourceManagerTest, charges)
 {
+    static constexpr auto kDecayTicks = 128uz;
+
     TestLogic logic{j_};
 
     {
-        beast::IP::Endpoint const address{beast::IP::Endpoint::fromString("192.0.2.1")};
+        beast::ip::Endpoint const address{beast::ip::Endpoint::fromString("192.0.2.1")};
         Consumer c{logic.newInboundEndpoint(address)};
         Charge const fee{1000};
         JLOG(j_.info()) << "Charging " << c.toString() << " " << fee << " per second";
         c.charge(fee);
-        for (int i = 0; i < 128; ++i)
+        for (auto tick = 0uz; tick < kDecayTicks; ++tick)
         {
             JLOG(j_.info()) << "Time= " << logic.clock().now().time_since_epoch().count()
                             << ", Balance = " << c.balance();
@@ -192,11 +199,11 @@ TEST_F(ResourceManagerTest, charges)
     }
 
     {
-        beast::IP::Endpoint const address{beast::IP::Endpoint::fromString("192.0.2.2")};
+        beast::ip::Endpoint const address{beast::ip::Endpoint::fromString("192.0.2.2")};
         Consumer c{logic.newInboundEndpoint(address)};
         Charge const fee{1000};
         JLOG(j_.info()) << "Charging " << c.toString() << " " << fee << " per second";
-        for (int i = 0; i < 128; ++i)
+        for (auto tick = 0uz; tick < kDecayTicks; ++tick)
         {
             c.charge(fee);
             JLOG(j_.info()) << "Time= " << logic.clock().now().time_since_epoch().count()
@@ -210,13 +217,10 @@ TEST_F(ResourceManagerTest, imports)
 {
     TestLogic logic{j_};
 
-    Gossip g[5];
-
-    for (auto& i : g)
-        populateGossip(i);
-
-    for (int i = 0; i < 5; ++i)
-        logic.importConsumers(std::to_string(i), g[i]);
+    static constexpr auto kGossipSources = 5uz;
+    std::ranges::for_each(std::views::iota(0uz, kGossipSources), [&](auto const i) {
+        logic.importConsumers(std::to_string(i), makeGossip());
+    });
 }
 
 TEST_F(ResourceManagerTest, import)
@@ -226,16 +230,16 @@ TEST_F(ResourceManagerTest, import)
     Gossip g;
     Gossip::Item item;
     item.balance = 100;
-    beast::IP::AddressV4::bytes_type const d = {{
+    beast::ip::AddressV4::bytes_type const d = {{
         192,
         0,
         2,
         1,
     }};
-    item.address = beast::IP::Endpoint{beast::IP::AddressV4{d}};
-    g.items.push_back(item);
+    item.address = beast::ip::Endpoint{beast::ip::AddressV4{d}};
+    g.items.push_back(std::move(item));
 
     logic.importConsumers("g", g);
 }
 
-}  // namespace xrpl::Resource
+}  // namespace xrpl::resource
diff --git a/src/tests/libxrpl/server/InfoSub.cpp b/src/tests/libxrpl/server/InfoSub.cpp
new file mode 100644
index 0000000000..6913812a92
--- /dev/null
+++ b/src/tests/libxrpl/server/InfoSub.cpp
@@ -0,0 +1,60 @@
+#include 
+
+#include 
+
+#include 
+#include 
+
+using namespace xrpl;
+
+// The per-connection subscription cap is enforced by the pure predicate
+// exceedsSubscriptionCap(current, additional). Testing it directly (rather than
+// by subscribing the real cap through a WebSocket, which would exceed the frame
+// limit and drop the connection before the check runs) lets the boundary be
+// asserted exactly.
+TEST(InfoSubSubscriptionCap, Boundary)
+{
+    constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
+
+    // Empty connection: anything up to the cap is admitted, cap+1 is not.
+    EXPECT_FALSE(exceedsSubscriptionCap(0, 0));
+    EXPECT_FALSE(exceedsSubscriptionCap(0, cap));
+    EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1));
+
+    // Exactly at the cap: zero more is fine, one more is rejected.
+    EXPECT_FALSE(exceedsSubscriptionCap(cap, 0));
+    EXPECT_TRUE(exceedsSubscriptionCap(cap, 1));
+
+    // One below the cap: exactly one more reaches the cap; two exceed it.
+    EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1));
+    EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2));
+}
+
+TEST(InfoSubSubscriptionCap, NoOverflow)
+{
+    constexpr std::size_t cap = kMaxSubscriptionsPerConnection;
+    constexpr std::size_t max = std::numeric_limits::max();
+
+    // current + additional must not wrap: a huge additional is rejected even
+    // when current is 0 (the additional > cap term guards the subtraction).
+    EXPECT_TRUE(exceedsSubscriptionCap(0, max));
+    EXPECT_TRUE(exceedsSubscriptionCap(cap, max));
+}
+
+TEST(InfoSubSubscriptionCap, ExplicitCap)
+{
+    // A configured override is honored: the boundary tracks the passed cap, not
+    // the built-in default. This is the seam doSubscribe uses to enforce a
+    // per-connection cap set via [max_subscriptions_per_connection].
+    constexpr std::size_t cap = 5;
+
+    EXPECT_FALSE(exceedsSubscriptionCap(0, cap, cap));
+    EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1, cap));
+    EXPECT_FALSE(exceedsSubscriptionCap(cap, 0, cap));
+    EXPECT_TRUE(exceedsSubscriptionCap(cap, 1, cap));
+    EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1, cap));
+    EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2, cap));
+
+    // The overflow guard still holds with a small explicit cap.
+    EXPECT_TRUE(exceedsSubscriptionCap(0, std::numeric_limits::max(), cap));
+}
diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp
index 238c34bf9c..c84cdf504f 100644
--- a/src/tests/libxrpl/shamap/SHAMap.cpp
+++ b/src/tests/libxrpl/shamap/SHAMap.cpp
@@ -16,7 +16,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -113,7 +112,7 @@ protected:
     intToVuc(std::uint8_t v)
     {
         Buffer vuc{32};
-        std::fill_n(vuc.data(), vuc.size(), v);
+        vuc.fill(v);
         return vuc;
     }
 };
@@ -257,12 +256,9 @@ TEST_P(SHAMapTest, add_traverse_snapshot_build_tear_and_iterate)
             map.invariants();
         }
 
-        int h = 7;
+        auto keyIndex = kKeys.size();
         for (auto const& k : map)
-        {
-            EXPECT_EQ(k.key(), kKeys[h]);
-            --h;
-        }
+            EXPECT_EQ(k.key(), kKeys[--keyIndex]);
     }
 }
 
@@ -288,7 +284,11 @@ TEST_F(SHAMapPathProof, verify_proof_path)
     uint256 rootHash;
     std::vector goodPath;
 
-    for (unsigned char c = 1; c < 100; ++c)
+    static constexpr unsigned char kFirstKey = 1;
+    static constexpr unsigned char kKeyCount = 100;
+    static constexpr unsigned char kLastKey = kKeyCount - 1;
+
+    for (unsigned char c = kFirstKey; c < kKeyCount; ++c)
     {
         uint256 k(c);
         map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()}));
@@ -304,7 +304,7 @@ TEST_F(SHAMapPathProof, verify_proof_path)
         auto& proofPath = *path;
 
         EXPECT_TRUE(map.verifyProofPath(root, k, proofPath));
-        if (c == 1)
+        if (c == kFirstKey)
         {
             // extra node
             proofPath.insert(proofPath.begin(), proofPath.front());
@@ -313,7 +313,7 @@ TEST_F(SHAMapPathProof, verify_proof_path)
             uint256 const wrongKey(c + 1);
             EXPECT_FALSE(map.getProofPath(wrongKey));
         }
-        if (c == 99)
+        if (c == kLastKey)
         {
             key = k;
             rootHash = root;
diff --git a/src/tests/libxrpl/shamap/SHAMapSync.cpp b/src/tests/libxrpl/shamap/SHAMapSync.cpp
index 5cefbae8a1..e4bcbd8970 100644
--- a/src/tests/libxrpl/shamap/SHAMapSync.cpp
+++ b/src/tests/libxrpl/shamap/SHAMapSync.cpp
@@ -1,4 +1,3 @@
-#include 
 #include 
 #include 
 #include 
@@ -18,6 +17,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -34,15 +34,17 @@ protected:
     boost::intrusive_ptr
     makeRandomAS()
     {
+        static constexpr auto kWordsPerState = 3uz;
+
         Serializer s;
 
-        for (int d = 0; d < 3; ++d)
+        for (auto word = 0uz; word < kWordsPerState; ++word)
             s.add32(randInt(eng_));
         return makeShamapitem(s.getSHA512Half(), s.slice());
     }
 
     bool
-    confuseMap(SHAMap& map, int count)
+    confuseMap(SHAMap& map, std::size_t count)
     {
         // add a bunch of random states to a map, then remove them
         // map should be the same
@@ -50,7 +52,7 @@ protected:
 
         std::list items;
 
-        for (int i = 0; i < count; ++i)
+        for (auto i = 0uz; i < count; ++i)
         {
             auto item = makeRandomAS();
             items.push_back(item->key());
@@ -87,39 +89,45 @@ TEST_F(SHAMapSyncTest, sync)
     SHAMap source{SHAMapType::FREE, f};
     SHAMap destination{SHAMapType::FREE, f2};
 
-    int const items = 10000;
-    for (int i = 0; i < items; ++i)
+    static constexpr auto kItemCount = 10000uz;
+    static constexpr auto kInvariantInterval = 100uz;
+    static constexpr auto kNodesToConfuse = 500uz;
+    static constexpr auto kMaxNodesPerRequest = 2048;
+
+    for (auto i = 0uz; i < kItemCount; ++i)
     {
         source.addItem(SHAMapNodeType::TnAccountState, makeRandomAS());
-        if (i % 100 == 0)
+        if (i % kInvariantInterval == 0)
             source.invariants();
     }
 
     source.invariants();
-    ASSERT_TRUE(confuseMap(source, 500));
+    ASSERT_TRUE(confuseMap(source, kNodesToConfuse));
     source.invariants();
 
     source.setImmutable();
 
-    int count = 0;
+    std::size_t count = 0;
     source.visitLeaves([&count]([[maybe_unused]] auto const& item) { ++count; });
-    EXPECT_EQ(count, items);
+    EXPECT_EQ(count, kItemCount);
 
     std::vector missingNodes;
-    source.walkMap(missingNodes, 2048);
+    source.walkMap(missingNodes, kMaxNodesPerRequest);
     EXPECT_TRUE(missingNodes.empty());
 
     destination.setSynching();
 
     {
-        std::vector> a;
+        std::vector a;
 
         ASSERT_TRUE(source.getNodeFat(SHAMapNodeID(), a, randBool(eng_), randInt(eng_, 2)));
 
         ASSERT_FALSE(a.empty()) << "NodeSize";
 
-        ASSERT_TRUE(
-            destination.addRootNode(source.getHash(), makeSlice(a[0].second), nullptr).isGood());
+        auto node = SHAMapTreeNode::makeFromWire(makeSlice(a[0].data));
+        if (!node)
+            FAIL() << "Could not create node";
+        ASSERT_TRUE(destination.addRootNode(source.getHash(), std::move(node), nullptr).isGood());
     }
 
     do
@@ -127,13 +135,13 @@ TEST_F(SHAMapSyncTest, sync)
         f.clock().advance(std::chrono::seconds(1));
 
         // get the list of nodes we know we need
-        auto nodesMissing = destination.getMissingNodes(2048, nullptr);
+        auto nodesMissing = destination.getMissingNodes(kMaxNodesPerRequest, nullptr);
 
         if (nodesMissing.empty())
             break;
 
         // get as many nodes as possible based on this information
-        std::vector> b;
+        std::vector b;
 
         for (auto& it : nodesMissing)
         {
@@ -155,7 +163,12 @@ TEST_F(SHAMapSyncTest, sync)
             // Keep failures fatal here because this loop is data-dependent.
             // non-deterministic number of times and the number of tests run
             // should be deterministic
-            if (!destination.addKnownNode(i.first, makeSlice(i.second), nullptr).isUseful())
+            auto node = SHAMapTreeNode::makeFromWire(makeSlice(i.data));
+            if (!node)
+                FAIL() << "Could not create node";
+            if (i.isLeaf != node->isLeaf())
+                FAIL() << "Node is not a leaf";
+            if (!destination.addKnownNode(i.nodeID, std::move(node), nullptr).isUseful())
                 FAIL() << "Known node was not useful";
         }
     } while (true);
diff --git a/src/tests/libxrpl/shamap/common.h b/src/tests/libxrpl/shamap/common.h
index 5b44f2b251..91401d2973 100644
--- a/src/tests/libxrpl/shamap/common.h
+++ b/src/tests/libxrpl/shamap/common.h
@@ -24,13 +24,13 @@ namespace xrpl::tests {
 class TestNodeFamily : public Family
 {
 private:
-    std::unique_ptr db_;
+    std::unique_ptr db_;
 
     std::shared_ptr fbCache_;
     std::shared_ptr tnCache_;
 
     TestStopwatch clock_;
-    NodeStore::DummyScheduler scheduler_;
+    node_store::DummyScheduler scheduler_;
 
     beast::Journal const j_;
 
@@ -49,17 +49,17 @@ public:
         Section testSection;
         testSection.set(Keys::kType, "memory");
         testSection.set(Keys::kPath, "SHAMap_test");
-        db_ = NodeStore::Manager::instance().makeDatabase(
+        db_ = node_store::Manager::instance().makeDatabase(
             megabytes(4), scheduler_, 1, testSection, j);
     }
 
-    NodeStore::Database&
+    node_store::Database&
     db() override
     {
         return *db_;
     }
 
-    [[nodiscard]] NodeStore::Database const&
+    [[nodiscard]] node_store::Database const&
     db() const override
     {
         return *db_;
diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp
index 87d00c58bf..ae291791d4 100644
--- a/src/tests/libxrpl/tx/AccountSet.cpp
+++ b/src/tests/libxrpl/tx/AccountSet.cpp
@@ -15,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -610,7 +611,7 @@ TEST(AccountSet, Ticket)
 
     // Get alice's current sequence - the ticket will be created at seq + 1
     std::uint32_t const aliceSeqBefore = env.getAccountRoot(alice.id()).getSequence();
-    std::uint32_t const ticketSeq = aliceSeqBefore + 1;
+    auto const ticketSeq = SeqProxy::rawTicket(aliceSeqBefore + 1);
 
     // Create a ticket
     EXPECT_EQ(env.submit(transactions::TicketCreateBuilder{alice, 1}, alice).ter, tesSUCCESS);
@@ -623,7 +624,9 @@ TEST(AccountSet, Ticket)
 
     // Try using a ticket that alice doesn't have
     EXPECT_EQ(
-        env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq + 1), alice)
+        env.submit(
+               transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value() + 1),
+               alice)
             .ter,
         terPRE_TICKET);
     env.close();
@@ -636,7 +639,9 @@ TEST(AccountSet, Ticket)
 
     // Actually use alice's ticket (noop AccountSet)
     EXPECT_EQ(
-        env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter,
+        env.submit(
+               transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value()), alice)
+            .ter,
         tesSUCCESS);
     env.close();
 
@@ -649,7 +654,9 @@ TEST(AccountSet, Ticket)
 
     // Try re-using a ticket that alice already used
     EXPECT_EQ(
-        env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter,
+        env.submit(
+               transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value()), alice)
+            .ter,
         tefNO_TICKET);
 }
 
diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp
index 4abf77f578..42270a91f1 100644
--- a/src/xrpld/app/consensus/RCLConsensus.cpp
+++ b/src/xrpld/app/consensus/RCLConsensus.cpp
@@ -1,6 +1,5 @@
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -17,8 +16,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 
@@ -32,6 +29,9 @@
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -389,7 +389,7 @@ RCLConsensus::Adaptor::onClose(
     if (!wrongLCL)
     {
         LedgerIndex const seq = prevLedger->header().seq + 1;
-        RCLCensorshipDetector::TxIDSeqVec proposed;
+        CensorshipDetector::TxIDSeqVec proposed;
 
         initialSet->visitLeaves(
             [&proposed, seq](boost::intrusive_ptr const& item) {
@@ -686,28 +686,17 @@ RCLConsensus::Adaptor::doAccept(
     //  close time reports, and update our clock.
     if ((mode == ConsensusMode::Proposing || mode == ConsensusMode::Observing) && !consensusFail)
     {
-        auto closeTime = rawCloseTimes.self;
-
-        JLOG(j_.info()) << "We closed at " << closeTime.time_since_epoch().count();
-        using usec64_t = std::chrono::duration;
-        auto closeTotal = std::chrono::duration_cast(closeTime.time_since_epoch());
+        JLOG(j_.info()) << "We closed at " << rawCloseTimes.self.time_since_epoch().count();
         int closeCount = 1;
-
         for (auto const& [t, v] : rawCloseTimes.peers)
         {
             JLOG(j_.info()) << std::to_string(v) << " time votes for "
                             << std::to_string(t.time_since_epoch().count());
             closeCount += v;
-            closeTotal += std::chrono::duration_cast(t.time_since_epoch()) * v;
         }
 
-        closeTotal += usec64_t(closeCount / 2);  // for round to nearest
-        closeTotal /= closeCount;
-
-        // Use signed times since we are subtracting
-        using duration = std::chrono::duration;
-        using time_point = std::chrono::time_point;
-        auto offset = time_point{closeTotal} - std::chrono::time_point_cast(closeTime);
+        // Median handles outliers better than mean.
+        auto const offset = medianCloseOffset(rawCloseTimes);
         JLOG(j_.info()) << "Our close offset is estimated at " << offset.count() << " ("
                         << closeCount << ")";
 
@@ -847,7 +836,7 @@ RCLConsensus::Adaptor::validate(RCLCxLedger const& ledger, RCLTxSet const& txns,
 
             // Report our server version every flag ledger:
             if (ledger.ledger->isVotingLedger())
-                v.setFieldU64(sfServerVersion, BuildInfo::getEncodedVersion());
+                v.setFieldU64(sfServerVersion, build_info::getEncodedVersion());
 
             // Report our load
             {
diff --git a/src/xrpld/app/consensus/RCLConsensus.h b/src/xrpld/app/consensus/RCLConsensus.h
index 4ffe18a7a8..4c07e7e646 100644
--- a/src/xrpld/app/consensus/RCLConsensus.h
+++ b/src/xrpld/app/consensus/RCLConsensus.h
@@ -1,20 +1,20 @@
 #pragma once
 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
-#include 
-#include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -83,7 +83,7 @@ class RCLConsensus
         std::atomic prevRoundTime_{std::chrono::milliseconds{0}};
         std::atomic mode_{ConsensusMode::Observing};
 
-        RCLCensorshipDetector censorshipDetector_;
+        CensorshipDetector censorshipDetector_;
         NegativeUNLVote nUnlVote_;
 
     public:
diff --git a/src/xrpld/app/consensus/RCLCxPeerPos.h b/src/xrpld/app/consensus/RCLCxPeerPos.h
index 050bdf6d36..078556dea8 100644
--- a/src/xrpld/app/consensus/RCLCxPeerPos.h
+++ b/src/xrpld/app/consensus/RCLCxPeerPos.h
@@ -1,11 +1,10 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/consensus/RCLValidations.cpp b/src/xrpld/app/consensus/RCLValidations.cpp
index 9d40e60b00..c587a04cf0 100644
--- a/src/xrpld/app/consensus/RCLValidations.cpp
+++ b/src/xrpld/app/consensus/RCLValidations.cpp
@@ -5,12 +5,12 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/consensus/RCLValidations.h b/src/xrpld/app/consensus/RCLValidations.h
index 7eadaf0dff..963b6c150e 100644
--- a/src/xrpld/app/consensus/RCLValidations.h
+++ b/src/xrpld/app/consensus/RCLValidations.h
@@ -1,10 +1,9 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/ledger/AcceptedLedger.h b/src/xrpld/app/ledger/AcceptedLedger.h
index 6e42d611d4..a8b78d08b0 100644
--- a/src/xrpld/app/ledger/AcceptedLedger.h
+++ b/src/xrpld/app/ledger/AcceptedLedger.h
@@ -57,6 +57,15 @@ public:
         return transactions_.end();
     }
 
+    /**
+     * The last accepted transaction. Precondition: size() > 0.
+     */
+    [[nodiscard]] AcceptedLedgerTx const&
+    back() const
+    {
+        return *transactions_.back();
+    }
+
 private:
     std::shared_ptr ledger_;
     std::vector> transactions_;
diff --git a/src/xrpld/app/ledger/AccountStateSF.h b/src/xrpld/app/ledger/AccountStateSF.h
index f5117db4d4..5c1d260c9a 100644
--- a/src/xrpld/app/ledger/AccountStateSF.h
+++ b/src/xrpld/app/ledger/AccountStateSF.h
@@ -18,7 +18,7 @@ namespace xrpl {
 class AccountStateSF : public SHAMapSyncFilter
 {
 public:
-    AccountStateSF(NodeStore::Database& db, AbstractFetchPackContainer& fp) : db_(db), fp_(fp)
+    AccountStateSF(node_store::Database& db, AbstractFetchPackContainer& fp) : db_(db), fp_(fp)
     {
     }
 
@@ -34,7 +34,7 @@ public:
     getNode(SHAMapHash const& nodeHash) const override;
 
 private:
-    NodeStore::Database& db_;
+    node_store::Database& db_;
     AbstractFetchPackContainer& fp_;
 };
 
diff --git a/src/xrpld/app/ledger/InboundLedger.h b/src/xrpld/app/ledger/InboundLedger.h
index d8a9ddf46b..9a7ee510f6 100644
--- a/src/xrpld/app/ledger/InboundLedger.h
+++ b/src/xrpld/app/ledger/InboundLedger.h
@@ -6,7 +6,6 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -24,7 +23,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 
@@ -136,7 +135,7 @@ private:
     addPeers();
 
     void
-    tryDB(NodeStore::Database& srcDB);
+    tryDB(node_store::Database& srcDB);
 
     void
     done();
@@ -154,16 +153,19 @@ private:
     processData(std::shared_ptr peer, protocol::TMLedgerData const& data);
 
     bool
-    takeHeader(std::string const& data);
+    takeHeader(std::string_view data);
 
     void
-    receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode&);
+    receiveNode(
+        std::shared_ptr const& peer,
+        protocol::TMLedgerData const& packet,
+        SHAMapAddNode& san);
 
     bool
-    takeTxRootNode(Slice const& data, SHAMapAddNode&);
+    takeTxRootNode(std::string_view data, SHAMapAddNode& san);
 
     bool
-    takeAsRootNode(Slice const& data, SHAMapAddNode&);
+    takeAsRootNode(std::string_view data, SHAMapAddNode& san);
 
     std::vector
     neededTxHashes(int max, SHAMapSyncFilter const* filter) const;
diff --git a/src/xrpld/app/ledger/LedgerNodeHelpers.h b/src/xrpld/app/ledger/LedgerNodeHelpers.h
new file mode 100644
index 0000000000..9df9ab06c7
--- /dev/null
+++ b/src/xrpld/app/ledger/LedgerNodeHelpers.h
@@ -0,0 +1,52 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace protocol {
+class TMLedgerNode;
+}  // namespace protocol
+
+namespace xrpl {
+
+/**
+ * @brief Deserializes a SHAMapTreeNode from wire format data.
+ *
+ * This function attempts to create a SHAMapTreeNode from the provided data string. If the data is
+ * malformed or deserialization fails, the function returns a nullptr instead of throwing an
+ * exception.
+ *
+ * @param data The serialized node data in wire format.
+ * @return The deserialized tree node if successful, or a nullptr if deserialization fails.
+ */
+[[nodiscard]] SHAMapTreeNodePtr
+getTreeNode(std::string_view data);
+
+/**
+ * @brief Extracts or reconstructs the SHAMapNodeID from a ledger node proto message.
+ *
+ * This function retrieves the SHAMapNodeID for a tree node, with behavior that depends on which
+ * field is set and the node type (inner vs. leaf).
+ *
+ * When the legacy `nodeid` field is set in the message:
+ * - For all nodes: Deserializes the node ID from the field.
+ * - For leaf nodes: Validates that the node ID is consistent with the leaf's key.
+ *
+ * When the new `id` or `depth` field is set in the message:
+ * - For inner nodes: Deserializes the node ID from the `id` field.
+ * - For leaf nodes: Reconstructs the node ID using both the depth from the `depth` field and the
+ *   key from the leaf node's item.
+ * Note that root nodes may be inner nodes or leaf nodes.
+ *
+ * @param ledgerNode The validated protocol message containing the ledger node data.
+ * @param treeNode The deserialized tree node (inner or leaf node).
+ * @return An optional containing the node ID if extraction/reconstruction succeeds, or std::nullopt
+ *         if the required fields are missing or validation fails.
+ */
+[[nodiscard]] std::optional
+getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& treeNode);
+
+}  // namespace xrpl
diff --git a/src/xrpld/app/ledger/LedgerReplayer.h b/src/xrpld/app/ledger/LedgerReplayer.h
index 6feb187df6..b2806ee813 100644
--- a/src/xrpld/app/ledger/LedgerReplayer.h
+++ b/src/xrpld/app/ledger/LedgerReplayer.h
@@ -24,7 +24,7 @@ namespace test {
 class LedgerReplayClient;
 }  // namespace test
 
-namespace LedgerReplayParameters {
+namespace ledger_replay_parameters {
 // timeout value for LedgerReplayTask
 constexpr auto kTaskTimeout = std::chrono::milliseconds{500};
 
@@ -53,7 +53,7 @@ constexpr std::uint32_t kMaxTaskSize = 256;
 
 // to limit the number of LedgerReplay related jobs in JobQueue
 constexpr std::uint32_t kMaxQueuedTasks = 100;
-}  // namespace LedgerReplayParameters
+}  // namespace ledger_replay_parameters
 
 /**
  * Manages the lifetime of ledger replay tasks.
diff --git a/src/xrpld/app/ledger/LedgerToJson.h b/src/xrpld/app/ledger/LedgerToJson.h
index 1eac4d68f1..e1172e897a 100644
--- a/src/xrpld/app/ledger/LedgerToJson.h
+++ b/src/xrpld/app/ledger/LedgerToJson.h
@@ -18,7 +18,7 @@ struct LedgerFill
 {
     LedgerFill(
         ReadView const& l,
-        RPC::Context const* ctx,
+        rpc::Context const* ctx,
         int o = 0,
         std::vector q = {})
         : ledger(l), options(o), txQueue(std::move(q)), context(ctx)
@@ -40,7 +40,7 @@ struct LedgerFill
     ReadView const& ledger;
     int options;
     std::vector txQueue;
-    RPC::Context const* context;
+    rpc::Context const* context;
     std::optional closeTime;
 };
 
diff --git a/src/xrpld/app/ledger/TransactionStateSF.h b/src/xrpld/app/ledger/TransactionStateSF.h
index a3f7e7f55a..b8c1b2c835 100644
--- a/src/xrpld/app/ledger/TransactionStateSF.h
+++ b/src/xrpld/app/ledger/TransactionStateSF.h
@@ -18,7 +18,7 @@ namespace xrpl {
 class TransactionStateSF : public SHAMapSyncFilter
 {
 public:
-    TransactionStateSF(NodeStore::Database& db, AbstractFetchPackContainer& fp) : db_(db), fp_(fp)
+    TransactionStateSF(node_store::Database& db, AbstractFetchPackContainer& fp) : db_(db), fp_(fp)
     {
     }
 
@@ -34,7 +34,7 @@ public:
     getNode(SHAMapHash const& nodeHash) const override;
 
 private:
-    NodeStore::Database& db_;
+    node_store::Database& db_;
     AbstractFetchPackContainer& fp_;
 };
 
diff --git a/src/xrpld/app/ledger/detail/InboundLedger.cpp b/src/xrpld/app/ledger/detail/InboundLedger.cpp
index 627a5d574f..246c7d567b 100644
--- a/src/xrpld/app/ledger/detail/InboundLedger.cpp
+++ b/src/xrpld/app/ledger/detail/InboundLedger.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -44,8 +45,8 @@
 #include 
 #include 
 #include 
-#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -224,7 +225,7 @@ InboundLedger::neededStateHashes(int max, SHAMapSyncFilter const* filter) const
 // See how much of the ledger data is stored locally
 // Data found in a fetch pack will be stored
 void
-InboundLedger::tryDB(NodeStore::Database& srcDB)
+InboundLedger::tryDB(node_store::Database& srcDB)
 {
     if (!haveHeader_)
     {
@@ -779,7 +780,7 @@ InboundLedger::filterNodes(
  */
 // data must not have hash prefix
 bool
-InboundLedger::takeHeader(std::string const& data)
+InboundLedger::takeHeader(std::string_view data)
 {
     // Return value: true=normal, false=bad data
     JLOG(journal_.trace()) << "got header acquiring ledger " << hash_;
@@ -825,7 +826,10 @@ InboundLedger::takeHeader(std::string const& data)
  * Call with a lock
  */
 void
-InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode& san)
+InboundLedger::receiveNode(
+    std::shared_ptr const& peer,
+    protocol::TMLedgerData const& packet,
+    SHAMapAddNode& san)
 {
     if (!haveHeader_)
     {
@@ -868,32 +872,47 @@ InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode&
     {
         auto const f = filter.get();
 
-        for (auto const& node : packet.nodes())
+        for (auto const& ledgerNode : packet.nodes())
         {
-            auto const nodeID = deserializeSHAMapNodeID(node.nodeid());
+            auto treeNode = getTreeNode(ledgerNode.nodedata());
+            if (!treeNode)
+            {
+                JLOG(journal_.warn())
+                    << "Got invalid node data for ledger " << hash_ << " from peer " << peer->id();
+                peer->charge(resource::kFeeInvalidData, "ledger_node.node_data invalid");
+                san.incInvalid();
+                return;
+            }
 
+            auto const nodeID = getSHAMapNodeID(ledgerNode, *treeNode);
             if (!nodeID)
-                throw std::runtime_error("data does not properly deserialize");
-
-            if (nodeID->isRoot())
             {
-                san += map.addRootNode(rootHash, makeSlice(node.nodedata()), f);
-            }
-            else
-            {
-                san += map.addKnownNode(*nodeID, makeSlice(node.nodedata()), f);
+                JLOG(journal_.warn())
+                    << "Got invalid node id for ledger " << hash_ << " from peer " << peer->id();
+                peer->charge(resource::kFeeInvalidData, "ledger_node.node_id invalid");
+                san.incInvalid();
+                return;
             }
 
-            if (!san.isGood())
+            auto const result = nodeID->isRoot()
+                ? map.addRootNode(rootHash, std::move(treeNode), f)
+                : map.addKnownNode(*nodeID, std::move(treeNode), f);
+            san += result;
+
+            if (result.isInvalid())
             {
-                JLOG(journal_.warn()) << "Received bad node data";
+                JLOG(journal_.warn()) << "Got invalid node " << *nodeID << " for ledger " << hash_
+                                      << " from peer " << peer->id();
+                peer->charge(resource::kFeeInvalidData, "ledger_node invalid");
                 return;
             }
         }
     }
     catch (std::exception const& e)
     {
-        JLOG(journal_.error()) << "Received bad node data: " << e.what();
+        // If we get here it is not necessarily because the node was bad, so don't charge the peer.
+        JLOG(journal_.error()) << "Could not process node for ledger " << hash_ << " from peer "
+                               << peer->id() << ": " << e.what();
         san.incInvalid();
         return;
     }
@@ -922,7 +941,7 @@ InboundLedger::receiveNode(protocol::TMLedgerData const& packet, SHAMapAddNode&
  * Call with a lock
  */
 bool
-InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san)
+InboundLedger::takeAsRootNode(std::string_view data, SHAMapAddNode& san)
 {
     if (failed_ || haveState_)
     {
@@ -938,10 +957,19 @@ InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san)
         // LCOV_EXCL_STOP
     }
 
+    auto treeNode = getTreeNode(data);
+    if (!treeNode)
+    {
+        JLOG(journal_.warn()) << "Got invalid AS root node data for ledger " << hash_;
+        san.incInvalid();
+        return false;
+    }
+
     AccountStateSF filter(ledger_->stateMap().family().db(), app_.getLedgerMaster());
-    san +=
-        ledger_->stateMap().addRootNode(SHAMapHash{ledger_->header().accountHash}, data, &filter);
-    return san.isGood();
+    auto const result = ledger_->stateMap().addRootNode(
+        SHAMapHash{ledger_->header().accountHash}, std::move(treeNode), &filter);
+    san += result;
+    return !result.isInvalid();
 }
 
 /**
@@ -949,7 +977,7 @@ InboundLedger::takeAsRootNode(Slice const& data, SHAMapAddNode& san)
  * Call with a lock
  */
 bool
-InboundLedger::takeTxRootNode(Slice const& data, SHAMapAddNode& san)
+InboundLedger::takeTxRootNode(std::string_view data, SHAMapAddNode& san)
 {
     if (failed_ || haveTransactions_)
     {
@@ -965,9 +993,19 @@ InboundLedger::takeTxRootNode(Slice const& data, SHAMapAddNode& san)
         // LCOV_EXCL_STOP
     }
 
+    auto treeNode = getTreeNode(data);
+    if (!treeNode)
+    {
+        JLOG(journal_.warn()) << "Got invalid TX root node data for ledger " << hash_;
+        san.incInvalid();
+        return false;
+    }
+
     TransactionStateSF filter(ledger_->txMap().family().db(), app_.getLedgerMaster());
-    san += ledger_->txMap().addRootNode(SHAMapHash{ledger_->header().txHash}, data, &filter);
-    return san.isGood();
+    auto const result = ledger_->txMap().addRootNode(
+        SHAMapHash{ledger_->header().txHash}, std::move(treeNode), &filter);
+    san += result;
+    return !result.isInvalid();
 }
 
 std::vector
@@ -1030,7 +1068,7 @@ InboundLedger::gotData(
  * Returns the number of useful nodes
  */
 // VFALCO NOTE, it is not necessary to pass the entire Peer,
-//              we can get away with just a Resource::Consumer endpoint.
+//              we can get away with just a resource::Consumer endpoint.
 //
 //        TODO Change peer to Consumer
 //
@@ -1042,7 +1080,7 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co
         if (packet.nodes().empty())
         {
             JLOG(journal_.warn()) << peer->id() << ": empty header data";
-            peer->charge(Resource::kFeeMalformedRequest, "ledger_data empty header");
+            peer->charge(resource::kFeeMalformedRequest, "ledger_data empty header");
             return -1;
         }
 
@@ -1057,7 +1095,7 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co
                 if (!takeHeader(packet.nodes(0).nodedata()))
                 {
                     JLOG(journal_.warn()) << "Got invalid header data";
-                    peer->charge(Resource::kFeeMalformedRequest, "ledger_data invalid header");
+                    peer->charge(resource::kFeeMalformedRequest, "ledger_data invalid header");
                     return -1;
                 }
 
@@ -1065,22 +1103,35 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co
             }
 
             if (!haveState_ && (packet.nodes().size() > 1) &&
-                !takeAsRootNode(makeSlice(packet.nodes(1).nodedata()), san))
+                !takeAsRootNode(packet.nodes(1).nodedata(), san))
             {
-                JLOG(journal_.warn()) << "Included AS root invalid";
+                JLOG(journal_.warn()) << "Included AS root invalid for ledger " << hash_
+                                      << " from peer " << peer->id();
+                if (san.isInvalid())
+                {
+                    peer->charge(resource::kFeeInvalidData, "ledger_data invalid AS root");
+                    return -1;
+                }
             }
 
             if (!haveTransactions_ && (packet.nodes().size() > 2) &&
-                !takeTxRootNode(makeSlice(packet.nodes(2).nodedata()), san))
+                !takeTxRootNode(packet.nodes(2).nodedata(), san))
             {
-                JLOG(journal_.warn()) << "Included TX root invalid";
+                JLOG(journal_.warn()) << "Included TX root invalid for ledger " << hash_
+                                      << " from peer " << peer->id();
+                if (san.isInvalid())
+                {
+                    peer->charge(resource::kFeeInvalidData, "ledger_data invalid TX root");
+                    return -1;
+                }
             }
         }
         catch (std::exception const& ex)
         {
-            JLOG(journal_.warn()) << "Included AS/TX root invalid: " << ex.what();
+            JLOG(journal_.warn()) << "Included AS/TX root invalid for ledger " << hash_
+                                  << " from peer " << peer->id() << ": " << ex.what();
             using namespace std::string_literals;
-            peer->charge(Resource::kFeeInvalidData, "ledger_data "s + ex.what());
+            peer->charge(resource::kFeeInvalidData, "ledger_data "s + ex.what());
             return -1;
         }
 
@@ -1096,30 +1147,24 @@ InboundLedger::processData(std::shared_ptr peer, protocol::TMLedgerData co
         if (packet.nodes().empty())
         {
             JLOG(journal_.info()) << peer->id() << ": response with no nodes";
-            peer->charge(Resource::kFeeMalformedRequest, "ledger_data no nodes");
+            peer->charge(resource::kFeeMalformedRequest, "ledger_data no nodes");
             return -1;
         }
 
         ScopedLockType const sl(mtx_);
 
-        // Verify node IDs and data are complete
-        for (auto const& node : packet.nodes())
-        {
-            if (!node.has_nodeid() || !node.has_nodedata())
-            {
-                JLOG(journal_.warn()) << "Got bad node";
-                peer->charge(Resource::kFeeMalformedRequest, "ledger_data bad node");
-                return -1;
-            }
-        }
-
         SHAMapAddNode san;
-        receiveNode(packet, san);
+        receiveNode(peer, packet, san);
 
         JLOG(journal_.debug()) << "Ledger "
                                << ((packet.type() == protocol::liTX_NODE) ? "TX" : "AS")
                                << " node stats: " << san.get();
 
+        // `san` accumulates across the whole packet, so `isInvalid()` (bad_ > 0) does not mean the
+        // packet had no useful nodes: credit whatever good/useful nodes were sent rather than
+        // discarding everything because one node in an otherwise-good packet was bad.
+        // Note: Peer charges for invalid/malformed data are issued from within receiveNode at the
+        // exact failure site, so the peer is only charged for problems they are responsible for.
         if (san.isUseful())
             progress_ = true;
 
diff --git a/src/xrpld/app/ledger/detail/InboundLedgers.cpp b/src/xrpld/app/ledger/detail/InboundLedgers.cpp
index dc361694cf..4d565ca674 100644
--- a/src/xrpld/app/ledger/detail/InboundLedgers.cpp
+++ b/src/xrpld/app/ledger/detail/InboundLedgers.cpp
@@ -2,13 +2,13 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -252,23 +252,17 @@ public:
         Serializer s;
         try
         {
-            for (int i = 0; i < packetPtr->nodes().size(); ++i)
+            for (auto const& ledgerNode : packetPtr->nodes())
             {
-                auto const& node = packetPtr->nodes(i);
-
-                if (!node.has_nodeid() || !node.has_nodedata())
-                    return;
-
-                auto newNode = SHAMapTreeNode::makeFromWire(makeSlice(node.nodedata()));
-
-                if (!newNode)
+                auto const treeNode = getTreeNode(ledgerNode.nodedata());
+                if (!treeNode)
                     return;
 
                 s.erase();
-                newNode->serializeWithPrefix(s);
+                treeNode->serializeWithPrefix(s);
 
                 app_.getLedgerMaster().addFetchPack(
-                    newNode->getHash().asUInt256(), std::make_shared(s.begin(), s.end()));
+                    treeNode->getHash().asUInt256(), std::make_shared(s.begin(), s.end()));
             }
         }
         catch (std::exception const&)  // NOLINT(bugprone-empty-catch)
diff --git a/src/xrpld/app/ledger/detail/InboundTransactions.cpp b/src/xrpld/app/ledger/detail/InboundTransactions.cpp
index 9b50a1584f..62897fe617 100644
--- a/src/xrpld/app/ledger/detail/InboundTransactions.cpp
+++ b/src/xrpld/app/ledger/detail/InboundTransactions.cpp
@@ -1,11 +1,11 @@
 #include 
 
+#include 
 #include 
 #include 
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -14,6 +14,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 
@@ -137,34 +138,45 @@ public:
 
         if (ta == nullptr)
         {
-            peer->charge(Resource::kFeeUselessData, "ledger_data");
+            peer->charge(resource::kFeeUselessData, "ledger_data useless");
             return;
         }
 
-        std::vector> data;
+        std::vector> data;
         data.reserve(packet.nodes().size());
 
-        for (auto const& node : packet.nodes())
+        for (auto const& ledgerNode : packet.nodes())
         {
-            if (!node.has_nodeid() || !node.has_nodedata())
+            auto treeNode = getTreeNode(ledgerNode.nodedata());
+            if (!treeNode)
             {
-                peer->charge(Resource::kFeeMalformedRequest, "ledger_data");
+                JLOG(j_.warn()) << "Got invalid node data for TX set " << hash << " from peer "
+                                << peer->id();
+                peer->charge(resource::kFeeInvalidData, "ledger_node.node_data invalid");
                 return;
             }
 
-            auto const id = deserializeSHAMapNodeID(node.nodeid());
-
-            if (!id)
+            auto const nodeID = getSHAMapNodeID(ledgerNode, *treeNode);
+            if (!nodeID)
             {
-                peer->charge(Resource::kFeeInvalidData, "ledger_data");
+                JLOG(j_.warn()) << "Got invalid node id for TX set " << hash << " from peer "
+                                << peer->id();
+                peer->charge(resource::kFeeInvalidData, "ledger_node.node_id invalid");
                 return;
             }
 
-            data.emplace_back(*id, makeSlice(node.nodedata()));
+            data.emplace_back(*nodeID, std::move(treeNode));
         }
 
-        if (!ta->takeNodes(data, peer).isUseful())
-            peer->charge(Resource::kFeeUselessData, "ledger_data not useful");
+        auto const san = ta->takeNodes(std::move(data), peer);
+        if (san.isInvalid())
+        {
+            peer->charge(resource::kFeeInvalidData, "ledger_data invalid");
+        }
+        else if (!san.isUseful())
+        {
+            peer->charge(resource::kFeeUselessData, "ledger_data useless");
+        }
     }
 
     void
diff --git a/src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp b/src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp
index 7ac85b892e..344d5cb8fc 100644
--- a/src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerDeltaAcquire.cpp
@@ -43,10 +43,10 @@ LedgerDeltaAcquire::LedgerDeltaAcquire(
     : TimeoutCounter(
           app,
           ledgerHash,
-          LedgerReplayParameters::kSubTaskTimeout,
+          ledger_replay_parameters::kSubTaskTimeout,
           {.jobType = JtReplayTask,
            .jobName = "LedReplDelta",
-           .jobLimit = LedgerReplayParameters::kMaxQueuedTasks},
+           .jobLimit = ledger_replay_parameters::kMaxQueuedTasks},
           app.getJournal("LedgerReplayDelta"))
     , inboundLedgers_(inboundLedgers)
     , ledgerSeq_(ledgerSeq)
@@ -101,10 +101,10 @@ LedgerDeltaAcquire::trigger(std::size_t limit, ScopedLockType& sl)
                 }
                 else
                 {
-                    if (++noFeaturePeerCount_ >= LedgerReplayParameters::kMaxNoFeaturePeerCount)
+                    if (++noFeaturePeerCount_ >= ledger_replay_parameters::kMaxNoFeaturePeerCount)
                     {
                         JLOG(journal_.debug()) << "Fall back for " << hash_;
-                        timerInterval_ = LedgerReplayParameters::kSubTaskFallbackTimeout;
+                        timerInterval_ = ledger_replay_parameters::kSubTaskFallbackTimeout;
                         fallBack_ = true;
                     }
                 }
@@ -119,7 +119,7 @@ void
 LedgerDeltaAcquire::onTimer(bool progress, ScopedLockType& sl)
 {
     JLOG(journal_.trace()) << "timeouts_=" << timeouts_ << " for " << hash_;
-    if (timeouts_ > LedgerReplayParameters::kSubTaskMaxTimeouts)
+    if (timeouts_ > ledger_replay_parameters::kSubTaskMaxTimeouts)
     {
         failed_ = true;
         JLOG(journal_.debug()) << "too many timeouts " << hash_;
diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
index 2bd83b0f18..83d76bcd2a 100644
--- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
@@ -653,7 +653,7 @@ LedgerMaster::tryFill(std::shared_ptr ledger)
     std::uint32_t minHas = seq;
     std::uint32_t maxHas = seq;
 
-    NodeStore::Database& nodeStore{app_.getNodeStore()};
+    node_store::Database& nodeStore{app_.getNodeStore()};
     while (!app_.getJobQueue().isStopping() && seq > 0)
     {
         {
@@ -1042,8 +1042,8 @@ LedgerMaster::checkAccept(std::shared_ptr const& ledger)
                 if (v->isFieldPresent(sfServerVersion))
                 {
                     auto version = v->getFieldU64(sfServerVersion);
-                    higherVersionCount += BuildInfo::isNewerVersion(version) ? 1 : 0;
-                    xrpldCount += BuildInfo::isXrpldVersion(version) ? 1 : 0;
+                    higherVersionCount += build_info::isNewerVersion(version) ? 1 : 0;
+                    xrpldCount += build_info::isXrpldVersion(version) ? 1 : 0;
                 }
             }
             // We report only if (1) we have accumulated validation messages
@@ -2088,21 +2088,21 @@ LedgerMaster::makeFetchPack(
     if (!have)
     {
         JLOG(journal_.info()) << "Peer requests fetch pack for ledger we don't have: " << have;
-        peer->charge(Resource::kFeeRequestNoReply, "get_object ledger");
+        peer->charge(resource::kFeeRequestNoReply, "get_object ledger");
         return;
     }
 
     if (have->open())
     {
         JLOG(journal_.warn()) << "Peer requests fetch pack from open ledger: " << have;
-        peer->charge(Resource::kFeeMalformedRequest, "get_object ledger open");
+        peer->charge(resource::kFeeMalformedRequest, "get_object ledger open");
         return;
     }
 
     if (have->header().seq < getEarliestFetch())
     {
         JLOG(journal_.debug()) << "Peer requests fetch pack that is too early";
-        peer->charge(Resource::kFeeMalformedRequest, "get_object ledger early");
+        peer->charge(resource::kFeeMalformedRequest, "get_object ledger early");
         return;
     }
 
@@ -2112,7 +2112,7 @@ LedgerMaster::makeFetchPack(
     {
         JLOG(journal_.info()) << "Peer requests fetch pack for ledger whose predecessor we "
                               << "don't have: " << have;
-        peer->charge(Resource::kFeeRequestNoReply, "get_object ledger no parent");
+        peer->charge(resource::kFeeRequestNoReply, "get_object ledger no parent");
         return;
     }
 
diff --git a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp
new file mode 100644
index 0000000000..230c802022
--- /dev/null
+++ b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp
@@ -0,0 +1,88 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+SHAMapTreeNodePtr
+getTreeNode(std::string_view data)
+{
+    auto const slice = makeSlice(data);
+    try
+    {
+        return SHAMapTreeNode::makeFromWire(slice);
+    }
+    catch (std::exception const&)
+    {
+        return {};
+    }
+}
+
+std::optional
+getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& treeNode)
+{
+    if (ledgerNode.has_id() || ledgerNode.has_depth())
+    {
+        // Reject ambiguous messages that mix the legacy and new reference fields.
+        if (ledgerNode.has_nodeid())
+            return std::nullopt;
+
+        if (treeNode.isInner())
+        {
+            if (!ledgerNode.has_id())
+                return std::nullopt;
+
+            REACHABLE("xrpl::getSHAMapNodeID : inner node ID from id field");
+            return deserializeSHAMapNodeID(ledgerNode.id());
+        }
+
+        if (treeNode.isLeaf())
+        {
+            SOMETIMES(
+                ledgerNode.has_depth() && ledgerNode.depth() > SHAMap::kLeafDepth,
+                "xrpl::getSHAMapNodeID : leaf depth exceeds max");
+            if (!ledgerNode.has_depth() || ledgerNode.depth() > SHAMap::kLeafDepth)
+                return std::nullopt;
+
+            auto const key = leafKey(treeNode);
+            REACHABLE("xrpl::getSHAMapNodeID : leaf node ID reconstructed from depth");
+            return SHAMapNodeID::createID(ledgerNode.depth(), key);
+        }
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::getSHAMapNodeID : tree node is neither inner nor leaf");
+        return std::nullopt;
+        // LCOV_EXCL_STOP
+    }
+
+    if (!ledgerNode.has_nodeid())
+        return std::nullopt;
+
+    auto nodeID = deserializeSHAMapNodeID(ledgerNode.nodeid());
+    if (!nodeID.has_value())
+        return std::nullopt;
+
+    if (treeNode.isLeaf())
+    {
+        auto const key = leafKey(treeNode);
+        SOMETIMES(
+            !nodeID->isPrefixOf(key),
+            "xrpl::getSHAMapNodeID : legacy leaf ID inconsistent with key");
+        if (!nodeID->isPrefixOf(key))
+            return std::nullopt;
+    }
+
+    return nodeID;
+}
+
+}  // namespace xrpl
diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp
index 07738d99f4..6ed4a296ac 100644
--- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp
@@ -101,42 +101,54 @@ LedgerReplayMsgHandler::processProofPathRequest(
     return reply;
 }
 
-bool
+ReplayMsgStatus
 LedgerReplayMsgHandler::processProofPathResponse(
     std::shared_ptr const& msg)
 {
     protocol::TMProofPathResponse const& reply = *msg;
-    if (reply.has_error() || !reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() ||
+    if (reply.has_error())
+    {
+        JLOG(journal_.debug()) << "ProofPathResponse: peer reported error";
+        return ReplayMsgStatus::BadData;
+    }
+    if (!reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() ||
         !reply.has_ledgerheader() || reply.path_size() == 0 ||
         reply.ledgerhash().size() != uint256::size() || reply.key().size() != uint256::size())
     {
-        JLOG(journal_.debug()) << "Bad message: Error reply";
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (missing or wrong-size fields)";
+        return ReplayMsgStatus::Malformed;
     }
 
     if (reply.type() != protocol::lmACCOUNT_STATE)
     {
-        JLOG(journal_.debug()) << "Bad message: we only support the state ShaMap for now";
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (unsupported map type)";
+        return ReplayMsgStatus::Malformed;
     }
 
     // deserialize the header
-    auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()});
+    LedgerHeader info;
+    try
+    {
+        info = deserializeHeader(makeSlice(reply.ledgerheader()));
+    }
+    catch (std::exception const& e)
+    {
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed header (" << e.what() << ")";
+        return ReplayMsgStatus::Malformed;
+    }
     uint256 const replyHash = uint256::fromRaw(reply.ledgerhash());
     if (calculateLedgerHash(info) != replyHash)
     {
-        JLOG(journal_.debug()) << "Bad message: Hash mismatch";
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (hash mismatch)";
+        return ReplayMsgStatus::Malformed;
     }
     info.hash = replyHash;
 
     uint256 const key = uint256::fromRaw(reply.key());
     if (key != keylet::skip().key)
     {
-        JLOG(journal_.debug()) << "Bad message: we only support the short skip list for now. "
-                                  "Key in reply "
-                               << key;
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (unexpected key " << key << ")";
+        return ReplayMsgStatus::Malformed;
     }
 
     // verify the skip list
@@ -149,26 +161,35 @@ LedgerReplayMsgHandler::processProofPathResponse(
 
     if (!SHAMap::verifyProofPath(info.accountHash, key, path))
     {
-        JLOG(journal_.debug()) << "Bad message: Proof path verify failed";
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (proof path verify failed)";
+        return ReplayMsgStatus::Malformed;
     }
 
     // deserialize the SHAMapItem
-    auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.front()));
+    SHAMapTreeNodePtr node;
+    try
+    {
+        node = SHAMapTreeNode::makeFromWire(makeSlice(path.front()));
+    }
+    catch (std::exception const& e)
+    {
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed SHAMap node (" << e.what() << ")";
+        return ReplayMsgStatus::Malformed;
+    }
     if (!node || !node->isLeaf())
     {
-        JLOG(journal_.debug()) << "Bad message: Cannot deserialize";
-        return false;
+        JLOG(journal_.debug()) << "ProofPathResponse: malformed (not a leaf node)";
+        return ReplayMsgStatus::Malformed;
     }
 
     if (auto item = safeDowncast(node.get())->peekItem())
     {
         replayer_.gotSkipList(info, item);
-        return true;
+        return ReplayMsgStatus::Ok;
     }
 
-    JLOG(journal_.debug()) << "Bad message: Cannot get ShaMapItem";
-    return false;
+    JLOG(journal_.debug()) << "ProofPathResponse: malformed (no SHAMapItem)";
+    return ReplayMsgStatus::Malformed;
 }
 
 protocol::TMReplayDeltaResponse
@@ -210,24 +231,38 @@ LedgerReplayMsgHandler::processReplayDeltaRequest(
     return reply;
 }
 
-bool
+ReplayMsgStatus
 LedgerReplayMsgHandler::processReplayDeltaResponse(
     std::shared_ptr const& msg)
 {
     protocol::TMReplayDeltaResponse const& reply = *msg;
-    if (reply.has_error() || !reply.has_ledgerheader() || !reply.has_ledgerhash() ||
+    if (reply.has_error())
+    {
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: peer reported error";
+        return ReplayMsgStatus::BadData;
+    }
+    if (!reply.has_ledgerheader() || !reply.has_ledgerhash() ||
         reply.ledgerhash().size() != uint256::size())
     {
-        JLOG(journal_.debug()) << "Bad message: Error reply";
-        return false;
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (missing or wrong-size fields)";
+        return ReplayMsgStatus::Malformed;
     }
 
-    auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()});
+    LedgerHeader info;
+    try
+    {
+        info = deserializeHeader(makeSlice(reply.ledgerheader()));
+    }
+    catch (std::exception const& e)
+    {
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed header (" << e.what() << ")";
+        return ReplayMsgStatus::Malformed;
+    }
     uint256 const replyHash = uint256::fromRaw(reply.ledgerhash());
     if (calculateLedgerHash(info) != replyHash)
     {
-        JLOG(journal_.debug()) << "Bad message: Hash mismatch";
-        return false;
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (hash mismatch)";
+        return ReplayMsgStatus::Malformed;
     }
     info.hash = replyHash;
 
@@ -252,8 +287,8 @@ LedgerReplayMsgHandler::processReplayDeltaResponse(
             auto tx = std::make_shared(txSit);
             if (!tx)
             {
-                JLOG(journal_.debug()) << "Bad message: Cannot deserialize";
-                return false;
+                JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx deserialize)";
+                return ReplayMsgStatus::Malformed;
             }
             auto tid = tx->getTransactionID();
             STObject meta(metaSit, sfMetadata);
@@ -262,25 +297,26 @@ LedgerReplayMsgHandler::processReplayDeltaResponse(
             if (!txMap.addGiveItem(
                     SHAMapNodeType::TnTransactionMd, makeShamapitem(tid, shaMapItemData.slice())))
             {
-                JLOG(journal_.debug()) << "Bad message: Cannot deserialize";
-                return false;
+                JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx map add)";
+                return ReplayMsgStatus::Malformed;
             }
         }
     }
-    catch (std::exception const&)
+    catch (std::exception const& e)
     {
-        JLOG(journal_.debug()) << "Bad message: Cannot deserialize";
-        return false;
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed transactions (" << e.what()
+                               << ")";
+        return ReplayMsgStatus::Malformed;
     }
 
     if (txMap.getHash().asUInt256() != info.txHash)
     {
-        JLOG(journal_.debug()) << "Bad message: Transactions verify failed";
-        return false;
+        JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (transactions verify failed)";
+        return ReplayMsgStatus::Malformed;
     }
 
     replayer_.gotReplayDelta(info, std::move(orderedTxns));
-    return true;
+    return ReplayMsgStatus::Ok;
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h
index 5a8951fb25..ba989e2586 100644
--- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h
+++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h
@@ -10,6 +10,15 @@ namespace xrpl {
 class Application;
 class LedgerReplayer;
 
+/**
+ * Outcome of processing an incoming ledger-replay response.
+ */
+enum class ReplayMsgStatus {
+    Ok,         ///< Accepted.
+    BadData,    ///< Peer reported has_error() (legitimate "cannot fulfill" signal).
+    Malformed,  ///< Protocol-level violation; no honest peer would produce this.
+};
+
 class LedgerReplayMsgHandler final
 {
 public:
@@ -19,31 +28,31 @@ public:
     /**
      * Process TMProofPathRequest and return TMProofPathResponse
      * @note check has_error() and error() of the response for error
+     * @return TMProofPathResponse with the proof path, or with error() set if
+     *         the request cannot be fulfilled
      */
     protocol::TMProofPathResponse
     processProofPathRequest(std::shared_ptr const& msg);
 
     /**
      * Process TMProofPathResponse
-     * @return false if the response message has bad format or bad data;
-     *         true otherwise
      */
-    bool
+    ReplayMsgStatus
     processProofPathResponse(std::shared_ptr const& msg);
 
     /**
      * Process TMReplayDeltaRequest and return TMReplayDeltaResponse
      * @note check has_error() and error() of the response for error
+     * @return TMReplayDeltaResponse with the ledger delta, or with error() set
+     *         if the request cannot be fulfilled
      */
     protocol::TMReplayDeltaResponse
     processReplayDeltaRequest(std::shared_ptr const& msg);
 
     /**
      * Process TMReplayDeltaResponse
-     * @return false if the response message has bad format or bad data;
-     *         true otherwise
      */
-    bool
+    ReplayMsgStatus
     processReplayDeltaResponse(std::shared_ptr const& msg);
 
 private:
diff --git a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp
index e7cd031247..3d7b1e0f92 100644
--- a/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerReplayTask.cpp
@@ -87,18 +87,18 @@ LedgerReplayTask::LedgerReplayTask(
     : TimeoutCounter(
           app,
           parameter.finishHash,
-          LedgerReplayParameters::kTaskTimeout,
+          ledger_replay_parameters::kTaskTimeout,
           {.jobType = JtReplayTask,
            .jobName = "LedReplTask",
-           .jobLimit = LedgerReplayParameters::kMaxQueuedTasks},
+           .jobLimit = ledger_replay_parameters::kMaxQueuedTasks},
           app.getJournal("LedgerReplayTask"))
     , inboundLedgers_(inboundLedgers)
     , replayer_(replayer)
     , parameter_(parameter)
     , maxTimeouts_(
           std::max(
-              LedgerReplayParameters::kTaskMaxTimeoutsMinimum,
-              parameter.totalLedgers * LedgerReplayParameters::kTaskMaxTimeoutsMultiplier))
+              ledger_replay_parameters::kTaskMaxTimeoutsMinimum,
+              parameter.totalLedgers * ledger_replay_parameters::kTaskMaxTimeoutsMultiplier))
     , skipListAcquirer_(skipListAcquirer)
 {
     JLOG(journal_.trace()) << "Create " << hash_;
diff --git a/src/xrpld/app/ledger/detail/LedgerReplayer.cpp b/src/xrpld/app/ledger/detail/LedgerReplayer.cpp
index 3bb5ca9434..52184c1723 100644
--- a/src/xrpld/app/ledger/detail/LedgerReplayer.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerReplayer.cpp
@@ -51,7 +51,7 @@ LedgerReplayer::replay(
 {
     XRPL_ASSERT(
         finishLedgerHash.isNonZero() && totalNumLedgers > 0 &&
-            totalNumLedgers <= LedgerReplayParameters::kMaxTaskSize,
+            totalNumLedgers <= ledger_replay_parameters::kMaxTaskSize,
         "xrpl::LedgerReplayer::replay : valid inputs");
 
     // NOLINTNEXTLINE(misc-const-correctness)
@@ -64,7 +64,7 @@ LedgerReplayer::replay(
         std::scoped_lock const lock(mtx_);
         if (app_.isStopping())
             return;
-        if (tasks_.size() >= LedgerReplayParameters::kMaxTasks)
+        if (tasks_.size() >= ledger_replay_parameters::kMaxTasks)
         {
             JLOG(j_.info()) << "Too many replay tasks, dropping new task " << parameter.finishHash;
             return;
diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
index 7a581e2389..9d3820e9f7 100644
--- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
@@ -135,7 +135,7 @@ fillJsonTx(
     {
         copyFrom(txJson[jss::tx_json], txn->getJson(JsonOptions::Values::DisableApiPriorV2, false));
         txJson[jss::hash] = to_string(txn->getTransactionID());
-        RPC::insertDeliverMax(txJson[jss::tx_json], txnType, fill.context->apiVersion);
+        rpc::insertDeliverMax(txJson[jss::tx_json], txnType, fill.context->apiVersion);
 
         if (stMeta)
         {
@@ -144,7 +144,7 @@ fillJsonTx(
             // If applicable, insert delivered amount
             if (txnType == ttPAYMENT || txnType == ttCHECK_CASH)
             {
-                RPC::insertDeliveredAmount(
+                rpc::insertDeliveredAmount(
                     txJson[jss::meta],
                     fill.ledger,
                     txn,
@@ -152,7 +152,7 @@ fillJsonTx(
             }
 
             // If applicable, insert mpt issuance id
-            RPC::insertMPTokenIssuanceID(
+            rpc::insertMPTokenIssuanceID(
                 txJson[jss::meta], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
         }
 
@@ -172,7 +172,7 @@ fillJsonTx(
     else
     {
         copyFrom(txJson, txn->getJson(JsonOptions::Values::None));
-        RPC::insertDeliverMax(txJson, txnType, fill.context->apiVersion);
+        rpc::insertDeliverMax(txJson, txnType, fill.context->apiVersion);
         if (stMeta)
         {
             txJson[jss::metaData] = stMeta->getJson(JsonOptions::Values::None);
@@ -180,7 +180,7 @@ fillJsonTx(
             // If applicable, insert delivered amount
             if (txnType == ttPAYMENT || txnType == ttCHECK_CASH)
             {
-                RPC::insertDeliveredAmount(
+                rpc::insertDeliveredAmount(
                     txJson[jss::metaData],
                     fill.ledger,
                     txn,
@@ -188,7 +188,7 @@ fillJsonTx(
             }
 
             // If applicable, insert mpt issuance id
-            RPC::insertMPTokenIssuanceID(
+            rpc::insertMPTokenIssuanceID(
                 txJson[jss::metaData], txn, {txn->getTransactionID(), fill.ledger.seq(), *stMeta});
         }
     }
@@ -337,7 +337,7 @@ fillJson(json::Value& json, LedgerFill const& fill)
             fill.ledger.header(),
             bFull,
             ((fill.context != nullptr) ? fill.context->apiVersion
-                                       : RPC::kApiMaximumSupportedVersion));
+                                       : rpc::kApiMaximumSupportedVersion));
     }
 
     if (bFull || ((fill.options & static_cast(LedgerFill::Options::DumpTxrp)) != 0))
diff --git a/src/xrpld/app/ledger/detail/LocalTxs.cpp b/src/xrpld/app/ledger/detail/LocalTxs.cpp
index 5bfe8684f0..d540134f8d 100644
--- a/src/xrpld/app/ledger/detail/LocalTxs.cpp
+++ b/src/xrpld/app/ledger/detail/LocalTxs.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -147,7 +148,7 @@ public:
             if (!sleAcct)
                 return false;
 
-            SeqProxy const acctSeq = SeqProxy::sequence(sleAcct->getFieldU32(sfSequence));
+            SeqProxy const acctSeq = SeqProxy::rawSequence(sleAcct->getFieldU32(sfSequence));
             SeqProxy const seqProx = txn.getSeqProxy();
 
             if (seqProx.isSeq())
diff --git a/src/xrpld/app/ledger/detail/SkipListAcquire.cpp b/src/xrpld/app/ledger/detail/SkipListAcquire.cpp
index 8ebd14083a..97b50a9a3a 100644
--- a/src/xrpld/app/ledger/detail/SkipListAcquire.cpp
+++ b/src/xrpld/app/ledger/detail/SkipListAcquire.cpp
@@ -37,10 +37,10 @@ SkipListAcquire::SkipListAcquire(
     : TimeoutCounter(
           app,
           ledgerHash,
-          LedgerReplayParameters::kSubTaskTimeout,
+          ledger_replay_parameters::kSubTaskTimeout,
           {.jobType = JtReplayTask,
            .jobName = "SkipListAcq",
-           .jobLimit = LedgerReplayParameters::kMaxQueuedTasks},
+           .jobLimit = ledger_replay_parameters::kMaxQueuedTasks},
           app.getJournal("LedgerReplaySkipList"))
     , inboundLedgers_(inboundLedgers)
     , peerSet_(std::move(peerSet))
@@ -96,10 +96,10 @@ SkipListAcquire::trigger(std::size_t limit, ScopedLockType& sl)
                 {
                     JLOG(journal_.trace())
                         << "Add a no feature peer " << peer->id() << " for " << hash_;
-                    if (++noFeaturePeerCount_ >= LedgerReplayParameters::kMaxNoFeaturePeerCount)
+                    if (++noFeaturePeerCount_ >= ledger_replay_parameters::kMaxNoFeaturePeerCount)
                     {
                         JLOG(journal_.debug()) << "Fall back for " << hash_;
-                        timerInterval_ = LedgerReplayParameters::kSubTaskFallbackTimeout;
+                        timerInterval_ = ledger_replay_parameters::kSubTaskFallbackTimeout;
                         fallBack_ = true;
                     }
                 }
@@ -114,7 +114,7 @@ void
 SkipListAcquire::onTimer(bool progress, ScopedLockType& sl)
 {
     JLOG(journal_.trace()) << "timeouts_=" << timeouts_ << " for " << hash_;
-    if (timeouts_ > LedgerReplayParameters::kSubTaskMaxTimeouts)
+    if (timeouts_ > ledger_replay_parameters::kSubTaskMaxTimeouts)
     {
         failed_ = true;
         JLOG(journal_.debug()) << "too many timeouts " << hash_;
diff --git a/src/xrpld/app/ledger/detail/TransactionAcquire.cpp b/src/xrpld/app/ledger/detail/TransactionAcquire.cpp
index 62312b04d2..db99299fd6 100644
--- a/src/xrpld/app/ledger/detail/TransactionAcquire.cpp
+++ b/src/xrpld/app/ledger/detail/TransactionAcquire.cpp
@@ -7,13 +7,13 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 
 #include 
 
@@ -171,7 +171,7 @@ TransactionAcquire::trigger(std::shared_ptr const& peer)
 
 SHAMapAddNode
 TransactionAcquire::takeNodes(
-    std::vector> const& data,
+    std::vector> data,
     std::shared_ptr const& peer)
 {
     ScopedLockType const sl(mtx_);
@@ -195,7 +195,7 @@ TransactionAcquire::takeNodes(
 
         ConsensusTransSetSF sf(app_, app_.getTempNodeCache());
 
-        for (auto const& d : data)
+        for (auto& d : data)
         {
             if (d.first.isRoot())
             {
@@ -203,18 +203,22 @@ TransactionAcquire::takeNodes(
                 {
                     JLOG(journal_.debug()) << "Got root TXS node, already have it";
                 }
-                else if (!map_->addRootNode(SHAMapHash{hash_}, d.second, nullptr).isGood())
+                else if (!map_->addRootNode(SHAMapHash{hash_}, std::move(d.second), nullptr)
+                              .isGood())
                 {
-                    JLOG(journal_.warn()) << "TX acquire got bad root node";
+                    JLOG(journal_.warn()) << "TX acquire got bad root node for TX set " << hash_
+                                          << " from peer " << peer->id();
+                    return SHAMapAddNode::invalid();
                 }
                 else
                 {
                     haveRoot_ = true;
                 }
             }
-            else if (!map_->addKnownNode(d.first, d.second, &sf).isGood())
+            else if (!map_->addKnownNode(d.first, std::move(d.second), &sf).isGood())
             {
-                JLOG(journal_.warn()) << "TX acquire got bad non-root node";
+                JLOG(journal_.warn()) << "TX acquire got bad non-root node " << d.first
+                                      << " for TX set " << hash_ << " from peer " << peer->id();
                 return SHAMapAddNode::invalid();
             }
         }
diff --git a/src/xrpld/app/ledger/detail/TransactionAcquire.h b/src/xrpld/app/ledger/detail/TransactionAcquire.h
index 5b33066390..2faf74b557 100644
--- a/src/xrpld/app/ledger/detail/TransactionAcquire.h
+++ b/src/xrpld/app/ledger/detail/TransactionAcquire.h
@@ -6,10 +6,10 @@
 #include 
 
 #include 
-#include 
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -32,8 +32,8 @@ public:
 
     SHAMapAddNode
     takeNodes(
-        std::vector> const& data,
-        std::shared_ptr const&);
+        std::vector> data,
+        std::shared_ptr const& peer);
 
     void
     init(int startPeers);
diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp
index d329475874..d50637d98e 100644
--- a/src/xrpld/app/main/Application.cpp
+++ b/src/xrpld/app/main/Application.cpp
@@ -91,6 +91,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -230,9 +231,9 @@ public:
     std::optional> nodeIdentity_;
     ValidatorKeys const validatorKeys_;
 
-    std::unique_ptr resourceManager_;
+    std::unique_ptr resourceManager_;
 
-    std::unique_ptr nodeStore_;
+    std::unique_ptr nodeStore_;
     NodeFamily nodeFamily_;
     std::unique_ptr orderBookDB_;
     std::unique_ptr pathRequestManager_;
@@ -375,7 +376,7 @@ public:
         , networkIDService_(std::make_unique(config_->networkId))
         , validatorKeys_(*config_, journal_)
         , resourceManager_(
-              Resource::makeManager(collectorManager_->collector(), logs_->journal("Resource")))
+              resource::makeManager(collectorManager_->collector(), logs_->journal("Resource")))
         , nodeStore_(shaMapStore_->makeNodeStore(
               config_->prefetchWorkers > 0 ? config_->prefetchWorkers : 4))
         , nodeFamily_(*this, *collectorManager_)
@@ -428,8 +429,14 @@ public:
         , cluster_(std::make_unique(logs_->journal("Overlay")))
         , peerReservations_(
               std::make_unique(logs_->journal("PeerReservationTable")))
-        , validatorManifests_(std::make_unique(logs_->journal("ManifestCache")))
-        , publisherManifests_(std::make_unique(logs_->journal("ManifestCache")))
+        , validatorManifests_(
+              std::make_unique(
+                  logs_->journal("ManifestCache"),
+                  untrustedManifestCount(config_->maxUntrustedCount)))
+        , publisherManifests_(
+              std::make_unique(
+                  logs_->journal("ManifestCache"),
+                  untrustedManifestCount(config_->maxUntrustedCount)))
         , validators_(
               std::make_unique(
                   *validatorManifests_,
@@ -655,7 +662,7 @@ public:
         return tempNodeCache_;
     }
 
-    NodeStore::Database&
+    node_store::Database&
     getNodeStore() override
     {
         return *nodeStore_;
@@ -673,7 +680,7 @@ public:
         return *loadManager_;
     }
 
-    Resource::Manager&
+    resource::Manager&
     getResourceManager() override
     {
         return *resourceManager_;
@@ -860,9 +867,9 @@ public:
         if (config_->doImport)
         {
             auto j = logs_->journal("NodeObject");
-            NodeStore::DummyScheduler dummyScheduler;
-            std::unique_ptr source =
-                NodeStore::Manager::instance().makeDatabase(
+            node_store::DummyScheduler dummyScheduler;
+            std::unique_ptr source =
+                node_store::Manager::instance().makeDatabase(
                     megabytes(config_->getValueFor(SizedItem::BurstSize, std::nullopt)),
                     dummyScheduler,
                     0,
@@ -1187,9 +1194,18 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
             logs_->threshold(Severity::Debug);
     }
 
-    JLOG(journal_.info()) << "Process starting: " << BuildInfo::getFullVersionString()
+    JLOG(journal_.info()) << "Process starting: " << build_info::getFullVersionString()
                           << ", Instance Cookie: " << instanceCookie_;
 
+    // Log the resolved manifest counts, whether configured or defaulted, so a
+    // shared log shows what the server is running without needing its config.
+    JLOG(journal_.warn()) << "Manifest counts: max_untrusted_count "
+                          << untrustedManifestCount(config_->maxUntrustedCount)
+                          << (config_->maxUntrustedCount ? " (configured)" : " (default)")
+                          << ", max_trusted_count "
+                          << trustedManifestCount(config_->maxTrustedCount)
+                          << (config_->maxTrustedCount ? " (configured)" : " (default)");
+
     if (numberOfThreads(*config_) < 2)
     {
         JLOG(journal_.warn()) << "Limited to a single I/O service thread by "
@@ -1457,9 +1473,9 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
             JLOG(journal_.fatal()) << "Startup RPC: " << jvCommand << std::endl;
         }
 
-        Resource::Charge loadType = Resource::kFeeReferenceRpc;
-        Resource::Consumer c;
-        RPC::JsonContext context{
+        resource::Charge loadType = resource::kFeeReferenceRpc;
+        resource::Consumer c;
+        rpc::JsonContext context{
             {.j = getJournal("RPCHandler"),
              .app = *this,
              .loadType = loadType,
@@ -1469,11 +1485,11 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
              .role = Role::ADMIN,
              .coro = {},
              .infoSub = {},
-             .apiVersion = RPC::kApiMaximumSupportedVersion},
+             .apiVersion = rpc::kApiMaximumSupportedVersion},
             jvCommand};
 
         json::Value jvResult;
-        RPC::doCommand(context, jvResult);
+        rpc::doCommand(context, jvResult);
 
         if (!config_->quiet())
         {
@@ -1489,7 +1505,7 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
 void
 ApplicationImp::start(bool withTimers)
 {
-    JLOG(journal_.info()) << "Application starting. Version is " << BuildInfo::getVersionString();
+    JLOG(journal_.info()) << "Application starting. Version is " << build_info::getVersionString();
 
     if (withTimers)
     {
diff --git a/src/xrpld/app/main/CollectorManager.cpp b/src/xrpld/app/main/CollectorManager.cpp
index 9e1278607f..87b5286f97 100644
--- a/src/xrpld/app/main/CollectorManager.cpp
+++ b/src/xrpld/app/main/CollectorManager.cpp
@@ -30,8 +30,8 @@ public:
 
         if (server == "statsd")
         {
-            beast::IP::Endpoint const address(
-                beast::IP::Endpoint::fromString(get(params, Keys::kAddress)));
+            beast::ip::Endpoint const address(
+                beast::ip::Endpoint::fromString(get(params, Keys::kAddress)));
             std::string const& prefix(get(params, Keys::kPrefix));
 
             collector_ = beast::insight::StatsDCollector::make(address, prefix, journal);
diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp
index 1146cbdc08..c1ea5e874b 100644
--- a/src/xrpld/app/main/GRPCServer.cpp
+++ b/src/xrpld/app/main/GRPCServer.cpp
@@ -9,6 +9,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -24,7 +25,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -49,6 +49,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -71,10 +72,10 @@ getEndpoint(std::string const& peer)
             peerClean = peer.substr(first + 1);
         }
 
-        std::optional endpoint =
-            beast::IP::Endpoint::fromStringChecked(peerClean);
+        std::optional endpoint =
+            beast::ip::Endpoint::fromStringChecked(peerClean);
         if (endpoint)
-            return beast::IP::toAsioEndpoint(endpoint.value());
+            return beast::ip::toAsioEndpoint(endpoint.value());
     }
     catch (std::exception const&)  // NOLINT(bugprone-empty-catch)
     {
@@ -92,8 +93,8 @@ GRPCServerImpl::CallData::CallData(
     BindListener bindListener,
     Handler handler,
     Forward forward,
-    RPC::Condition requiredCondition,
-    Resource::Charge loadType,
+    rpc::Condition requiredCondition,
+    resource::Charge loadType,
     std::vector const& secureGatewayIPs)
     : service_(service)
     , cq_(cq)
@@ -195,7 +196,7 @@ GRPCServerImpl::CallData::process(std::shared_ptr context{
+            rpc::GRPCContext context{
                 {app_.getJournal("gRPCServer"),
                  app_,
                  loadType,
@@ -209,11 +210,11 @@ GRPCServerImpl::CallData::process(std::shared_ptr::isFinished()
 }
 
 template 
-Resource::Charge
+resource::Charge
 GRPCServerImpl::CallData::getLoadType()
 {
     return loadType_;
@@ -323,12 +324,12 @@ GRPCServerImpl::CallData::setIsUnlimited(Response& response,
 }
 
 template 
-Resource::Consumer
+resource::Consumer
 GRPCServerImpl::CallData::getUsage()
 {
     auto endpoint = getClientEndpoint();
     if (endpoint)
-        return app_.getResourceManager().newInboundEndpoint(beast::IP::fromAsio(endpoint.value()));
+        return app_.getResourceManager().newInboundEndpoint(beast::ip::fromAsio(endpoint.value()));
     Throw("Failed to get client endpoint");
 }
 
@@ -371,7 +372,7 @@ GRPCServerImpl::GRPCServerImpl(Application& app)
                 std::string ip;
                 while (std::getline(ss, ip, ','))
                 {
-                    boost::algorithm::trim(ip);
+                    ip = trimWhitespace(ip);
                     auto const addr = boost::asio::ip::make_address(ip);
 
                     if (addr.is_unspecified())
@@ -527,7 +528,7 @@ GRPCServerImpl::handleRpcs()
 std::vector>
 GRPCServerImpl::setupListeners()
 {
-    using RPC::Condition;
+    using rpc::Condition;
     std::vector> requests;
 
     auto addToRequests = [&requests](auto callData) { requests.push_back(std::move(callData)); };
@@ -545,7 +546,7 @@ GRPCServerImpl::setupListeners()
                 doLedgerGrpc,
                 &org::xrpl::rpc::v1::XRPLedgerAPIService::Stub::GetLedger,
                 Condition::NoCondition,
-                Resource::kFeeMediumBurdenRpc,
+                resource::kFeeMediumBurdenRpc,
                 secureGatewayIPs_));
     }
     {
@@ -562,7 +563,7 @@ GRPCServerImpl::setupListeners()
                 doLedgerDataGrpc,
                 &org::xrpl::rpc::v1::XRPLedgerAPIService::Stub::GetLedgerData,
                 Condition::NoCondition,
-                Resource::kFeeMediumBurdenRpc,
+                resource::kFeeMediumBurdenRpc,
                 secureGatewayIPs_));
     }
     {
@@ -579,7 +580,7 @@ GRPCServerImpl::setupListeners()
                 doLedgerDiffGrpc,
                 &org::xrpl::rpc::v1::XRPLedgerAPIService::Stub::GetLedgerDiff,
                 Condition::NoCondition,
-                Resource::kFeeMediumBurdenRpc,
+                resource::kFeeMediumBurdenRpc,
                 secureGatewayIPs_));
     }
     {
@@ -596,7 +597,7 @@ GRPCServerImpl::setupListeners()
                 doLedgerEntryGrpc,
                 &org::xrpl::rpc::v1::XRPLedgerAPIService::Stub::GetLedgerEntry,
                 Condition::NoCondition,
-                Resource::kFeeMediumBurdenRpc,
+                resource::kFeeMediumBurdenRpc,
                 secureGatewayIPs_));
     }
     return requests;
@@ -615,7 +616,7 @@ GRPCServerImpl::createServerCredentials()
 
     try
     {
-        boost::system::error_code ec;
+        std::error_code ec;
         grpc::SslServerCredentialsOptions sslOpts;
         grpc::SslServerCredentialsOptions::PemKeyCertPair keyCertPair;
 
diff --git a/src/xrpld/app/main/GRPCServer.h b/src/xrpld/app/main/GRPCServer.h
index db948cab99..98b50fcd0c 100644
--- a/src/xrpld/app/main/GRPCServer.h
+++ b/src/xrpld/app/main/GRPCServer.h
@@ -102,7 +102,7 @@ private:
     // typedef for actual handler (that populates a response)
     // handlers are defined in rpc/GRPCHandlers.h
     template 
-    using Handler = std::function(RPC::GRPCContext&)>;
+    using Handler = std::function(rpc::GRPCContext&)>;
     // This implementation is currently limited to v1 of the API
     static constexpr unsigned kApiVersion = 1;
 
@@ -189,10 +189,10 @@ private:
         Forward forward_;
 
         // Condition required for this RPC
-        RPC::Condition requiredCondition_;
+        rpc::Condition requiredCondition_;
 
         // Load type for this RPC
-        Resource::Charge loadType_;
+        resource::Charge loadType_;
 
         std::vector const& secureGatewayIPs_;
 
@@ -209,8 +209,8 @@ private:
             BindListener bindListener,
             Handler handler,
             Forward forward,
-            RPC::Condition requiredCondition,
-            Resource::Charge loadType,
+            rpc::Condition requiredCondition,
+            resource::Charge loadType,
             std::vector const& secureGatewayIPs);
 
         CallData(CallData const&) = delete;
@@ -233,7 +233,7 @@ private:
         process(std::shared_ptr coro);
 
         // return load type of this RPC
-        Resource::Charge
+        resource::Charge
         getLoadType();
 
         // return the Role used for this RPC
@@ -241,7 +241,7 @@ private:
         getRole(bool isUnlimited);
 
         // register endpoint with ResourceManager and return usage
-        Resource::Consumer
+        resource::Consumer
         getUsage();
 
         // Returns the ip of the client
@@ -290,7 +290,7 @@ private:
 
         // forward request to a p2p node
         void
-        forwardToP2p(RPC::GRPCContext& context);
+        forwardToP2p(rpc::GRPCContext& context);
 
     };  // CallData
 
diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp
index d0b40efce8..ba6520db5f 100644
--- a/src/xrpld/app/main/Main.cpp
+++ b/src/xrpld/app/main/Main.cpp
@@ -6,6 +6,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -21,7 +22,6 @@
 
 #include 
 #include 
-#include 
 #include 
 #include   // IWYU pragma: keep
 #include 
@@ -211,7 +211,7 @@ public:
         boost::split(v, patterns, boost::algorithm::is_any_of(","));
         selectors_.reserve(v.size());
         std::ranges::for_each(v, [this](std::string s) {
-            boost::trim(s);
+            s = trimWhitespace(s);
             if (selectors_.empty() || !s.empty())
                 selectors_.emplace_back(beast::unit_test::Selector::ModeT::Automatch, s);
         });
@@ -506,7 +506,7 @@ run(int argc, char** argv)
     if (vm.contains("version"))
     {
         // LCOV_EXCL_START
-        std::cout << "xrpld version " << BuildInfo::getVersionString() << std::endl;
+        std::cout << "xrpld version " << build_info::getVersionString() << std::endl;
         std::cout << "Git commit hash: " << xrpl::git::getCommitHash() << std::endl;
         std::cout << "Git build branch: " << xrpl::git::getBuildBranch() << std::endl;
         return 0;
@@ -614,7 +614,7 @@ run(int argc, char** argv)
                 std::vector result;
                 for (auto& s : strVec)
                 {
-                    boost::trim(s);
+                    s = trimWhitespace(s);
                     if (!s.empty())
                         result.push_back(std::stoi(s));
                 }
@@ -716,7 +716,7 @@ run(int argc, char** argv)
     // happen after the config file is loaded.
     if (vm.contains("rpc_ip"))
     {
-        auto endpoint = beast::IP::Endpoint::fromStringChecked(vm["rpc_ip"].as());
+        auto endpoint = beast::ip::Endpoint::fromStringChecked(vm["rpc_ip"].as());
         if (!endpoint)
         {
             std::cerr << "Invalid rpc_ip = " << vm["rpc_ip"].as() << "\n";
@@ -826,7 +826,7 @@ run(int argc, char** argv)
 
     // We have an RPC command to process:
     beast::setCurrentThreadName("xrpld: rpc");
-    return RPCCall::fromCommandLine(
+    return rpc_call::fromCommandLine(
         *config, vm["parameters"].as>(), *logs);
     // LCOV_EXCL_STOP
 }
diff --git a/src/xrpld/app/main/NodeStoreScheduler.cpp b/src/xrpld/app/main/NodeStoreScheduler.cpp
index 7892503f90..c3ac5d78cf 100644
--- a/src/xrpld/app/main/NodeStoreScheduler.cpp
+++ b/src/xrpld/app/main/NodeStoreScheduler.cpp
@@ -12,7 +12,7 @@ NodeStoreScheduler::NodeStoreScheduler(JobQueue& jobQueue) : jobQueue_(jobQueue)
 }
 
 void
-NodeStoreScheduler::scheduleTask(NodeStore::Task& task)
+NodeStoreScheduler::scheduleTask(node_store::Task& task)
 {
     if (jobQueue_.isStopped())
         return;
@@ -26,19 +26,19 @@ NodeStoreScheduler::scheduleTask(NodeStore::Task& task)
 }
 
 void
-NodeStoreScheduler::onFetch(NodeStore::FetchReport const& report)
+NodeStoreScheduler::onFetch(node_store::FetchReport const& report)
 {
     if (jobQueue_.isStopped())
         return;
 
     jobQueue_.addLoadEvents(
-        report.fetchType == NodeStore::FetchType::Async ? JtNsAsyncRead : JtNsSyncRead,
+        report.fetchType == node_store::FetchType::Async ? JtNsAsyncRead : JtNsSyncRead,
         1,
         report.elapsed);
 }
 
 void
-NodeStoreScheduler::onBatchWrite(NodeStore::BatchWriteReport const& report)
+NodeStoreScheduler::onBatchWrite(node_store::BatchWriteReport const& report)
 {
     if (jobQueue_.isStopped())
         return;
diff --git a/src/xrpld/app/main/NodeStoreScheduler.h b/src/xrpld/app/main/NodeStoreScheduler.h
index 8bfd1607ae..09a48d5be1 100644
--- a/src/xrpld/app/main/NodeStoreScheduler.h
+++ b/src/xrpld/app/main/NodeStoreScheduler.h
@@ -7,19 +7,19 @@
 namespace xrpl {
 
 /**
- * A NodeStore::Scheduler which uses the JobQueue.
+ * A node_store::Scheduler which uses the JobQueue.
  */
-class NodeStoreScheduler : public NodeStore::Scheduler
+class NodeStoreScheduler : public node_store::Scheduler
 {
 public:
     explicit NodeStoreScheduler(JobQueue& jobQueue);
 
     void
-    scheduleTask(NodeStore::Task& task) override;
+    scheduleTask(node_store::Task& task) override;
     void
-    onFetch(NodeStore::FetchReport const& report) override;
+    onFetch(node_store::FetchReport const& report) override;
     void
-    onBatchWrite(NodeStore::BatchWriteReport const& report) override;
+    onBatchWrite(node_store::BatchWriteReport const& report) override;
 
 private:
     JobQueue& jobQueue_;
diff --git a/src/xrpld/app/misc/DeliverMax.h b/src/xrpld/app/misc/DeliverMax.h
index 73ccc95800..1683219c8b 100644
--- a/src/xrpld/app/misc/DeliverMax.h
+++ b/src/xrpld/app/misc/DeliverMax.h
@@ -6,7 +6,7 @@ namespace json {
 class Value;
 }  // namespace json
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 /**
  * Copy `Amount` field to `DeliverMax` field in transaction output JSON.
@@ -22,4 +22,4 @@ insertDeliverMax(json::Value& txJson, TxType txnType, unsigned int apiVersion);
 
 /** @} */
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/app/misc/FeeVoteImpl.cpp b/src/xrpld/app/misc/FeeVoteImpl.cpp
index 76a4d8f186..f1cb944a52 100644
--- a/src/xrpld/app/misc/FeeVoteImpl.cpp
+++ b/src/xrpld/app/misc/FeeVoteImpl.cpp
@@ -260,39 +260,35 @@ FeeVoteImpl::doVoting(
     }
 
     // choose our positions
-    // TODO: Use structured binding once LLVM 16 is the minimum supported
-    // version. See also: https://github.com/llvm/llvm-project/issues/48582
-    // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c
-    auto const baseFee = baseFeeVote.getVotes();
-    auto const baseReserve = baseReserveVote.getVotes();
-    auto const incReserve = incReserveVote.getVotes();
+    auto const [baseFee, baseFeeChanged] = baseFeeVote.getVotes();
+    auto const [baseReserve, baseReserveChanged] = baseReserveVote.getVotes();
+    auto const [incReserve, incReserveChanged] = incReserveVote.getVotes();
 
     auto const seq = lastClosedLedger->header().seq + 1;
 
     // add transactions to our position
-    if (baseFee.second || baseReserve.second || incReserve.second)
+    if (baseFeeChanged || baseReserveChanged || incReserveChanged)
     {
-        JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee.first << "/"
-                              << baseReserve.first << "/" << incReserve.first;
+        JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee << "/" << baseReserve
+                              << "/" << incReserve;
 
         STTx const feeTx(ttFEE, [=, &rules](auto& obj) {
             obj[sfAccount] = AccountID();
             obj[sfLedgerSequence] = seq;
             if (rules.enabled(featureXRPFees))
             {
-                obj[sfBaseFeeDrops] = baseFee.first;
-                obj[sfReserveBaseDrops] = baseReserve.first;
-                obj[sfReserveIncrementDrops] = incReserve.first;
+                obj[sfBaseFeeDrops] = baseFee;
+                obj[sfReserveBaseDrops] = baseReserve;
+                obj[sfReserveIncrementDrops] = incReserve;
             }
             else
             {
                 // Without the featureXRPFees amendment, these fields are
                 // required.
-                obj[sfBaseFee] = baseFee.first.dropsAs(baseFeeVote.current());
-                obj[sfReserveBase] =
-                    baseReserve.first.dropsAs(baseReserveVote.current());
+                obj[sfBaseFee] = baseFee.dropsAs(baseFeeVote.current());
+                obj[sfReserveBase] = baseReserve.dropsAs(baseReserveVote.current());
                 obj[sfReserveIncrement] =
-                    incReserve.first.dropsAs(incReserveVote.current());
+                    incReserve.dropsAs(incReserveVote.current());
                 obj[sfReferenceFeeUnits] = kFeeUnitsDeprecated;
             }
         });
diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp
index 4b0091dff6..771330367c 100644
--- a/src/xrpld/app/misc/NetworkOPs.cpp
+++ b/src/xrpld/app/misc/NetworkOPs.cpp
@@ -21,8 +21,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
@@ -54,6 +52,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -154,6 +154,12 @@
 
 namespace xrpl {
 
+/**
+ * Concrete NetworkOPs: server sequencer, network tracker, and owner of all
+ * client subscription state (accounts, books, streams). Subscriptions use three
+ * independent non-recursive locks (accountLock_, bookLock_, streamLock_); see
+ * their declarations for the locking and deferred-destruction rules.
+ */
 class NetworkOPsImp final : public NetworkOPs
 {
     /**
@@ -194,7 +200,7 @@ class NetworkOPsImp final : public NetworkOPs
     /**
      * State accounting records two attributes for each possible server state:
      * 1) Amount of time spent in each state (in microseconds). This value is
-     *    updated upon each state transition.
+     * updated upon each state transition.
      * 2) Number of transitions to each state.
      *
      * This data can be polled through server_info and represented by
@@ -573,6 +579,13 @@ public:
     unsubAccountHistoryInternal(std::uint64_t seq, AccountID const& account, bool historyOnly)
         override;
 
+    void
+    scheduleAccountCleanup(
+        std::uint64_t seq,
+        hash_set rtAccounts,
+        hash_set normalAccounts,
+        hash_set historyAccounts) override;
+
     bool
     subLedger(InfoSub::ref ispListener, json::Value& jvResult) override;
     bool
@@ -636,6 +649,20 @@ public:
     bool
     tryRemoveRpcSub(std::string const& strUrl) override;
 
+    /**
+     * Look up an RPC subscription without taking streamLock_.
+     *
+     * Callers MUST already hold streamLock_. This exists so tryRemoveRpcSub
+     * can reuse the lookup while holding the lock; the plain std::mutex is not
+     * recursive, so calling the public findRpcSub (which locks) from under the
+     * lock would self-deadlock.
+     *
+     * @param strUrl The subscription URL key into rpcSubMap_.
+     * @return The matching InfoSub, or an empty pointer if not found.
+     */
+    InfoSub::pointer
+    findRpcSubLocked(std::string const& strUrl);
+
     beast::Journal const&
     journal() const override
     {
@@ -724,22 +751,22 @@ private:
      * Extracts the set of order books affected by @p transaction, then
      * delivers @p jvObj to every live subscriber of those books.
      *
-     * Uses a two-pass design to keep subLock_ hold time short:
-     *   1. Under subLock_, collect strong InfoSub pointers for all live
-     *      subscribers and prune any expired weak_ptrs encountered.
-     *   2. Release subLock_, then call send() on each collected pointer.
+     * Uses a two-pass design to keep bookLock_ hold time short:
+     * 1. Under bookLock_, collect strong InfoSub pointers for all live
+     * subscribers and prune any expired weak_ptrs encountered.
+     * 2. Release bookLock_, then call send() on each collected pointer.
      *
      * @param transaction The accepted ledger transaction to inspect.
      * @param jvObj JSON representation of the transaction to deliver.
      *
-     * @note Thread-safety: acquires subLock_ for the collection pass only.
-     *       send() is intentionally called outside the lock to avoid blocking
-     *       all other sub/unsub/publish paths while I/O is in progress.
-     * @note Contention: subLock_ is shared with all other subscription types.
-     *       On high-throughput nodes processing multi-hop payments that touch
-     *       many offer nodes, this pass holds subLock_ longer than the old
-     *       per-book BookListeners locks did. This is an accepted trade-off
-     *       for lock-domain simplicity.
+     * @note Thread-safety: acquires bookLock_ for the collection pass only.
+     * send() is intentionally called outside the lock to avoid blocking
+     * other book sub/unsub/publish paths while I/O is in progress.
+     * @note Contention: bookLock_ guards only book subscriptions, so this pass
+     * no longer competes with account or stream traffic. On high-throughput
+     * nodes processing multi-hop payments that touch many offer nodes, it
+     * still holds bookLock_ longer than the old per-book BookListeners
+     * locks did. This is an accepted trade-off for lock-domain simplicity.
      */
     void
     pubBookTransaction(AcceptedLedgerTx const& transaction, MultiApiJson const& jvObj);
@@ -750,6 +777,23 @@ private:
         std::shared_ptr const& transaction,
         TER result);
 
+    /**
+     * Send the ledgerClosed and book-changes stream updates for a ledger.
+     * Takes streamLock_ only.
+     */
+    void
+    publishLedgerStreams(
+        std::shared_ptr const& lpAccepted,
+        std::shared_ptr const& alpAccepted);
+
+    /**
+     * On the first published ledger only, start the delayed account-history
+     * streaming for any subscriptions that were registered before a validated
+     * ledger existed. Takes accountLock_ only.
+     */
+    void
+    kickoffAccountHistory(std::shared_ptr const& alpAccepted);
+
     void
     pubServer();
     void
@@ -802,7 +846,9 @@ private:
         hash_map>;
 
     /**
-     * @note called while holding subLock_
+     * @note called while holding accountLock_ (it only touches
+     * subAccountHistory_ and posts a JobQueue task; it never reacquires
+     * a subscription lock nor touches the stream maps).
      */
     void
     subAccountHistoryStart(
@@ -813,12 +859,85 @@ private:
     void
     setAccountHistoryJobTimer(SubAccountHistoryInfoWeak subInfo);
 
+    /**
+     * Maximum number of account entries erased per accountLock_ acquisition
+     * during disconnect-time cleanup.
+     *
+     * The cleanup erase loops drop and reacquire accountLock_ after every
+     * chunk of this many accounts, bounding how long a large teardown holds
+     * the lock. A concurrent publish may interleave between chunks; that is
+     * safe because publishing tolerates a partially-cleaned map (a dead
+     * subscriber is simply not notified).
+     */
+    static constexpr std::size_t kAccountCleanupChunk = 4096;
+
+    /**
+     * Erase one connection's entries from a subscription map in
+     * accountLock_-bounded chunks.
+     *
+     * Shared engine behind cleanupAccountSubscriptions and
+     * cleanupAccountHistorySubscriptions: both walk @p accounts, and for each
+     * remove this connection's @p seq from the inner per-account map, dropping
+     * the outer entry once its last subscriber leaves. The lock is released
+     * between chunks so a competing publish can interleave; no iterator is held
+     * across the unlock, so a concurrent mutation cannot dangle.
+     *
+     * @tparam OuterMap    hash_map>.
+     * @tparam BeforeErase Invoked with the inner value about to be erased, for
+     * per-entry teardown the plain account maps do not need
+     * (the history map uses it to stop its paging job).
+     * @param seq          The disconnecting connection's subscription id.
+     * @param accounts     The accounts this connection was subscribed to.
+     * @param outerMap     The subscription map to erase from.
+     * @param beforeErase  Called on each inner value just before it is erased.
+     * See kAccountCleanupChunk.
+     */
+    template 
+    void
+    cleanupSubscriptionMap(
+        std::uint64_t seq,
+        hash_set const& accounts,
+        OuterMap& outerMap,
+        BeforeErase&& beforeErase);
+
+    /**
+     * Erase one connection's entries from the given account map (subAccount_
+     * or subRTAccount_) in accountLock_-bounded chunks. The caller selects the
+     * map, so this need not know about the real-time/normal distinction. Keyed
+     * on seq, so it only removes the disconnecting connection's entries.
+     * See kAccountCleanupChunk.
+     */
+    void
+    cleanupAccountSubscriptions(
+        std::uint64_t seq,
+        hash_set const& accounts,
+        SubInfoMapType& subMap);
+
+    /**
+     * Erase one connection's entries from subAccountHistory_ in
+     * accountLock_-bounded chunks. Keyed on seq. See kAccountCleanupChunk.
+     */
+    void
+    cleanupAccountHistorySubscriptions(std::uint64_t seq, hash_set const& accounts);
+
     std::reference_wrapper registry_;
     beast::Journal journal_;
 
     std::unique_ptr localTX_;
 
-    std::recursive_mutex subLock_;
+    // Independent lock domains so a long cleanup/publish on one does not stall
+    // the others. Hold at most one at a time; if ever more, order: accountLock_,
+    // bookLock_, streamLock_.
+    //
+    // Deferred-destruction rule (non-recursive mutexes): under bookLock_ or
+    // streamLock_, never let the last InfoSub pointer die inside the lock -
+    // ~InfoSub re-acquires it via unsub* -> self-deadlock. Publishers collect the
+    // locked pointers in a vector declared before the lock and destruct after
+    // release (see pubServer / pubBookTransaction). accountLock_ is exempt:
+    // ~InfoSub offloads account teardown to scheduleAccountCleanup.
+    std::mutex accountLock_;  ///< Guards subAccount_, subRTAccount_, subAccountHistory_.
+    std::mutex bookLock_;     ///< Guards subBook_.
+    std::mutex streamLock_;   ///< Guards streamMaps_[] and rpcSubMap_.
 
     std::atomic mode_;
 
@@ -843,18 +962,18 @@ private:
 
     /**
      * Maps each order book to its current set of subscribers.
-     *  Outer key: the Book (currency pair + optional domain).
-     *  Inner key: InfoSub::seq (unique per connection).
-     *  Inner value: weak_ptr so that a dropped connection does not prevent
-     *  the InfoSub from being destroyed; expired entries are pruned lazily
-     *  by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path).
-     *  Guarded by subLock_.
+     * Outer key: the Book (currency pair + optional domain).
+     * Inner key: InfoSub::seq (unique per connection).
+     * Inner value: weak_ptr so that a dropped connection does not prevent
+     * the InfoSub from being destroyed; expired entries are pruned lazily
+     * by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path).
+     * Guarded by bookLock_.
      */
     using SubBookMapType = hash_map;
 
     SubInfoMapType subAccount_;
     SubInfoMapType subRTAccount_;
-    SubBookMapType subBook_;  ///< Guarded by subLock_.
+    SubBookMapType subBook_;  ///< Guarded by bookLock_.
 
     subRpcMapType rpcSubMap_;
 
@@ -875,6 +994,10 @@ private:
         SLastEntry        // Any new entry must be ADDED ABOVE this one
     };
 
+    /**
+     * One weak_ptr subscriber map per stream type. Guarded by streamLock_;
+     * subject to its deferred-destruction rule (see pubServer).
+     */
     std::array streamMaps_;
 
     ServerFeeSummary lastFeeSummary_;
@@ -1215,7 +1338,7 @@ NetworkOPsImp::processClusterTimer()
             n.set_nodename(node.name());
     });
 
-    Resource::Gossip const gossip = registry_.get().getResourceManager().exportConsumers();
+    resource::Gossip const gossip = registry_.get().getResourceManager().exportConsumers();
     for (auto& item : gossip.items)
     {
         protocol::TMLoadSource& node = *cluster.add_loadsources();
@@ -2245,8 +2368,14 @@ NetworkOPsImp::consensusViewChange()
 void
 NetworkOPsImp::pubManifest(Manifest const& mo)
 {
+    // Hold each locked subscriber alive until after streamLock_ is released:
+    // if this is the last reference, ~InfoSub re-acquires streamLock_ (via its
+    // unsub* calls), which would self-deadlock on this non-recursive mutex.
+    // Declared before the lock so it is destroyed after the lock is dropped.
+    std::vector toRelease;
+
     // VFALCO consider std::shared_mutex
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
 
     if (!streamMaps_[SManifests].empty())
     {
@@ -2269,6 +2398,7 @@ NetworkOPsImp::pubManifest(Manifest const& mo)
             if (auto p = i->second.lock())
             {
                 p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
                 ++i;
             }
             else
@@ -2320,11 +2450,16 @@ trunc32(std::uint64_t v)
 void
 NetworkOPsImp::pubServer()
 {
+    // Hold each locked subscriber alive until after streamLock_ is released; a
+    // last-reference ~InfoSub would otherwise re-acquire this non-recursive
+    // mutex and self-deadlock. Declared before the lock, destroyed after it.
+    std::vector toRelease;
+
     // VFALCO TODO Don't hold the lock across calls to send...make a copy of the
     //             list into a local array while holding the lock then release
     //             the lock and call send on everyone.
     //
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
 
     if (!streamMaps_[SServer].empty())
     {
@@ -2362,7 +2497,7 @@ NetworkOPsImp::pubServer()
 
         for (auto i = streamMaps_[SServer].begin(); i != streamMaps_[SServer].end();)
         {
-            InfoSub::pointer const p = i->second.lock();
+            InfoSub::pointer p = i->second.lock();
 
             // VFALCO TODO research the possibility of using thread queues and
             //             linearizing the deletion of subscribers with the
@@ -2370,6 +2505,7 @@ NetworkOPsImp::pubServer()
             if (p)
             {
                 p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
                 ++i;
             }
             else
@@ -2383,7 +2519,12 @@ NetworkOPsImp::pubServer()
 void
 NetworkOPsImp::pubConsensus(ConsensusPhase phase)
 {
-    std::scoped_lock const sl(subLock_);
+    // Hold each locked subscriber alive until after streamLock_ is released; a
+    // last-reference ~InfoSub would otherwise re-acquire this non-recursive
+    // mutex and self-deadlock. Declared before the lock, destroyed after it.
+    std::vector toRelease;
+
+    std::scoped_lock const sl(streamLock_);
 
     auto& streamMap = streamMaps_[SConsensusPhase];
     if (!streamMap.empty())
@@ -2397,6 +2538,7 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase)
             if (auto p = i->second.lock())
             {
                 p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
                 ++i;
             }
             else
@@ -2410,8 +2552,13 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase)
 void
 NetworkOPsImp::pubValidation(std::shared_ptr const& val)
 {
+    // Hold each locked subscriber alive until after streamLock_ is released; a
+    // last-reference ~InfoSub would otherwise re-acquire this non-recursive
+    // mutex and self-deadlock. Declared before the lock, destroyed after it.
+    std::vector toRelease;
+
     // VFALCO consider std::shared_mutex
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
 
     if (!streamMaps_[SValidations].empty())
     {
@@ -2487,7 +2634,7 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val)
         // for consumers supporting different API versions
         MultiApiJson multiObj{jvObj};
         multiObj.visit(
-            RPC::kApiVersion<1>,  //
+            rpc::kApiVersion<1>,  //
             [](json::Value& jvTx) {
                 // Type conversion for older API versions to string
                 if (jvTx.isMember(jss::ledger_index))
@@ -2503,6 +2650,7 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val)
                 multiObj.visit(
                     p->getApiVersion(),  //
                     [&](json::Value const& jv) { p->send(jv, true); });
+                toRelease.push_back(std::move(p));
                 ++i;
             }
             else
@@ -2516,7 +2664,12 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val)
 void
 NetworkOPsImp::pubPeerStatus(std::function const& func)
 {
-    std::scoped_lock const sl(subLock_);
+    // Hold each locked subscriber alive until after streamLock_ is released; a
+    // last-reference ~InfoSub would otherwise re-acquire this non-recursive
+    // mutex and self-deadlock. Declared before the lock, destroyed after it.
+    std::vector toRelease;
+
+    std::scoped_lock const sl(streamLock_);
 
     if (!streamMaps_[SPeerStatus].empty())
     {
@@ -2526,11 +2679,12 @@ NetworkOPsImp::pubPeerStatus(std::function const& func)
 
         for (auto i = streamMaps_[SPeerStatus].begin(); i != streamMaps_[SPeerStatus].end();)
         {
-            InfoSub::pointer const p = i->second.lock();
+            InfoSub::pointer p = i->second.lock();
 
             if (p)
             {
                 p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
                 ++i;
             }
             else
@@ -2688,7 +2842,7 @@ NetworkOPsImp::getServerInfo(bool human, bool admin, bool counters)
     if (!registry_.get().getApp().config().serverDomain.empty())
         info[jss::server_domain] = registry_.get().getApp().config().serverDomain;
 
-    info[jss::build_version] = BuildInfo::getVersionString();
+    info[jss::build_version] = build_info::getVersionString();
 
     info[jss::server_state] = strOperatingMode(admin);
 
@@ -3074,7 +3228,13 @@ NetworkOPsImp::pubProposedTransaction(
     MultiApiJson const jvObj = transJson(transaction, result, false, ledger, std::nullopt);
 
     {
-        std::scoped_lock const sl(subLock_);
+        // Hold each locked subscriber alive until after streamLock_ is
+        // released; a last-reference ~InfoSub would otherwise re-acquire this
+        // non-recursive mutex and self-deadlock. Declared before the lock,
+        // destroyed after the block ends.
+        std::vector toRelease;
+
+        std::scoped_lock const sl(streamLock_);
 
         auto it = streamMaps_[SRtTransactions].begin();
         while (it != streamMaps_[SRtTransactions].end())
@@ -3086,6 +3246,7 @@ NetworkOPsImp::pubProposedTransaction(
                 jvObj.visit(
                     p->getApiVersion(),  //
                     [&](json::Value const& jv) { p->send(jv, true); });
+                toRelease.push_back(std::move(p));
                 ++it;
             }
             else
@@ -3117,100 +3278,121 @@ NetworkOPsImp::pubLedger(std::shared_ptr const& lpAccepted)
         alpAccepted->getLedger().get() == lpAccepted.get(),
         "xrpl::NetworkOPsImp::pubLedger : accepted input");
 
-    {
-        JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " "
-                               << lpAccepted->header().hash;
+    JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " "
+                           << lpAccepted->header().hash;
 
-        std::scoped_lock const sl(subLock_);
-
-        if (!streamMaps_[SLedger].empty())
-        {
-            json::Value jvObj(json::ValueType::Object);
-
-            jvObj[jss::type] = "ledgerClosed";
-            jvObj[jss::ledger_index] = lpAccepted->header().seq;
-            jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash);
-            jvObj[jss::ledger_time] =
-                json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count());
-
-            jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID();
-
-            if (!lpAccepted->rules().enabled(featureXRPFees))
-                jvObj[jss::fee_ref] = kFeeUnitsDeprecated;
-            jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped();
-            jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped();
-            jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped();
-
-            jvObj[jss::txn_count] = json::UInt(alpAccepted->size());
-
-            if (mode_ >= OperatingMode::SYNCING)
-            {
-                jvObj[jss::validated_ledgers] =
-                    registry_.get().getLedgerMaster().getCompleteLedgers();
-            }
-
-            auto it = streamMaps_[SLedger].begin();
-            while (it != streamMaps_[SLedger].end())
-            {
-                InfoSub::pointer const p = it->second.lock();
-                if (p)
-                {
-                    p->send(jvObj, true);
-                    ++it;
-                }
-                else
-                {
-                    it = streamMaps_[SLedger].erase(it);
-                }
-            }
-        }
-
-        if (!streamMaps_[SBookChanges].empty())
-        {
-            json::Value const jvObj = xrpl::RPC::computeBookChanges(lpAccepted);
-
-            auto it = streamMaps_[SBookChanges].begin();
-            while (it != streamMaps_[SBookChanges].end())
-            {
-                InfoSub::pointer const p = it->second.lock();
-                if (p)
-                {
-                    p->send(jvObj, true);
-                    ++it;
-                }
-                else
-                {
-                    it = streamMaps_[SBookChanges].erase(it);
-                }
-            }
-        }
-
-        {
-            static bool kFirstTime = true;
-            if (kFirstTime)
-            {
-                // First validated ledger, start delayed SubAccountHistory
-                kFirstTime = false;
-                for (auto& outer : subAccountHistory_)
-                {
-                    for (auto& inner : outer.second)
-                    {
-                        auto& subInfo = inner.second;
-                        if (subInfo.index->separationLedgerSeq == 0)
-                        {
-                            subAccountHistoryStart(alpAccepted->getLedger(), subInfo);
-                        }
-                    }
-                }
-            }
-        }
-    }
+    // Stream updates and the account-history kick-off touch different lock
+    // domains; each helper takes only its own lock, so the two are never held
+    // together.
+    publishLedgerStreams(lpAccepted, alpAccepted);
+    kickoffAccountHistory(alpAccepted);
 
     // Don't lock since pubAcceptedTransaction is locking.
     for (auto const& accTx : *alpAccepted)
     {
         JLOG(journal_.trace()) << "pubAccepted: " << accTx->getJson();
-        pubValidatedTransaction(lpAccepted, *accTx, accTx == *(--alpAccepted->end()));
+        bool const last = &*accTx == &alpAccepted->back();
+        pubValidatedTransaction(lpAccepted, *accTx, last);
+    }
+}
+
+void
+NetworkOPsImp::publishLedgerStreams(
+    std::shared_ptr const& lpAccepted,
+    std::shared_ptr const& alpAccepted)
+{
+    // Hold each locked subscriber alive until after streamLock_ is released; a
+    // last-reference ~InfoSub would otherwise re-acquire this non-recursive
+    // mutex and self-deadlock. Declared before the lock, destroyed after it;
+    // covers both the ledger and book-changes loops below.
+    std::vector toRelease;
+
+    std::scoped_lock const sl(streamLock_);
+
+    if (!streamMaps_[SLedger].empty())
+    {
+        json::Value jvObj(json::ValueType::Object);
+
+        jvObj[jss::type] = "ledgerClosed";
+        jvObj[jss::ledger_index] = lpAccepted->header().seq;
+        jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash);
+        jvObj[jss::ledger_time] =
+            json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count());
+
+        jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID();
+
+        if (!lpAccepted->rules().enabled(featureXRPFees))
+            jvObj[jss::fee_ref] = kFeeUnitsDeprecated;
+        jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped();
+        jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped();
+        jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped();
+
+        jvObj[jss::txn_count] = json::UInt(alpAccepted->size());
+
+        if (mode_ >= OperatingMode::SYNCING)
+        {
+            jvObj[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers();
+        }
+        auto it = streamMaps_[SLedger].begin();
+        while (it != streamMaps_[SLedger].end())
+        {
+            InfoSub::pointer p = it->second.lock();
+            if (p)
+            {
+                p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
+                ++it;
+            }
+            else
+            {
+                it = streamMaps_[SLedger].erase(it);
+            }
+        }
+    }
+
+    if (!streamMaps_[SBookChanges].empty())
+    {
+        json::Value const jvObj = xrpl::rpc::computeBookChanges(lpAccepted);
+
+        auto it = streamMaps_[SBookChanges].begin();
+        while (it != streamMaps_[SBookChanges].end())
+        {
+            InfoSub::pointer p = it->second.lock();
+            if (p)
+            {
+                p->send(jvObj, true);
+                toRelease.push_back(std::move(p));
+                ++it;
+            }
+            else
+            {
+                it = streamMaps_[SBookChanges].erase(it);
+            }
+        }
+    }
+}
+
+void
+NetworkOPsImp::kickoffAccountHistory(std::shared_ptr const& alpAccepted)
+{
+    // Runs exactly once, the first time a ledger is published. The atomic
+    // exchange lets the common post-first-ledger path return without taking
+    // accountLock_, while still admitting exactly one caller even if ledger
+    // publishing is ever made concurrent.
+    static std::atomic done{false};
+    if (done.exchange(true))
+        return;
+
+    // It only reads/writes subAccountHistory_, so it takes accountLock_ alone.
+    std::scoped_lock const sl(accountLock_);
+    for (auto& outer : subAccountHistory_)
+    {
+        for (auto& inner : outer.second)
+        {
+            auto& subInfo = inner.second;
+            if (subInfo.index->separationLedgerSeq == 0)
+                subAccountHistoryStart(alpAccepted->getLedger(), subInfo);
+        }
     }
 }
 
@@ -3249,7 +3431,7 @@ NetworkOPsImp::getLocalTxCount()
 std::size_t
 NetworkOPsImp::getBookSubscribersCount()
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(bookLock_);
     std::size_t total = 0;
     for (auto const& [_, subs] : subBook_)
         total += subs.size();
@@ -3281,9 +3463,9 @@ NetworkOPsImp::transJson(
     if (meta)
     {
         jvObj[jss::meta] = meta->get().getJson(JsonOptions::Values::None);
-        RPC::insertDeliveredAmount(jvObj[jss::meta], *ledger, transaction, meta->get());
-        RPC::insertNFTSyntheticInJson(jvObj, transaction, meta->get());
-        RPC::insertMPTokenIssuanceID(jvObj[jss::meta], transaction, meta->get());
+        rpc::insertDeliveredAmount(jvObj[jss::meta], *ledger, transaction, meta->get());
+        rpc::insertNFTSyntheticInJson(jvObj, transaction, meta->get());
+        rpc::insertMPTokenIssuanceID(jvObj[jss::meta], transaction, meta->get());
     }
 
     // add CTID where the needed data for it exists
@@ -3295,7 +3477,7 @@ NetworkOPsImp::transJson(
         if (transaction->isFieldPresent(sfNetworkID))
             netID = transaction->getFieldU32(sfNetworkID);
 
-        if (std::optional ctid = RPC::encodeCTID(ledger->header().seq, txnSeq, netID);
+        if (std::optional ctid = rpc::encodeCTID(ledger->header().seq, txnSeq, netID);
             ctid)
             jvObj[jss::ctid] = *ctid;
     }
@@ -3346,7 +3528,7 @@ NetworkOPsImp::transJson(
     forAllApiVersions(
         multiObj.visit(),  //
         [&](json::Value& jvTx, std::integral_constant) {
-            RPC::insertDeliverMax(jvTx[jss::transaction], transaction->getTxnType(), Version);
+            rpc::insertDeliverMax(jvTx[jss::transaction], transaction->getTxnType(), Version);
 
             if constexpr (Version > 1)
             {
@@ -3376,7 +3558,13 @@ NetworkOPsImp::pubValidatedTransaction(
     MultiApiJson const jvObj = transJson(stTxn, trResult, true, ledger, metaRef);
 
     {
-        std::scoped_lock const sl(subLock_);
+        // Hold each locked subscriber alive until after streamLock_ is
+        // released; a last-reference ~InfoSub would otherwise re-acquire this
+        // non-recursive mutex and self-deadlock. Declared before the lock,
+        // destroyed after the block ends; covers both loops below.
+        std::vector toRelease;
+
+        std::scoped_lock const sl(streamLock_);
 
         auto it = streamMaps_[STransactions].begin();
         while (it != streamMaps_[STransactions].end())
@@ -3388,6 +3576,7 @@ NetworkOPsImp::pubValidatedTransaction(
                 jvObj.visit(
                     p->getApiVersion(),  //
                     [&](json::Value const& jv) { p->send(jv, true); });
+                toRelease.push_back(std::move(p));
                 ++it;
             }
             else
@@ -3407,6 +3596,7 @@ NetworkOPsImp::pubValidatedTransaction(
                 jvObj.visit(
                     p->getApiVersion(),  //
                     [&](json::Value const& jv) { p->send(jv, true); });
+                toRelease.push_back(std::move(p));
                 ++it;
             }
             else
@@ -3431,20 +3621,20 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con
 
     // Two-pass design:
     //
-    //   1. Under subLock_, walk subBook_, collect a strong pointer for each
+    //   1. Under bookLock_, walk subBook_, collect a strong pointer for each
     //      unique listener (and prune any expired weak_ptrs we encounter).
-    //   2. Release subLock_, then send to each collected listener.
+    //   2. Release bookLock_, then send to each collected listener.
     //
     // Reasoning:
-    //   * send() can be slow / blocking, so holding subLock_ across it would
-    //     stall every other sub/unsub/pub path on this server (see the matching
-    //     TODO above pubServer at line ~2275).
-    //   * A strong pointer destructed while subLock_ is held risks running
+    //   * send() can be slow / blocking, so holding bookLock_ across it would
+    //     stall every other book sub/unsub/pub path on this server (see the
+    //     matching TODO above pubServer at line ~2275).
+    //   * A strong pointer destructed while bookLock_ is held risks running
     //     ~InfoSub() in-line, which re-enters unsubBook() and mutates the very
     //     subBook_/SubMapType being iterated -> dangling iterator UB.
     //
-    // Releasing subLock_ before any InfoSub::pointer can decay solves both.
-    // ~InfoSub() reacquires subLock_ via unsubBook() on its own and serializes
+    // Releasing bookLock_ before any InfoSub::pointer can decay solves both.
+    // ~InfoSub() reacquires bookLock_ via unsubBook() on its own and serializes
     // safely with concurrent traffic.
 
     std::vector listeners;
@@ -3458,7 +3648,7 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con
     seen.reserve(books.size());
 
     {
-        std::scoped_lock const sl(subLock_);
+        std::scoped_lock const sl(bookLock_);
 
         for (auto const& book : books)
         {
@@ -3496,8 +3686,8 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con
     {
         jvObj.visit(p->getApiVersion(), [&](json::Value const& jv) { p->send(jv, true); });
     }
-    // listeners destructs here, outside subLock_; ~InfoSub (if any fires)
-    // will reacquire subLock_ via unsubBook with no iterator hazard.
+    // listeners destructs here, outside bookLock_; ~InfoSub (if any fires)
+    // will reacquire bookLock_ via unsubBook with no iterator hazard.
 }
 
 void
@@ -3513,7 +3703,7 @@ NetworkOPsImp::pubAccountTransaction(
     std::vector accountHistoryNotify;
     auto const currLedgerSeq = ledger->seq();
     {
-        std::scoped_lock const sl(subLock_);
+        std::scoped_lock const sl(accountLock_);
 
         if (!subAccount_.empty() || !subRTAccount_.empty() || !subAccountHistory_.empty())
         {
@@ -3646,7 +3836,7 @@ NetworkOPsImp::pubProposedAccountTransaction(
     std::vector accountHistoryNotify;
 
     {
-        std::scoped_lock const sl(subLock_);
+        std::scoped_lock const sl(accountLock_);
 
         if (subRTAccount_.empty())
             return;
@@ -3730,7 +3920,7 @@ NetworkOPsImp::subAccount(
         isrListener->insertSubAccountInfo(naAccountID, rt);
     }
 
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(accountLock_);
 
     for (auto const& naAccountID : vnaAccountIDs)
     {
@@ -3773,7 +3963,7 @@ NetworkOPsImp::unsubAccountInternal(
     hash_set const& vnaAccountIDs,
     bool rt)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(accountLock_);
 
     SubInfoMapType& subMap = rt ? subRTAccount_ : subAccount_;
 
@@ -3795,6 +3985,122 @@ NetworkOPsImp::unsubAccountInternal(
     }
 }
 
+template 
+void
+NetworkOPsImp::cleanupSubscriptionMap(
+    std::uint64_t seq,
+    hash_set const& accounts,
+    OuterMap& outerMap,
+    BeforeErase&& beforeErase)
+{
+    // Walk the disconnecting connection's accounts in chunks. Each chunk takes
+    // accountLock_, erases up to kAccountCleanupChunk entries, then releases
+    // the lock so a competing account-publish can run before the next chunk.
+    // No iterator into outerMap is held across the unlock: every chunk re-finds
+    // each account, so a concurrent mutation between chunks cannot dangle.
+    auto it = accounts.begin();
+    auto const end = accounts.end();
+    while (it != end)
+    {
+        std::scoped_lock const sl(accountLock_);
+
+        for (std::size_t n = 0; n < kAccountCleanupChunk && it != end; ++n, ++it)
+        {
+            auto outerIter = outerMap.find(*it);
+            if (outerIter != outerMap.end())
+            {
+                // Give the caller a chance to tear down this connection's inner
+                // entry before it is erased (the history map stops its paging
+                // job here); the plain account maps pass a no-op.
+                auto innerIter = outerIter->second.find(seq);
+                if (innerIter != outerIter->second.end())
+                    beforeErase(innerIter->second);
+
+                // Erase only this connection's seq; other connections sharing
+                // the account keep their entry, so a reconnect is unaffected.
+                outerIter->second.erase(seq);
+                if (outerIter->second.empty())
+                    outerMap.erase(outerIter);
+            }
+        }
+    }
+}
+
+void
+NetworkOPsImp::cleanupAccountSubscriptions(
+    std::uint64_t seq,
+    hash_set const& accounts,
+    SubInfoMapType& subMap)
+{
+    // Plain account maps need no per-entry teardown before erase.
+    cleanupSubscriptionMap(seq, accounts, subMap, [](InfoSub::wptr const&) {});
+}
+
+void
+NetworkOPsImp::cleanupAccountHistorySubscriptions(
+    std::uint64_t seq,
+    hash_set const& accounts)
+{
+    // Cancel any in-flight historical paging job for this connection before
+    // dropping its record. The job holds its own shared_ptr to the index, so
+    // erasing the map entry alone would not stop it; it reads this atomic
+    // between pages and exits promptly once set.
+    cleanupSubscriptionMap(
+        seq, accounts, subAccountHistory_, [](SubAccountHistoryInfoWeak const& info) {
+            info.index->stopHistorical = true;
+        });
+}
+
+void
+NetworkOPsImp::scheduleAccountCleanup(
+    std::uint64_t seq,
+    hash_set rtAccounts,
+    hash_set normalAccounts,
+    hash_set historyAccounts)
+{
+    // Nothing to do for a connection that never subscribed to any account.
+    if (rtAccounts.empty() && normalAccounts.empty() && historyAccounts.empty())
+        return;
+
+    // Post the erase work to a low-priority job so the disconnect thread (and
+    // ~InfoSub) returns immediately. The job captures the sets BY MOVE and
+    // operates purely on seq + the captured accounts; it never touches the
+    // destroyed InfoSub. `this` outlives the job per the Source lifetime
+    // contract. Running on a JobQueue thread, it cannot re-enter accountLock_
+    // held by the disconnecting thread, so the plain std::mutex is safe.
+    //
+    // The body is exception-guarded: the JobQueue invokes it bare, so an
+    // escaping exception on the worker thread would terminate the process.
+    //
+    // addJob returns false only once the JobQueue has been stopped, i.e. during
+    // process shutdown. At that point NetworkOPsImp's maps are about to be
+    // destroyed wholesale and no publish path can run, so dropping the cleanup
+    // is harmless; no inline fallback is needed.
+    jobQueue_.addJob(
+        JtClientAcctHist,
+        "SubCleanup",
+        [this,
+         seq,
+         rt = std::move(rtAccounts),
+         normal = std::move(normalAccounts),
+         history = std::move(historyAccounts)]() noexcept {
+            try
+            {
+                cleanupAccountSubscriptions(seq, rt, subRTAccount_);
+                cleanupAccountSubscriptions(seq, normal, subAccount_);
+                cleanupAccountHistorySubscriptions(seq, history);
+            }
+            catch (std::exception const& e)
+            {
+                JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: " << e.what();
+            }
+            catch (...)
+            {
+                JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: unknown exception";
+            }
+        });
+}
+
 void
 NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo)
 {
@@ -3815,7 +4121,7 @@ NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo)
             if (accountId == kGenesisAccountId)
             {
                 auto stx = tx->getSTransaction();
-                if (stx->getAccountID(sfAccount) == accountId && stx->getSeqValue() == 1)
+                if (stx->getAccountID(sfAccount) == accountId && stx->getSeqProxy().value() == 1)
                     return true;
             }
 
@@ -3891,7 +4197,7 @@ NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo)
             int feeChargeCount = 0;
             if (auto sptr = subInfo.sinkWptr.lock(); sptr)
             {
-                sptr->getConsumer().charge(Resource::kFeeMediumBurdenRpc);
+                sptr->getConsumer().charge(resource::kFeeMediumBurdenRpc);
                 ++feeChargeCount;
             }
             else
@@ -4077,7 +4383,7 @@ NetworkOPsImp::subAccountHistory(InfoSub::ref isrListener, AccountID const& acco
         return RpcInvalidParams;
     }
 
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(accountLock_);
     SubAccountHistoryInfoWeak ahi{
         .sinkWptr = isrListener, .index = std::make_shared(accountId)};
     auto simIterator = subAccountHistory_.find(accountId);
@@ -4125,7 +4431,7 @@ NetworkOPsImp::unsubAccountHistoryInternal(
     AccountID const& account,
     bool historyOnly)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(accountLock_);
     auto simIterator = subAccountHistory_.find(account);
     if (simIterator != subAccountHistory_.end())
     {
@@ -4157,7 +4463,7 @@ NetworkOPsImp::subBook(InfoSub::ref isrListener, Book const& book)
     // prune in pubBookTransaction. With the reverse ordering, ~InfoSub would
     // call unsubBookInternal for a key that was never inserted server-side.
     {
-        std::scoped_lock const sl(subLock_);
+        std::scoped_lock const sl(bookLock_);
         subBook_[book].try_emplace(isrListener->getSeq(), isrListener);
     }
     isrListener->insertBookSubscription(book);
@@ -4177,7 +4483,7 @@ NetworkOPsImp::unsubBook(InfoSub::ref isrListener, Book const& book)
 bool
 NetworkOPsImp::unsubBookInternal(std::uint64_t uSeq, Book const& book)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(bookLock_);
     auto it = subBook_.find(book);
     if (it == subBook_.end())
         return false;
@@ -4227,7 +4533,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult)
         jvResult[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers();
     }
 
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SLedger].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4235,7 +4541,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult)
 bool
 NetworkOPsImp::subBookChanges(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SBookChanges].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4243,7 +4549,7 @@ NetworkOPsImp::subBookChanges(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubLedger(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SLedger].erase(uSeq) != 0u;
 }
 
@@ -4251,7 +4557,7 @@ NetworkOPsImp::unsubLedger(std::uint64_t uSeq)
 bool
 NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SBookChanges].erase(uSeq) != 0u;
 }
 
@@ -4259,7 +4565,7 @@ NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subManifests(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SManifests].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4267,7 +4573,7 @@ NetworkOPsImp::subManifests(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubManifests(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SManifests].erase(uSeq) != 0u;
 }
 
@@ -4292,7 +4598,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a
     jvResult[jss::pubkey_node] =
         toBase58(TokenType::NodePublic, registry_.get().getApp().nodeIdentity().first);
 
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SServer].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4300,7 +4606,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a
 bool
 NetworkOPsImp::unsubServer(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SServer].erase(uSeq) != 0u;
 }
 
@@ -4308,7 +4614,7 @@ NetworkOPsImp::unsubServer(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subTransactions(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[STransactions].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4316,7 +4622,7 @@ NetworkOPsImp::subTransactions(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubTransactions(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[STransactions].erase(uSeq) != 0u;
 }
 
@@ -4324,7 +4630,7 @@ NetworkOPsImp::unsubTransactions(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SRtTransactions].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4332,7 +4638,7 @@ NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SRtTransactions].erase(uSeq) != 0u;
 }
 
@@ -4340,7 +4646,7 @@ NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subValidations(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SValidations].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4354,7 +4660,7 @@ NetworkOPsImp::stateAccounting(json::Value& obj)
 bool
 NetworkOPsImp::unsubValidations(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SValidations].erase(uSeq) != 0u;
 }
 
@@ -4362,7 +4668,7 @@ NetworkOPsImp::unsubValidations(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SPeerStatus].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4370,7 +4676,7 @@ NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SPeerStatus].erase(uSeq) != 0u;
 }
 
@@ -4378,7 +4684,7 @@ NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq)
 bool
 NetworkOPsImp::subConsensus(InfoSub::ref isrListener)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SConsensusPhase].emplace(isrListener->getSeq(), isrListener).second;
 }
 
@@ -4386,15 +4692,14 @@ NetworkOPsImp::subConsensus(InfoSub::ref isrListener)
 bool
 NetworkOPsImp::unsubConsensus(std::uint64_t uSeq)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
     return streamMaps_[SConsensusPhase].erase(uSeq) != 0u;
 }
 
 InfoSub::pointer
-NetworkOPsImp::findRpcSub(std::string const& strUrl)
+NetworkOPsImp::findRpcSubLocked(std::string const& strUrl)
 {
-    std::scoped_lock const sl(subLock_);
-
+    // Caller already holds streamLock_; this performs the lookup only.
     auto const it = rpcSubMap_.find(strUrl);
 
     if (it != rpcSubMap_.end())
@@ -4403,10 +4708,17 @@ NetworkOPsImp::findRpcSub(std::string const& strUrl)
     return InfoSub::pointer();
 }
 
+InfoSub::pointer
+NetworkOPsImp::findRpcSub(std::string const& strUrl)
+{
+    std::scoped_lock const sl(streamLock_);
+    return findRpcSubLocked(strUrl);
+}
+
 InfoSub::pointer
 NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry)
 {
-    std::scoped_lock const sl(subLock_);
+    std::scoped_lock const sl(streamLock_);
 
     rpcSubMap_.emplace(strUrl, rspEntry);
 
@@ -4416,20 +4728,31 @@ NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry)
 bool
 NetworkOPsImp::tryRemoveRpcSub(std::string const& strUrl)
 {
-    std::scoped_lock const sl(subLock_);
-    auto pInfo = findRpcSub(strUrl);
-
-    if (!pInfo)
-        return false;
-
-    // check to see if any of the stream maps still hold a weak reference to
-    // this entry before removing
-    for (SubMapType const& map : streamMaps_)
+    // Declared before the lock so it outlives the scoped_lock and is destroyed
+    // only after streamLock_ is released. The erase below may drop the last
+    // strong reference; if so, ~InfoSub runs and its unsub* calls re-acquire
+    // the non-recursive streamLock_. Destroying pInfo inside the lock would
+    // self-deadlock.
+    InfoSub::pointer pInfo;
     {
-        if (map.contains(pInfo->getSeq()))
+        std::scoped_lock const sl(streamLock_);
+        // Use the no-lock helper: we already hold streamLock_ and the mutex is
+        // not recursive, so calling the public findRpcSub here would deadlock.
+        pInfo = findRpcSubLocked(strUrl);
+
+        if (!pInfo)
             return false;
+
+        // check to see if any of the stream maps still hold a weak reference to
+        // this entry before removing
+        for (SubMapType const& map : streamMaps_)
+        {
+            if (map.contains(pInfo->getSeq()))
+                return false;
+        }
+        rpcSubMap_.erase(strUrl);
     }
-    rpcSubMap_.erase(strUrl);
+    // pInfo destroyed here, after streamLock_ is released.
     return true;
 }
 
diff --git a/src/xrpld/app/misc/SHAMapStore.h b/src/xrpld/app/misc/SHAMapStore.h
index df696c685f..eeb04df53d 100644
--- a/src/xrpld/app/misc/SHAMapStore.h
+++ b/src/xrpld/app/misc/SHAMapStore.h
@@ -43,7 +43,7 @@ public:
     [[nodiscard]] virtual std::uint32_t
     clampFetchDepth(std::uint32_t fetchDepth) const = 0;
 
-    virtual std::unique_ptr
+    virtual std::unique_ptr
     makeNodeStore(int readThreads) = 0;
 
     /**
@@ -101,5 +101,5 @@ public:
 //------------------------------------------------------------------------------
 
 std::unique_ptr
-makeSHAMapStore(Application& app, NodeStore::Scheduler& scheduler, beast::Journal journal);
+makeSHAMapStore(Application& app, node_store::Scheduler& scheduler, beast::Journal journal);
 }  // namespace xrpl
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp
index 9b5f412fc5..9e3f1ac52b 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.cpp
+++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp
@@ -6,6 +6,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -27,12 +28,10 @@
 #include 
 
 #include 
-#include 
-#include 
-#include 
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -94,7 +93,7 @@ SHAMapStoreImp::SavedStateDB::setLastRotated(LedgerIndex seq)
 
 SHAMapStoreImp::SHAMapStoreImp(
     Application& app,
-    NodeStore::Scheduler& scheduler,
+    node_store::Scheduler& scheduler,
     beast::Journal journal)
     : app_(app)
     , scheduler_(scheduler)
@@ -165,7 +164,7 @@ SHAMapStoreImp::SHAMapStoreImp(
     }
 }
 
-std::unique_ptr
+std::unique_ptr
 SHAMapStoreImp::makeNodeStore(int readThreads)
 {
     auto nscfg = app_.config().section(Sections::kNodeDatabase);
@@ -185,7 +184,7 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
             std::to_string(app_.config().getValueFor(SizedItem::TreeCacheAge, std::nullopt)));
     }
 
-    std::unique_ptr db;
+    std::unique_ptr db;
 
     if (deleteInterval_ != 0u)
     {
@@ -201,7 +200,7 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
 
         // Create NodeStore with two backends to allow online deletion of
         // data
-        auto dbr = std::make_unique(
+        auto dbr = std::make_unique(
             scheduler_,
             readThreads,
             std::move(writableBackend),
@@ -210,11 +209,11 @@ SHAMapStoreImp::makeNodeStore(int readThreads)
             app_.getJournal(kNodeStoreName));
         fdRequired_ += dbr->fdRequired();
         dbRotating_ = dbr.get();
-        db.reset(dynamic_cast(dbr.release()));
+        db.reset(dynamic_cast(dbr.release()));
     }
     else
     {
-        db = NodeStore::Manager::instance().makeDatabase(
+        db = node_store::Manager::instance().makeDatabase(
             megabytes(app_.config().getValueFor(SizedItem::BurstSize, std::nullopt)),
             scheduler_,
             readThreads,
@@ -257,7 +256,7 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node)
 {
     // Copy a single record from node to dbRotating_
     auto obj = dbRotating_->fetchNodeObject(
-        node.getHash().asUInt256(), 0, NodeStore::FetchType::Synchronous, true);
+        node.getHash().asUInt256(), 0, node_store::FetchType::Synchronous, true);
     if (!obj)
     {
         XRPL_ASSERT(node.cowid() == 0, "SHAMapStoreImp::copyNode : rescued node must be clean");
@@ -374,7 +373,7 @@ SHAMapStoreImp::run()
             // exception) also clear the flag.
             struct RotationExposureGuard
             {
-                NodeStore::DatabaseRotating& db;
+                node_store::DatabaseRotating& db;
                 ~RotationExposureGuard()
                 {
                     db.setRotationInFlight(false);
@@ -426,10 +425,10 @@ SHAMapStoreImp::dbPaths()
     if (boost::iequals(get(section, Keys::kType), "memory"))
         return;
 
-    boost::filesystem::path dbPath = get(section, Keys::kPath);
-    if (boost::filesystem::exists(dbPath))
+    std::filesystem::path dbPath = get(section, Keys::kPath);
+    if (std::filesystem::exists(dbPath))
     {
-        if (!boost::filesystem::is_directory(dbPath))
+        if (!std::filesystem::is_directory(dbPath))
         {
             journal_.error() << "node db path must be a directory. " << dbPath.string();
             Throw("node db path must be a directory.");
@@ -437,7 +436,7 @@ SHAMapStoreImp::dbPaths()
     }
     else
     {
-        boost::filesystem::create_directories(dbPath);
+        std::filesystem::create_directories(dbPath);
     }
 
     SavedState state = stateDb_.getState();
@@ -448,8 +447,8 @@ SHAMapStoreImp::dbPaths()
                 return false;
 
             // Check if configured "path" matches stored directory path
-            using namespace boost::filesystem;
-            auto const stored{path(sPath)};
+            using namespace std::filesystem;
+            auto const stored{std::filesystem::path(sPath)};
             if (stored.parent_path() == dbPath)
                 return false;
 
@@ -467,9 +466,9 @@ SHAMapStoreImp::dbPaths()
     bool writableDbExists = false;
     bool archiveDbExists = false;
 
-    std::vector pathsToDelete;
-    for (boost::filesystem::directory_iterator it(dbPath);
-         it != boost::filesystem::directory_iterator();
+    std::vector pathsToDelete;
+    for (std::filesystem::directory_iterator it(dbPath);
+         it != std::filesystem::directory_iterator();
          ++it)
     {
         if (state.writableDb == it->path().string())
@@ -490,7 +489,7 @@ SHAMapStoreImp::dbPaths()
         (!archiveDbExists && !state.archiveDb.empty()) || (writableDbExists != archiveDbExists) ||
         state.writableDb.empty() != state.archiveDb.empty())
     {
-        boost::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath);
+        std::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath);
         stateDbPathName /= dbName_;
         stateDbPathName += "*";
 
@@ -512,15 +511,15 @@ SHAMapStoreImp::dbPaths()
     }
 
     // The necessary directories exist. Now, remove any others.
-    for (boost::filesystem::path const& p : pathsToDelete)
-        boost::filesystem::remove_all(p);
+    for (std::filesystem::path const& p : pathsToDelete)
+        std::filesystem::remove_all(p);
 }
 
-std::unique_ptr
+std::unique_ptr
 SHAMapStoreImp::makeBackendRotating(std::string path)
 {
     Section section{app_.config().section(Sections::kNodeDatabase)};
-    boost::filesystem::path newPath;
+    std::filesystem::path newPath;
 
     if (!path.empty())
     {
@@ -528,14 +527,11 @@ SHAMapStoreImp::makeBackendRotating(std::string path)
     }
     else
     {
-        boost::filesystem::path p = get(section, Keys::kPath);
-        p /= dbPrefix_;
-        p += ".%%%%";
-        newPath = boost::filesystem::unique_path(p);
+        newPath = uniqueRandomPath(get(section, Keys::kPath), dbPrefix_ + ".");
     }
     section.set(Keys::kPath, newPath.string());
 
-    auto backend{NodeStore::Manager::instance().makeBackend(
+    auto backend{node_store::Manager::instance().makeBackend(
         section,
         megabytes(app_.config().getValueFor(SizedItem::BurstSize, std::nullopt)),
         scheduler_,
@@ -702,7 +698,7 @@ SHAMapStoreImp::minimumOnline() const
 //------------------------------------------------------------------------------
 
 std::unique_ptr
-makeSHAMapStore(Application& app, NodeStore::Scheduler& scheduler, beast::Journal journal)
+makeSHAMapStore(Application& app, node_store::Scheduler& scheduler, beast::Journal journal)
 {
     return std::make_unique(app, scheduler, journal);
 }
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h
index a0ca59ecc8..8a1b7504b9 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.h
+++ b/src/xrpld/app/misc/SHAMapStoreImp.h
@@ -81,9 +81,9 @@ private:
     // minimum ledger to maintain online.
     std::atomic minimumOnline_;
 
-    NodeStore::Scheduler& scheduler_;
+    node_store::Scheduler& scheduler_;
     beast::Journal const journal_;
-    NodeStore::DatabaseRotating* dbRotating_ = nullptr;
+    node_store::DatabaseRotating* dbRotating_ = nullptr;
     SavedStateDB stateDb_;
     std::thread thread_;
     bool stop_ = false;
@@ -119,7 +119,7 @@ private:
     static constexpr auto kNodeStoreName = "NodeStore";
 
 public:
-    SHAMapStoreImp(Application& app, NodeStore::Scheduler& scheduler, beast::Journal journal);
+    SHAMapStoreImp(Application& app, node_store::Scheduler& scheduler, beast::Journal journal);
 
     std::uint32_t
     clampFetchDepth(std::uint32_t fetchDepth) const override
@@ -127,7 +127,7 @@ public:
         return (deleteInterval_ != 0u) ? std::min(fetchDepth, deleteInterval_) : fetchDepth;
     }
 
-    std::unique_ptr
+    std::unique_ptr
     makeNodeStore(int readThreads) override;
 
     LedgerIndex
@@ -180,7 +180,7 @@ private:
     void
     dbPaths();
 
-    std::unique_ptr
+    std::unique_ptr
     makeBackendRotating(std::string path = std::string());
 
     template 
@@ -191,7 +191,7 @@ private:
 
         for (auto const& key : cache.getKeys())
         {
-            dbRotating_->fetchNodeObject(key, 0, NodeStore::FetchType::Synchronous, true);
+            dbRotating_->fetchNodeObject(key, 0, node_store::FetchType::Synchronous, true);
             if (!(++check % checkHealthInterval_) && healthWait() == HealthResult::Stopping)
                 return true;
         }
diff --git a/src/xrpld/app/misc/Transaction.h b/src/xrpld/app/misc/Transaction.h
index b6b6d1a8d5..61951fbb59 100644
--- a/src/xrpld/app/misc/Transaction.h
+++ b/src/xrpld/app/misc/Transaction.h
@@ -15,6 +15,10 @@
 #include 
 #include 
 
+// boost::optional (not std::optional) appears in the declarations below,
+// because SOCI's into()/use() bindings only support boost::optional.
+#include 
+
 #include 
 #include 
 #include 
diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h
index 3f9039eab8..4e001affe8 100644
--- a/src/xrpld/app/misc/ValidatorList.h
+++ b/src/xrpld/app/misc/ValidatorList.h
@@ -17,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,7 +30,6 @@
 #include 
 
 namespace protocol {
-class TMValidatorList;
 class TMValidatorListCollection;
 }  // namespace protocol
 
@@ -238,7 +238,7 @@ class ValidatorList
     ManifestCache& validatorManifests_;
     ManifestCache& publisherManifests_;
     TimeKeeper& timeKeeper_;
-    boost::filesystem::path const dataPath_;
+    std::filesystem::path const dataPath_;
     beast::Journal const j_;
     std::shared_mutex mutable mutex_;
     using scoped_lock = std::scoped_lock;
@@ -370,9 +370,6 @@ public:
     static std::vector
     parseBlobs(std::uint32_t version, json::Value const& body);
 
-    static std::vector
-    parseBlobs(protocol::TMValidatorList const& body);
-
     static std::vector
     parseBlobs(protocol::TMValidatorListCollection const& body);
 
@@ -390,7 +387,6 @@ public:
 
     [[nodiscard]] static std::pair
     buildValidatorListMessages(
-        std::size_t messageVersion,
         std::uint64_t peerSequence,
         std::size_t maxSequence,
         std::uint32_t rawVersion,
@@ -866,7 +862,7 @@ private:
     /**
      * Get the filename used for caching UNLs
      */
-    boost::filesystem::path
+    std::filesystem::path
     getCacheFileName(scoped_lock const&, PublicKey const& pubKey) const;
 
     /**
@@ -986,14 +982,6 @@ hash_append(Hasher& h, std::map const& blobs)
 
 namespace protocol {
 
-template 
-void
-hash_append(Hasher& h, TMValidatorList const& msg)
-{
-    using beast::hash_append;
-    hash_append(h, msg.manifest(), msg.blob(), msg.signature(), msg.version());
-}
-
 template 
 void
 hash_append(Hasher& h, TMValidatorListCollection const& msg)
diff --git a/src/xrpld/app/misc/detail/DeliverMax.cpp b/src/xrpld/app/misc/detail/DeliverMax.cpp
index add3cf89ee..e512b078c7 100644
--- a/src/xrpld/app/misc/detail/DeliverMax.cpp
+++ b/src/xrpld/app/misc/detail/DeliverMax.cpp
@@ -3,7 +3,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 void
 insertDeliverMax(json::Value& txJson, TxType txnType, unsigned int apiVersion)
@@ -19,4 +19,4 @@ insertDeliverMax(json::Value& txJson, TxType txnType, unsigned int apiVersion)
     }
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/app/misc/detail/Transaction.cpp b/src/xrpld/app/misc/detail/Transaction.cpp
index e29181bfe9..59cc4b6c4c 100644
--- a/src/xrpld/app/misc/detail/Transaction.cpp
+++ b/src/xrpld/app/misc/detail/Transaction.cpp
@@ -182,7 +182,7 @@ Transaction::getJson(JsonOptions options, bool binary) const
 
         if (txnSeq_ && netID)
         {
-            std::optional const ctid = RPC::encodeCTID(ledgerIndex_, *txnSeq_, *netID);
+            std::optional const ctid = rpc::encodeCTID(ledgerIndex_, *txnSeq_, *netID);
             if (ctid)
                 ret[jss::ctid] = *ctid;
         }
diff --git a/src/xrpld/app/misc/detail/TxQ.cpp b/src/xrpld/app/misc/detail/TxQ.cpp
index 041d2ade1e..b9cfc1d65c 100644
--- a/src/xrpld/app/misc/detail/TxQ.cpp
+++ b/src/xrpld/app/misc/detail/TxQ.cpp
@@ -773,7 +773,7 @@ TxQ::apply(
         return {terNO_ACCOUNT, false};
 
     // If the transaction needs a Ticket is that Ticket in the ledger?
-    SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]);
+    SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]);
     SeqProxy const txSeqProx = tx->getSeqProxy();
     if (txSeqProx.isTicket() && !view.exists(keylet::ticket(account, txSeqProx)))
     {
@@ -1605,9 +1605,9 @@ TxQ::nextQueuableSeqImpl(SLE::const_ref sleAccount, std::scoped_lock
     // If the account is not in the ledger or a non-account was passed
     // then return zero.  We have no idea.
     if (!sleAccount || sleAccount->getType() != ltACCOUNT_ROOT)
-        return SeqProxy::sequence(0);
+        return SeqProxy::rawSequence(0);
 
-    SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]);
+    SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]);
 
     // If the account is not in the queue then acctSeqProx is good enough.
     auto const accountIter = byAccount_.find((*sleAccount)[sfAccount]);
@@ -1669,7 +1669,7 @@ TxQ::tryDirectApply(
     if (!sleAccount)
         return {};
 
-    SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]);
+    SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]);
     SeqProxy const txSeqProx = tx->getSeqProxy();
 
     // Can only directly apply if the transaction sequence matches the account
diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp
index a9e7156158..f099ebf059 100644
--- a/src/xrpld/app/misc/detail/ValidatorList.cpp
+++ b/src/xrpld/app/misc/detail/ValidatorList.cpp
@@ -29,12 +29,8 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
-#include 
-#include 
-#include 
 
 #include 
 
@@ -43,6 +39,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -54,6 +51,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -288,7 +286,7 @@ ValidatorList::load(
     return true;
 }
 
-boost::filesystem::path
+std::filesystem::path
 ValidatorList::getCacheFileName(ValidatorList::scoped_lock const&, PublicKey const& pubKey) const
 {
     return dataPath_ / (kFilePrefix + strHex(pubKey));
@@ -372,9 +370,9 @@ ValidatorList::cacheValidatorFile(ValidatorList::scoped_lock const& lock, Public
     if (dataPath_.empty())
         return;
 
-    boost::filesystem::path const filename = getCacheFileName(lock, pubKey);
+    std::filesystem::path const filename = getCacheFileName(lock, pubKey);
 
-    boost::system::error_code ec;
+    std::error_code ec;
 
     json::Value value = buildFileData(strHex(pubKey), publisherLists_.at(pubKey), j_);
     // xrpld should be the only process writing to this file, so
@@ -451,13 +449,6 @@ ValidatorList::parseBlobs(std::uint32_t version, json::Value const& body)
     }
 }
 
-// static
-std::vector
-ValidatorList::parseBlobs(protocol::TMValidatorList const& body)
-{
-    return {{.blob = body.blob(), .signature = body.signature(), .manifest = {}}};
-}
-
 // static
 std::vector
 ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body)
@@ -478,7 +469,7 @@ ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body)
     }
     XRPL_ASSERT(
         result.size() == body.blobs_size(),
-        "xrpl::ValidatorList::parseBlobs(TMValidatorList) : result size "
+        "xrpl::ValidatorList::parseBlobs(TMValidatorListCollection) : result size "
         "match");
     return result;
 }
@@ -522,29 +513,6 @@ splitMessageParts(
 {
     if (end <= begin)
         return 0;
-    if (end - begin == 1)
-    {
-        protocol::TMValidatorList smallMsg;
-        smallMsg.set_version(1);
-        smallMsg.set_manifest(largeMsg.manifest());
-
-        auto const& blob = largeMsg.blobs(begin);
-        smallMsg.set_blob(blob.blob());
-        smallMsg.set_signature(blob.signature());
-        // This is only possible if "downgrading" a v2 UNL to v1.
-        if (blob.has_manifest())
-            smallMsg.set_manifest(blob.manifest());
-
-        XRPL_ASSERT(
-            Message::totalSize(smallMsg) <= kMaximumMessageSize,
-            "xrpl::splitMessageParts : maximum message size");
-
-        messages.emplace_back(
-            std::make_shared(smallMsg, protocol::mtVALIDATOR_LIST),
-            sha512Half(smallMsg),
-            1);
-        return messages.back().numVLs;
-    }
 
     std::optional smallMsg;
     smallMsg.emplace();
@@ -556,13 +524,29 @@ splitMessageParts(
         *smallMsg->add_blobs() = largeMsg.blobs(i);
     }
 
-    if (Message::totalSize(*smallMsg) > maxSize)
+    auto const size = Message::totalSize(*smallMsg);
+
+    // Split until each message fits, but a single blob can't be split any
+    // further, so stop recursing at that point regardless of maxSize.
+    if (size > maxSize && end - begin > 1)
     {
         // free up the message space
         smallMsg.reset();
         return splitMessage(messages, largeMsg, maxSize, begin, end);
     }
 
+    // An unsplittable blob is still bounded by the protocol limit: peers drop
+    // messages exceeding it on receipt, so don't waste the bandwidth. maxSize
+    // only ever tightens this (it defaults to kMaximumMessageSize), so a blob
+    // reaching here can exceed maxSize but never the protocol limit.
+    if (size > kMaximumMessageSize)
+    {
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::splitMessageParts : maximum message size exceeded");
+        return 0;
+        // LCOV_EXCL_STOP
+    }
+
     messages.emplace_back(
         std::make_shared(*smallMsg, protocol::mtVALIDATOR_LIST_COLLECTION),
         sha512Half(*smallMsg),
@@ -570,37 +554,6 @@ splitMessageParts(
     return messages.back().numVLs;
 }
 
-// Build a v1 protocol message using only the current VL
-std::size_t
-buildValidatorListMessage(
-    std::vector& messages,
-    std::uint32_t rawVersion,
-    std::string const& rawManifest,
-    ValidatorBlobInfo const& currentBlob,
-    std::size_t maxSize)
-{
-    XRPL_ASSERT(
-        messages.empty(),
-        "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : empty messages "
-        "input");
-    protocol::TMValidatorList msg;
-    auto const manifest = currentBlob.manifest ? *currentBlob.manifest : rawManifest;
-    auto const version = 1;
-    msg.set_manifest(manifest);
-    msg.set_blob(currentBlob.blob);
-    msg.set_signature(currentBlob.signature);
-    // Override the version
-    msg.set_version(version);
-
-    XRPL_ASSERT(
-        Message::totalSize(msg) <= kMaximumMessageSize,
-        "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : maximum "
-        "message size");
-    messages.emplace_back(
-        std::make_shared(msg, protocol::mtVALIDATOR_LIST), sha512Half(msg), 1);
-    return 1;
-}
-
 // Build a v2 protocol message using all the VLs with sequence larger than the
 // peer's
 std::size_t
@@ -652,7 +605,6 @@ buildValidatorListMessage(
 // static
 std::pair
 ValidatorList::buildValidatorListMessages(
-    std::size_t messageVersion,
     std::uint64_t peerSequence,
     std::size_t maxSequence,
     std::uint32_t rawVersion,
@@ -665,14 +617,12 @@ ValidatorList::buildValidatorListMessages(
         !blobInfos.empty(),
         "xrpl::ValidatorList::buildValidatorListMessages : empty messages "
         "input");
-    auto const& [currentSeq, currentBlob] = *blobInfos.begin();
     auto numVLs = std::accumulate(
         messages.begin(), messages.end(), 0, [](std::size_t total, MessageWithHash const& m) {
             return total + m.numVLs;
         });
-    if (messageVersion == 2 && peerSequence < maxSequence)
+    if (peerSequence < maxSequence)
     {
-        // Version 2
         if (messages.empty())
         {
             numVLs = buildValidatorListMessage(
@@ -680,36 +630,13 @@ ValidatorList::buildValidatorListMessages(
             if (messages.empty())
             {
                 // No message was generated. Create an empty placeholder so we
-                // dont' repeat the work later.
+                // don't repeat the work later.
                 messages.emplace_back();
             }
         }
 
-        // Don't send it next time.
         return {maxSequence, numVLs};
     }
-    if (messageVersion == 1 && peerSequence < currentSeq)
-    {
-        // Version 1
-        if (messages.empty())
-        {
-            numVLs = buildValidatorListMessage(
-                messages,
-                rawVersion,
-                currentBlob.manifest ? *currentBlob.manifest : rawManifest,
-                currentBlob,
-                maxSize);
-            if (messages.empty())
-            {
-                // No message was generated. Create an empty placeholder so we
-                // dont' repeat the work later.
-                messages.emplace_back();
-            }
-        }
-
-        // Don't send it next time.
-        return {currentSeq, numVLs};
-    }
     return {0, 0};
 }
 
@@ -727,19 +654,8 @@ ValidatorList::sendValidatorList(
     HashRouter& hashRouter,
     beast::Journal j)
 {
-    std::size_t messageVersion = 0;
-    if (peer.supportsFeature(ProtocolFeature::ValidatorList2Propagation))
-    {
-        messageVersion = 2;
-    }
-    else if (peer.supportsFeature(ProtocolFeature::ValidatorListPropagation))
-    {
-        messageVersion = 1;
-    }
-    if (messageVersion == 0u)
-        return;
     auto const [newPeerSequence, numVLs] = buildValidatorListMessages(
-        messageVersion, peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages);
+        peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages);
     if (newPeerSequence != 0u)
     {
         XRPL_ASSERT(
@@ -766,24 +682,11 @@ ValidatorList::sendValidatorList(
             "xrpl::ValidatorList::sendValidatorList : sent or one message");
         if (sent)
         {
-            if (messageVersion > 1)
-            {
-                JLOG(j.debug()) << "Sent " << messages.size()
-                                << " validator list collection(s) containing " << numVLs
-                                << " validator list(s) for " << strHex(publisherKey)
-                                << " with sequence range " << peerSequence << ", "
-                                << newPeerSequence << " to " << peer.fingerprint();
-            }
-            else
-            {
-                XRPL_ASSERT(
-                    numVLs == 1,
-                    "xrpl::ValidatorList::sendValidatorList : one validator "
-                    "list");
-                JLOG(j.debug()) << "Sent validator list for " << strHex(publisherKey)
-                                << " with sequence " << newPeerSequence << " to "
-                                << peer.fingerprint();
-            }
+            JLOG(j.debug()) << "Sent " << messages.size()
+                            << " validator list collection(s) containing " << numVLs
+                            << " validator list(s) for " << strHex(publisherKey)
+                            << " with sequence range " << peerSequence << ", " << newPeerSequence
+                            << " to " << peer.fingerprint();
         }
     }
 }
@@ -858,16 +761,9 @@ ValidatorList::broadcastBlobs(
 
     if (toSkip)
     {
-        // We don't know what messages or message versions we're sending
-        // until we examine our peer's properties. Build the message(s) on
-        // demand, but reuse them when possible.
-
-        // This will hold a v1 message with only the current VL if we have
-        // any peers that don't support v2
-        std::vector messages1;
-        // This will hold v2 messages indexed by the peer's
-        // `publisherListSequence`. For each `publisherListSequence`, we'll
-        // only send the VLs with higher sequences.
+        // Build v2 messages on demand and reuse them when possible. Messages
+        // are indexed by the peer's `publisherListSequence`; for each sequence,
+        // we only send VLs with higher sequences.
         std::map> messages2;
         // If any peers are found that are worth considering, this list will
         // be built to hold info for all of the valid VLs.
@@ -887,8 +783,6 @@ ValidatorList::broadcastBlobs(
                 {
                     if (blobInfos.empty())
                         buildBlobInfos(blobInfos, lists);
-                    auto const v2 =
-                        peer->supportsFeature(ProtocolFeature::ValidatorList2Propagation);
                     sendValidatorList(
                         *peer,
                         peerSequence,
@@ -897,11 +791,10 @@ ValidatorList::broadcastBlobs(
                         lists.rawVersion,
                         lists.rawManifest,
                         blobInfos,
-                        v2 ? messages2[peerSequence] : messages1,
+                        messages2[peerSequence],
                         hashRouter,
                         j);
-                    // Even if the peer doesn't support the messages,
-                    // suppress it so it'll be ignored next time.
+                    // Don't send it next time.
                     hashRouter.addSuppressionPeer(hash, peer->id());
                 }
             }
@@ -1065,6 +958,8 @@ ValidatorList::updatePublisherList(
         {
             // Increment list count for added keys
             ++keyListings_[*iNew];
+            // Key is now listed: free its untrusted slot if it had one.
+            validatorManifests_.promoteToTrusted(*iNew);
             ++iNew;
         }
         else if (iNew == publisherList.end() || (iOld != oldList.end() && *iOld < *iNew))
@@ -1103,7 +998,8 @@ ValidatorList::updatePublisherList(
             continue;
         }
 
-        if (auto const r = validatorManifests_.applyManifest(std::move(*m));
+        if (auto const r = validatorManifests_.applyManifest(
+                std::move(*m), ManifestRateLimitCapPolicy::Uncapped);
             r == ManifestDisposition::Invalid)
         {
             JLOG(j_.warn()) << "List for " << strHex(pubKey)
@@ -1127,6 +1023,15 @@ ValidatorList::applyList(
 
     json::Value list;
     auto const& manifest = localManifest ? *localManifest : globalManifest;
+    // Reject an oversized manifest before decoding it, so we do not allocate
+    // memory for an input that cannot be a valid manifest. deserializeManifest
+    // also enforces the decoded-byte limit, but checking here avoids the
+    // base64 decode entirely.
+    if (manifest.size() > kMaxManifestBase64)
+    {
+        JLOG(j_.warn()) << "UNL manifest exceeds maximum size";
+        return PublisherListStats{ListDisposition::Invalid};
+    }
     auto m = deserializeManifest(base64Decode(manifest));
     if (!m)
     {
@@ -1283,8 +1188,7 @@ std::vector
 ValidatorList::loadLists()
 {
     using namespace std::string_literals;
-    using namespace boost::filesystem;
-    using namespace boost::system::errc;
+    using namespace std::filesystem;
 
     std::scoped_lock const lock{mutex_};
 
@@ -1292,12 +1196,12 @@ ValidatorList::loadLists()
     sites.reserve(publisherLists_.size());
     for (auto const& [pubKey, publisherCollection] : publisherLists_)
     {
-        boost::system::error_code ec;
+        std::error_code ec;
 
         if (publisherCollection.status == PublisherStatus::Available)
             continue;
 
-        boost::filesystem::path const filename = getCacheFileName(lock, pubKey);
+        std::filesystem::path const filename = getCacheFileName(lock, pubKey);
 
         auto const fullPath{canonical(filename, ec)};
         if (ec)
@@ -1308,7 +1212,7 @@ ValidatorList::loadLists()
         {
             // Treat an empty file as a missing file, because
             // nobody else is going to write it.
-            ec = make_error_code(no_such_file_or_directory);
+            ec = make_error_code(std::errc::no_such_file_or_directory);
         }
         if (ec)
             continue;
@@ -1348,7 +1252,10 @@ ValidatorList::verify(
     PublicKey masterPubKey = manifest.masterKey;
     auto const revoked = manifest.revoked();
 
-    auto const result = publisherManifests_.applyManifest(std::move(manifest));
+    // Publisher keys are configured/trusted (checked above), so bypass the
+    // untrusted cap.
+    auto const result = publisherManifests_.applyManifest(
+        std::move(manifest), ManifestRateLimitCapPolicy::Uncapped);
 
     if (revoked && result == ManifestDisposition::Accepted)
     {
diff --git a/src/xrpld/app/misc/detail/WorkBase.h b/src/xrpld/app/misc/detail/WorkBase.h
index 73e5081036..fd36cb5318 100644
--- a/src/xrpld/app/misc/detail/WorkBase.h
+++ b/src/xrpld/app/misc/detail/WorkBase.h
@@ -223,7 +223,7 @@ WorkBase::onStart()
     req_.target(path_.empty() ? "/" : path_);
     req_.version(11);
     req_.set("Host", host_ + ":" + port_);
-    req_.set("User-Agent", BuildInfo::getFullVersionString());
+    req_.set("User-Agent", build_info::getFullVersionString());
     req_.prepare_payload();
     boost::beast::http::async_write(
         impl().stream(),
diff --git a/src/xrpld/app/misc/detail/WorkSSL.cpp b/src/xrpld/app/misc/detail/WorkSSL.cpp
index e8d24b55d6..48231b147e 100644
--- a/src/xrpld/app/misc/detail/WorkSSL.cpp
+++ b/src/xrpld/app/misc/detail/WorkSSL.cpp
@@ -10,8 +10,8 @@
 #include 
 #include 
 #include 
-#include 
 
+#include 
 #include 
 #include 
 
@@ -38,7 +38,7 @@ WorkSSL::WorkSSL(
 {
     auto ec = context_.preConnectVerify(stream_, host_);
     if (ec)
-        Throw(boost::str(boost::format("preConnectVerify: %s") % ec.message()));
+        Throw(std::format("preConnectVerify: {}", ec.message()));
 }
 
 void
diff --git a/src/xrpld/app/misc/detail/WorkSSL.h b/src/xrpld/app/misc/detail/WorkSSL.h
index d4b3b9ff25..e4b7586054 100644
--- a/src/xrpld/app/misc/detail/WorkSSL.h
+++ b/src/xrpld/app/misc/detail/WorkSSL.h
@@ -7,7 +7,6 @@
 #include 
 
 #include 
-#include 
 
 #include 
 #include 
diff --git a/src/xrpld/app/rdb/PeerFinder.h b/src/xrpld/app/rdb/PeerFinder.h
index 4f186ff7e2..3ff7b7268b 100644
--- a/src/xrpld/app/rdb/PeerFinder.h
+++ b/src/xrpld/app/rdb/PeerFinder.h
@@ -1,9 +1,8 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -45,6 +44,6 @@ readPeerFinderDB(soci::session& session, std::function const& v);
+savePeerFinderDB(soci::session& session, std::vector const& v);
 
 }  // namespace xrpl
diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp
index b2f14c71ea..be4c5d29e5 100644
--- a/src/xrpld/app/rdb/backend/detail/Node.cpp
+++ b/src/xrpld/app/rdb/backend/detail/Node.cpp
@@ -40,8 +40,6 @@
 #include 
 #include 
 
-#include 
-#include 
 #include   // IWYU pragma: keep
 #include 
 
@@ -58,6 +56,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -66,6 +66,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -108,18 +109,16 @@ makeLedgerDBs(
     // ledger database
     auto lgr{std::make_unique(
         setup, kLgrDbName, setup.lgrPragma, kLgrDbInit, checkpointerSetup, j)};
-    lgr->getSession() << boost::str(
-        boost::format("PRAGMA cache_size=-%d;") %
-        kilobytes(config.getValueFor(SizedItem::LgrDbCache)));
+    lgr->getSession() << std::format(
+        "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::LgrDbCache)));
 
     if (config.useTxTables())
     {
         // transaction database
         auto tx{std::make_unique(
             setup, kTxDbName, setup.txPragma, kTxDbInit, checkpointerSetup, j)};
-        tx->getSession() << boost::str(
-            boost::format("PRAGMA cache_size=-%d;") %
-            kilobytes(config.getValueFor(SizedItem::TxnDbCache)));
+        tx->getSession() << std::format(
+            "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::TxnDbCache)));
 
         if (!setup.standAlone || setup.startUp == StartUpType::Load ||
             setup.startUp == StartUpType::LoadFile || setup.startUp == StartUpType::Replay)
@@ -279,15 +278,17 @@ saveValidatedLedger(
     }
 
     {
-        static boost::format kDeleteLedger("DELETE FROM Ledgers WHERE LedgerSeq = %u;");
-        static boost::format kDeleteTranS1("DELETE FROM Transactions WHERE LedgerSeq = %u;");
-        static boost::format kDeleteTranS2("DELETE FROM AccountTransactions WHERE LedgerSeq = %u;");
-        static boost::format kDeleteAcctTrans(
-            "DELETE FROM AccountTransactions WHERE TransID = '%s';");
+        static constexpr char const* kDeleteLedger = "DELETE FROM Ledgers WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteTranS1 =
+            "DELETE FROM Transactions WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteTranS2 =
+            "DELETE FROM AccountTransactions WHERE LedgerSeq = {};";
+        static constexpr char const* kDeleteAcctTrans =
+            "DELETE FROM AccountTransactions WHERE TransID = '{}';";
 
         {
             auto db = ldgDB.checkoutDb();
-            *db << boost::str(kDeleteLedger % seq);
+            *db << std::format(kDeleteLedger, seq);
         }
 
         if (app.config().useTxTables())
@@ -304,19 +305,19 @@ saveValidatedLedger(
 
             soci::transaction tr(*db);
 
-            *db << boost::str(kDeleteTranS1 % seq);
-            *db << boost::str(kDeleteTranS2 % seq);
+            *db << std::format(kDeleteTranS1, seq);
+            *db << std::format(kDeleteTranS2, seq);
 
             std::string const ledgerSeq(std::to_string(seq));
 
             for (auto const& acceptedLedgerTx : *aLedger)
             {
-                uint256 transactionID = acceptedLedgerTx->getTransactionID();
+                uint256 const transactionID = acceptedLedgerTx->getTransactionID();
 
                 std::string const txnId(to_string(transactionID));
                 std::string const txnSeq(std::to_string(acceptedLedgerTx->getTxnSeq()));
 
-                *db << boost::str(kDeleteAcctTrans % transactionID);
+                *db << std::format(kDeleteAcctTrans, txnId);
 
                 auto const& accts = acceptedLedgerTx->getAffected();
 
@@ -628,11 +629,11 @@ getHashesByIndex(soci::session& session, LedgerIndex minSeq, LedgerIndex maxSeq,
 std::pair>, int>
 getTxHistory(soci::session& session, Application& app, LedgerIndex startIndex, int quantity)
 {
-    std::string const sql = boost::str(
-        boost::format(
-            "SELECT LedgerSeq, Status, RawTxn "
-            "FROM Transactions ORDER BY LedgerSeq DESC LIMIT %u,%u;") %
-        startIndex % quantity);
+    std::string const sql = std::format(
+        "SELECT LedgerSeq, Status, RawTxn "
+        "FROM Transactions ORDER BY LedgerSeq DESC LIMIT {},{};",
+        startIndex,
+        quantity);
 
     std::vector> txs;
     int total = 0;
@@ -729,41 +730,50 @@ transactionsSQL(
 
     if (options.ledgerRange.max != 0u)
     {
-        maxClause = boost::str(
-            boost::format("AND AccountTransactions.LedgerSeq <= '%u'") % options.ledgerRange.max);
+        maxClause =
+            std::format("AND AccountTransactions.LedgerSeq <= '{}'", options.ledgerRange.max);
     }
 
     if (options.ledgerRange.min != 0u)
     {
-        minClause = boost::str(
-            boost::format("AND AccountTransactions.LedgerSeq >= '%u'") % options.ledgerRange.min);
+        minClause =
+            std::format("AND AccountTransactions.LedgerSeq >= '{}'", options.ledgerRange.min);
     }
 
     std::string sql;
 
     if (count)
     {
-        sql = boost::str(
-            boost::format(
-                "SELECT %s FROM AccountTransactions "
-                "WHERE Account = '%s' %s %s LIMIT %u, %u;") %
-            selection % toBase58(options.account) % maxClause % minClause % options.offset %
+        sql = std::format(
+            "SELECT {} FROM AccountTransactions "
+            "WHERE Account = '{}' {} {} LIMIT {}, {};",
+            selection,
+            toBase58(options.account),
+            maxClause,
+            minClause,
+            options.offset,
             numberOfResults);
     }
     else
     {
-        sql = boost::str(
-            boost::format(
-                "SELECT %s FROM "
-                "AccountTransactions INNER JOIN Transactions "
-                "ON Transactions.TransID = AccountTransactions.TransID "
-                "WHERE Account = '%s' %s %s "
-                "ORDER BY AccountTransactions.LedgerSeq %s, "
-                "AccountTransactions.TxnSeq %s, AccountTransactions.TransID %s "
-                "LIMIT %u, %u;") %
-            selection % toBase58(options.account) % maxClause % minClause %
-            (descending ? "DESC" : "ASC") % (descending ? "DESC" : "ASC") %
-            (descending ? "DESC" : "ASC") % options.offset % numberOfResults);
+        char const* const order = descending ? "DESC" : "ASC";
+        sql = std::format(
+            "SELECT {} FROM "
+            "AccountTransactions INNER JOIN Transactions "
+            "ON Transactions.TransID = AccountTransactions.TransID "
+            "WHERE Account = '{}' {} {} "
+            "ORDER BY AccountTransactions.LedgerSeq {}, "
+            "AccountTransactions.TxnSeq {}, AccountTransactions.TransID {} "
+            "LIMIT {}, {};",
+            selection,
+            toBase58(options.account),
+            maxClause,
+            minClause,
+            order,
+            order,
+            order,
+            options.offset,
+            numberOfResults);
     }
     JLOG(j.trace()) << "txSQL query: " << sql;
     return sql;
@@ -1104,14 +1114,6 @@ accountTxPage(
 
     std::optional newmarker;
 
-    static std::string const kPrefix(
-        R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
-          Status,RawTxn,TxnMeta
-          FROM AccountTransactions INNER JOIN Transactions
-          ON Transactions.TransID = AccountTransactions.TransID
-          AND AccountTransactions.Account = '%s' WHERE
-          )");
-
     std::string sql;
 
     // SQL's BETWEEN uses a closed interval ([a,b])
@@ -1120,13 +1122,22 @@ accountTxPage(
 
     if (findLedger == 0)
     {
-        sql = boost::str(
-            boost::format(kPrefix + R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u
-             ORDER BY AccountTransactions.LedgerSeq %s,
-             AccountTransactions.TxnSeq %s
-             LIMIT %u;)") %
-            toBase58(options.account) % options.ledgerRange.min % options.ledgerRange.max % order %
-            order % queryLimit);
+        sql = std::format(
+            R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
+          Status,RawTxn,TxnMeta
+          FROM AccountTransactions INNER JOIN Transactions
+          ON Transactions.TransID = AccountTransactions.TransID
+          AND AccountTransactions.Account = '{}' WHERE
+          AccountTransactions.LedgerSeq BETWEEN {} AND {}
+             ORDER BY AccountTransactions.LedgerSeq {},
+             AccountTransactions.TxnSeq {}
+             LIMIT {};)",
+            toBase58(options.account),
+            options.ledgerRange.min,
+            options.ledgerRange.max,
+            order,
+            order,
+            queryLimit);
     }
     else
     {
@@ -1135,27 +1146,34 @@ accountTxPage(
         std::uint32_t const maxLedger = forward ? options.ledgerRange.max : findLedger - 1;
 
         auto b58acct = toBase58(options.account);
-        sql = boost::str(
-            boost::format(
-                R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
+        sql = std::format(
+            R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,
             Status,RawTxn,TxnMeta
             FROM AccountTransactions, Transactions WHERE
             (AccountTransactions.TransID = Transactions.TransID AND
-            AccountTransactions.Account = '%s' AND
-            AccountTransactions.LedgerSeq BETWEEN %u AND %u)
+            AccountTransactions.Account = '{}' AND
+            AccountTransactions.LedgerSeq BETWEEN {} AND {})
             UNION
             SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,Status,RawTxn,TxnMeta
             FROM AccountTransactions, Transactions WHERE
             (AccountTransactions.TransID = Transactions.TransID AND
-            AccountTransactions.Account = '%s' AND
-            AccountTransactions.LedgerSeq = %u AND
-            AccountTransactions.TxnSeq %s %u)
-            ORDER BY AccountTransactions.LedgerSeq %s,
-            AccountTransactions.TxnSeq %s
-            LIMIT %u;
-            )") %
-            b58acct % minLedger % maxLedger % b58acct % findLedger % compare % findSeq % order %
-            order % queryLimit);
+            AccountTransactions.Account = '{}' AND
+            AccountTransactions.LedgerSeq = {} AND
+            AccountTransactions.TxnSeq {} {})
+            ORDER BY AccountTransactions.LedgerSeq {},
+            AccountTransactions.TxnSeq {}
+            LIMIT {};
+            )",
+            b58acct,
+            minLedger,
+            maxLedger,
+            b58acct,
+            findLedger,
+            compare,
+            findSeq,
+            order,
+            order,
+            queryLimit);
     }
 
     {
@@ -1393,8 +1411,8 @@ getTransaction(
 bool
 dbHasSpace(soci::session& session, Config const& config, beast::Journal j)
 {
-    boost::filesystem::space_info const space =
-        boost::filesystem::space(config.legacy(Sections::kDatabasePath));
+    std::filesystem::space_info const space =
+        std::filesystem::space(config.legacy(Sections::kDatabasePath));
 
     if (space.available < megabytes(512))
     {
@@ -1405,9 +1423,9 @@ dbHasSpace(soci::session& session, Config const& config, beast::Journal j)
     if (config.useTxTables())
     {
         DatabaseCon::Setup const dbSetup = setupDatabaseCon(config);
-        boost::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName;
-        boost::system::error_code ec;
-        std::optional dbSize = boost::filesystem::file_size(dbPath, ec);
+        std::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName;
+        std::error_code ec;
+        std::optional dbSize = std::filesystem::file_size(dbPath, ec);
         if (ec)
         {
             JLOG(j.error()) << "Error checking transaction db file size: " << ec.message();
diff --git a/src/xrpld/app/rdb/detail/PeerFinder.cpp b/src/xrpld/app/rdb/detail/PeerFinder.cpp
index abdcd1c61a..8d9af69ae3 100644
--- a/src/xrpld/app/rdb/detail/PeerFinder.cpp
+++ b/src/xrpld/app/rdb/detail/PeerFinder.cpp
@@ -1,12 +1,11 @@
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include   // IWYU pragma: keep
@@ -109,7 +108,7 @@ updatePeerFinderDB(soci::session& session, int currentSchemaVersion, beast::Jour
         std::size_t count = 0;
         session << "SELECT COUNT(*) FROM PeerFinder_BootstrapCache;", soci::into(count);
 
-        std::vector list;
+        std::vector list;
 
         {
             list.reserve(count);
@@ -126,8 +125,8 @@ updatePeerFinderDB(soci::session& session, int currentSchemaVersion, beast::Jour
             st.execute();
             while (st.fetch())
             {
-                PeerFinder::Store::Entry entry;
-                entry.endpoint = beast::IP::Endpoint::fromString(s);
+                peer_finder::Store::Entry entry;
+                entry.endpoint = beast::ip::Endpoint::fromString(s);
                 if (!isUnspecified(entry.endpoint))
                 {
                     entry.valence = valence;
@@ -227,7 +226,7 @@ readPeerFinderDB(soci::session& session, std::function const& v)
+savePeerFinderDB(soci::session& session, std::vector const& v)
 {
     soci::transaction tr(session);
     session << "DELETE FROM PeerFinder_BootstrapCache;";
diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h
index 0cdef76d71..bb26a6af3d 100644
--- a/src/xrpld/core/Config.h
+++ b/src/xrpld/core/Config.h
@@ -11,11 +11,10 @@
 #include 
 #include 
 
-#include   // VFALCO FIX: This include should not be here
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -97,17 +96,17 @@ public:
     /**
      * Returns the full path and filename of the debug log file.
      */
-    [[nodiscard]] boost::filesystem::path
+    [[nodiscard]] std::filesystem::path
     getDebugLogFile() const;
 
 private:
-    boost::filesystem::path configFile_;
+    std::filesystem::path configFile_;
 
 public:
-    boost::filesystem::path configDir;
+    std::filesystem::path configDir;
 
 private:
-    boost::filesystem::path debugLogfile_;
+    std::filesystem::path debugLogfile_;
 
     void
     load();
@@ -229,6 +228,12 @@ public:
     static constexpr int kMaxJobQueueTx = 1000;
     static constexpr int kMinJobQueueTx = 100;
 
+    // Optional override for the per-connection subscription cap. Unset means
+    // use the built-in default (kMaxSubscriptionsPerConnection in InfoSub.h).
+    // Kept as an override here, rather than the default itself, so the core
+    // module need not depend on the server module that owns the constant.
+    std::optional maxSubscriptionsPerConnection;
+
     // Amendment majority time
     std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime;
 
@@ -280,7 +285,7 @@ public:
     std::size_t txRelayPercentage = 25;
 
     // These override the command line client settings
-    std::optional rpcIp;
+    std::optional rpcIp;
 
     std::unordered_set> features;
 
@@ -292,6 +297,23 @@ public:
     // How long can a peer remain in the "diverged" state
     std::chrono::seconds maxDivergedTime{300};
 
+    // Optional overrides for how many manifests are kept in the cache and
+    // carried in one TMManifests message, split by whether this node lists the
+    // validator. Unset means use the built-in defaults (kMaxUntrustedCount and
+    // kMaxTrustedCount in Manifest.h). Kept as overrides here, rather than the
+    // defaults themselves, so the core module need not depend on the server
+    // module that owns the constants.
+    std::optional maxUntrustedCount;
+    std::optional maxTrustedCount;
+
+    // Bounds for both counts above. The lower bound leaves room for a small
+    // network or a deliberately tight limit; note that setting a count below
+    // what peers actually send means their manifest messages are dropped for
+    // being oversized. The upper bound keeps the implied message size well
+    // under the overall protocol message limit.
+    static constexpr std::size_t kMinManifestCount = 50;
+    static constexpr std::size_t kMaxManifestCount = 1000;
+
     // Enable the beta API version
     bool betaRpcApi = false;
 
diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp
index 4e67eb1924..e0441134fc 100644
--- a/src/xrpld/core/detail/Config.cpp
+++ b/src/xrpld/core/detail/Config.cpp
@@ -21,21 +21,19 @@
 #include 
 #include 
 #include 
-#include 
-#include 
-#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -45,6 +43,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -185,7 +184,7 @@ parseIniFile(std::string const& strInput, bool const bTrim)
     for (auto& strValue : vLines)
     {
         if (bTrim)
-            boost::algorithm::trim(strValue);
+            strValue = trimWhitespace(strValue);
 
         if (strValue.empty() || strValue[0] == '#')
         {
@@ -313,13 +312,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
     // directory, use the current working directory as the
     // config directory and that with "db" as the data
     // directory.
-    boost::filesystem::path dataDir;
+    std::filesystem::path dataDir;
 
     if (!strConf.empty())
     {
         // --conf= : everything is relative that file.
         configFile_ = strConf;
-        configDir = boost::filesystem::absolute(configFile_);
+        configDir = std::filesystem::absolute(configFile_);
         configDir.remove_filename();
         dataDir = configDir / kDatabaseDirName;
     }
@@ -330,13 +329,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
             // Check if either of the config files exist in the current working
             // directory, in which case the databases will be stored in a
             // subdirectory.
-            configDir = boost::filesystem::current_path();
+            configDir = std::filesystem::current_path();
             dataDir = configDir / kDatabaseDirName;
             configFile_ = configDir / kConfigFileName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
             configFile_ = configDir / kConfigLegacyName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
 
             // Check if the home directory is set, and optionally the XDG config
@@ -363,10 +362,10 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
                 dataDir = strXdgDataHome + "/" + systemName();
                 configDir = strXdgConfigHome + "/" + systemName();
                 configFile_ = configDir / kConfigFileName;
-                if (boost::filesystem::exists(configFile_))
+                if (std::filesystem::exists(configFile_))
                     break;
                 configFile_ = configDir / kConfigLegacyName;
-                if (boost::filesystem::exists(configFile_))
+                if (std::filesystem::exists(configFile_))
                     break;
             }
 
@@ -374,7 +373,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
             dataDir = "/var/lib/" + systemName();
             configDir = "/etc/" + systemName();
             configFile_ = configDir / kConfigFileName;
-            if (boost::filesystem::exists(configFile_))
+            if (std::filesystem::exists(configFile_))
                 break;
             configFile_ = configDir / kConfigLegacyName;
         } while (false);
@@ -387,7 +386,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
         std::string const dbPath(legacy(Sections::kDatabasePath));
         if (!dbPath.empty())
         {
-            dataDir = boost::filesystem::path(dbPath);
+            dataDir = std::filesystem::path(dbPath);
         }
         else if (runStandalone_)
         {
@@ -397,13 +396,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand
 
     if (!dataDir.empty())
     {
-        boost::system::error_code ec;
-        boost::filesystem::create_directories(dataDir, ec);
+        std::error_code ec;
+        std::filesystem::create_directories(dataDir, ec);
 
         if (ec)
-            Throw(boost::str(boost::format("Can not create %s") % dataDir));
+            Throw(std::format("Can not create {}", dataDir.string()));
 
-        legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string());
+        legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string());
     }
 
     HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_);
@@ -455,7 +454,7 @@ Config::load()
     if (!quiet_)
         std::cerr << "Loading: " << configFile_ << "\n";
 
-    boost::system::error_code ec;
+    std::error_code ec;
     auto const fileContents = getFileContents(ec, configFile_);
 
     if (ec)
@@ -508,8 +507,8 @@ Config::loadFromString(std::string const& fileContents)
         std::string dbPath;
         if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_))
         {
-            boost::filesystem::path const p(dbPath);
-            legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string());
+            std::filesystem::path const p(dbPath);
+            legacy(Sections::kDatabasePath, std::filesystem::absolute(p).string());
         }
     }
 
@@ -677,6 +676,9 @@ Config::loadFromString(std::string const& fileContents)
     if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_))
         networkQuorum = beast::lexicalCastThrow(strTemp);
 
+    if (getSingleSection(secConfig, Sections::kMaxSubscriptionsPerConnection, strTemp, j_))
+        maxSubscriptionsPerConnection = beast::lexicalCastThrow(strTemp);
+
     fees = setupFeeVote(section(Sections::kVoting));
     /* [fee_default] is documented in the example config files as useful for
      * things like offline transaction signing. Until that's completely
@@ -922,6 +924,38 @@ Config::loadFromString(std::string const& fileContents)
                 std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay +
                 ": the time must be between 60 and 900 seconds, inclusive.");
         }
+
+        // Both manifest counts parse and validate identically, so read them
+        // the same way. Returns nullopt when the key is absent, leaving the
+        // built-in default in effect at the use site.
+        auto manifestCount = [&sec](char const* key) -> std::optional {
+            std::optional count;
+
+            try
+            {
+                if (auto val = sec.get(key))
+                    count = beast::lexicalCastThrow(*val);
+            }
+            catch (...)
+            {
+                Throw(
+                    std::string("Invalid value '") + key + "' in " + Sections::kOverlay +
+                    ": must be of the form '' representing a count of manifests.");
+            }
+
+            if (count && (*count < kMinManifestCount || *count > kMaxManifestCount))
+            {
+                Throw(
+                    std::string("Invalid value '") + key + "' in " + Sections::kOverlay +
+                    ": the count must be between " + std::to_string(kMinManifestCount) + " and " +
+                    std::to_string(kMaxManifestCount) + ", inclusive.");
+            }
+
+            return count;
+        };
+
+        maxUntrustedCount = manifestCount(Keys::kMaxUntrustedCount);
+        maxTrustedCount = manifestCount(Keys::kMaxTrustedCount);
     }
 
     if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_))
@@ -978,7 +1012,7 @@ Config::loadFromString(std::string const& fileContents)
         // If no path was specified, then look for validators.txt
         // in the same directory as the config file, but don't complain
         // if we can't find it.
-        boost::filesystem::path validatorsFile;
+        std::filesystem::path validatorsFile;
 
         if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_))
         {
@@ -993,7 +1027,7 @@ Config::loadFromString(std::string const& fileContents)
             if (!validatorsFile.is_absolute() && !configDir.empty())
                 validatorsFile = configDir / validatorsFile;
 
-            if (!boost::filesystem::exists(validatorsFile))
+            if (!std::filesystem::exists(validatorsFile))
             {
                 Throw(
                     std::string("The file specified in [") + Sections::kValidatorsFile +
@@ -1002,8 +1036,8 @@ Config::loadFromString(std::string const& fileContents)
                     validatorsFile.string());
             }
             else if (
-                !boost::filesystem::is_regular_file(validatorsFile) &&
-                !boost::filesystem::is_symlink(validatorsFile))
+                !std::filesystem::is_regular_file(validatorsFile) &&
+                !std::filesystem::is_symlink(validatorsFile))
             {
                 Throw(
                     std::string("Invalid file specified in [") + Sections::kValidatorsFile +
@@ -1016,20 +1050,20 @@ Config::loadFromString(std::string const& fileContents)
 
             if (!validatorsFile.empty())
             {
-                if (!boost::filesystem::exists(validatorsFile) ||
-                    (!boost::filesystem::is_regular_file(validatorsFile) &&
-                     !boost::filesystem::is_symlink(validatorsFile)))
+                if (!std::filesystem::exists(validatorsFile) ||
+                    (!std::filesystem::is_regular_file(validatorsFile) &&
+                     !std::filesystem::is_symlink(validatorsFile)))
                 {
                     validatorsFile.clear();
                 }
             }
         }
 
-        if (!validatorsFile.empty() && boost::filesystem::exists(validatorsFile) &&
-            (boost::filesystem::is_regular_file(validatorsFile) ||
-             boost::filesystem::is_symlink(validatorsFile)))
+        if (!validatorsFile.empty() && std::filesystem::exists(validatorsFile) &&
+            (std::filesystem::is_regular_file(validatorsFile) ||
+             std::filesystem::is_symlink(validatorsFile)))
         {
-            boost::system::error_code ec;
+            std::error_code ec;
             auto const data = getFileContents(ec, validatorsFile);
             if (ec)
             {
@@ -1162,7 +1196,7 @@ Config::loadFromString(std::string const& fileContents)
     }
 }
 
-boost::filesystem::path
+std::filesystem::path
 Config::getDebugLogFile() const
 {
     auto logFile = debugLogfile_;
@@ -1171,17 +1205,17 @@ Config::getDebugLogFile() const
     {
         // Unless an absolute path for the log file is specified, the
         // path is relative to the config file directory.
-        logFile = boost::filesystem::absolute(logFile, configDir);
+        logFile = std::filesystem::absolute(configDir / logFile);
     }
 
     if (!logFile.empty())
     {
         auto logDir = logFile.parent_path();
 
-        if (!boost::filesystem::is_directory(logDir))
+        if (!std::filesystem::is_directory(logDir))
         {
-            boost::system::error_code ec;
-            boost::filesystem::create_directories(logDir, ec);
+            std::error_code ec;
+            std::filesystem::create_directories(logDir, ec);
 
             // If we fail, we warn but continue so that the calling code can
             // decide how to handle this situation.
@@ -1283,8 +1317,7 @@ setupDatabaseCon(Config const& c, std::optional j)
                 boost::iequals(journalMode, "truncate") || boost::iequals(journalMode, "persist") ||
                 boost::iequals(journalMode, "wal"))
             {
-                result->emplace_back(
-                    boost::str(boost::format(kCommonDbPragmaJournal) % journalMode));
+                result->emplace_back(commonDbPragmaJournal(journalMode));
             }
             else
             {
@@ -1305,7 +1338,7 @@ setupDatabaseCon(Config const& c, std::optional j)
             if (higherRisk || boost::iequals(synchronous, "normal") ||
                 boost::iequals(synchronous, "full") || boost::iequals(synchronous, "extra"))
             {
-                result->emplace_back(boost::str(boost::format(kCommonDbPragmaSync) % synchronous));
+                result->emplace_back(commonDbPragmaSync(synchronous));
             }
             else
             {
@@ -1326,7 +1359,7 @@ setupDatabaseCon(Config const& c, std::optional j)
             if (higherRisk || boost::iequals(tempStore, "default") ||
                 boost::iequals(tempStore, "file"))
             {
-                result->emplace_back(boost::str(boost::format(kCommonDbPragmaTemp) % tempStore));
+                result->emplace_back(commonDbPragmaTemp(tempStore));
             }
             else
             {
diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h
index 2e187a2a4d..065da696d8 100644
--- a/src/xrpld/overlay/Message.h
+++ b/src/xrpld/overlay/Message.h
@@ -4,6 +4,7 @@
 
 #include 
 #include 
+#include 
 
 #include 
 
@@ -19,6 +20,41 @@
 namespace xrpl {
 
 constexpr std::size_t kMaximumMessageSize = megabytes(64);
+// Ping messages should be much smaller than the maximum message size,
+// so we define a separate limit for them.
+constexpr std::size_t kMaximumPingMessageSize = kilobytes(1);
+
+// Allowance for protobuf framing around each manifest in a TMManifests message.
+constexpr std::size_t kManifestFramingBytes = 8;
+
+/**
+ * Upper bound on the wire size of a TMManifests message.
+ *
+ * Allows both counts' worth of entries at @ref kMaxManifestBytes each, plus
+ * framing per entry. Messages larger than this are dropped before parsing,
+ * which bounds the work an oversized message can cause.
+ *
+ * @param trustedCount Trusted manifests per message.
+ *
+ * @param untrustedCount Untrusted manifests per message.
+ *
+ * @note A node that raises either count accepts larger messages than a peer
+ *     running the defaults, and the messages it sends may be dropped by such a
+ *     peer. Lowering either count below what peers send drops their manifest
+ *     messages, including any trusted key rotations they carry, and the drop
+ *     is not recorded on either side.
+ */
+constexpr std::size_t
+maximumManifestsMessageSize(std::size_t const trustedCount, std::size_t const untrustedCount)
+{
+    return (trustedCount + untrustedCount) * (kMaxManifestBytes + kManifestFramingBytes);
+}
+
+// The message size the defaults imply must stay within the overall protocol
+// message limit. The same check for the largest configurable counts lives in
+// OverlayImpl.h, where the configured bound is visible.
+static_assert(
+    maximumManifestsMessageSize(kMaxTrustedCount, kMaxUntrustedCount) < kMaximumMessageSize);
 
 // VFALCO NOTE If we forward declare Message and write out shared_ptr
 //             instead of using the in-class type alias, we can remove the
diff --git a/src/xrpld/overlay/Overlay.h b/src/xrpld/overlay/Overlay.h
index 6cc229f5a0..9ab80e6697 100644
--- a/src/xrpld/overlay/Overlay.h
+++ b/src/xrpld/overlay/Overlay.h
@@ -55,7 +55,7 @@ public:
         explicit Setup() = default;
 
         std::shared_ptr context;
-        beast::IP::Address publicIp;
+        beast::ip::Address publicIp;
         int ipLimit = 0;
         std::uint32_t crawlOptions = 0;
         std::optional networkID;
@@ -92,7 +92,7 @@ public:
      * performed asynchronously.
      */
     virtual void
-    connect(beast::IP::Endpoint const& address) = 0;
+    connect(beast::ip::Endpoint const& address) = 0;
 
     /**
      * Returns the maximum number of peers we are configured to allow.
diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h
index 23a45dc512..6c4cf1dff1 100644
--- a/src/xrpld/overlay/Peer.h
+++ b/src/xrpld/overlay/Peer.h
@@ -15,14 +15,13 @@
 
 namespace xrpl {
 
-namespace Resource {
+namespace resource {
 class Charge;
-}  // namespace Resource
+}  // namespace resource
 
 enum class ProtocolFeature {
-    ValidatorListPropagation,
-    ValidatorList2Propagation,
     LedgerReplay,
+    LedgerNodeDepth,
 };
 
 /**
@@ -50,7 +49,7 @@ public:
     virtual void
     send(std::shared_ptr const& m) = 0;
 
-    [[nodiscard]] virtual beast::IP::Endpoint
+    [[nodiscard]] virtual beast::ip::Endpoint
     getRemoteAddress() const = 0;
 
     /**
@@ -75,7 +74,7 @@ public:
      * Adjust this peer's load balance based on the type of load imposed.
      */
     virtual void
-    charge(Resource::Charge const& fee, std::string const& context) = 0;
+    charge(resource::Charge const& fee, std::string const& context) = 0;
 
     //
     // Identity
@@ -117,7 +116,7 @@ public:
     // Ledger
     //
 
-    [[nodiscard]] virtual uint256 const&
+    [[nodiscard]] virtual uint256
     getClosedLedgerHash() const = 0;
     [[nodiscard]] virtual bool
     hasLedger(uint256 const& hash, std::uint32_t seq) const = 0;
diff --git a/src/xrpld/overlay/detail/ConnectAttempt.cpp b/src/xrpld/overlay/detail/ConnectAttempt.cpp
index 064b4ecd3e..b78b8eb7b8 100644
--- a/src/xrpld/overlay/detail/ConnectAttempt.cpp
+++ b/src/xrpld/overlay/detail/ConnectAttempt.cpp
@@ -7,8 +7,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 
 #include 
 #include 
@@ -16,6 +14,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -49,10 +49,10 @@ ConnectAttempt::ConnectAttempt(
     Application& app,
     boost::asio::io_context& ioContext,
     endpoint_type remoteEndpoint,
-    Resource::Consumer usage,
+    resource::Consumer usage,
     shared_context const& context,
     Peer::id_t id,
-    std::shared_ptr const& slot,
+    std::shared_ptr const& slot,
     beast::Journal journal,
     OverlayImpl& overlay)
     : Child(overlay)
@@ -462,7 +462,7 @@ ConnectAttempt::processResponse()
 
         auto const result =
             overlay_.peerFinder().activate(slot_, publicKey, static_cast(member));
-        if (result != PeerFinder::Result::Success)
+        if (result != peer_finder::Result::Success)
         {
             fail("Outbound " + std::string(to_string(result)));
             return;
diff --git a/src/xrpld/overlay/detail/ConnectAttempt.h b/src/xrpld/overlay/detail/ConnectAttempt.h
index d7836e3c84..3ebffc529d 100644
--- a/src/xrpld/overlay/detail/ConnectAttempt.h
+++ b/src/xrpld/overlay/detail/ConnectAttempt.h
@@ -3,12 +3,12 @@
 #include 
 #include 
 #include 
-#include 
 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -41,7 +41,7 @@ private:
     beast::WrappedSink sink_;
     beast::Journal const journal_;
     endpoint_type remoteEndpoint_;
-    Resource::Consumer usage_;
+    resource::Consumer usage_;
     boost::asio::strand strand_;
     boost::asio::basic_waitable_timer timer_;
     std::unique_ptr streamPtr_;
@@ -49,7 +49,7 @@ private:
     stream_type& stream_;
     boost::beast::multi_buffer readBuf_;
     response_type response_;
-    std::shared_ptr slot_;
+    std::shared_ptr slot_;
     request_type req_;
 
 public:
@@ -57,10 +57,10 @@ public:
         Application& app,
         boost::asio::io_context& ioContext,
         endpoint_type remoteEndpoint,
-        Resource::Consumer usage,
+        resource::Consumer usage,
         shared_context const& context,
         Peer::id_t id,
-        std::shared_ptr const& slot,
+        std::shared_ptr const& slot,
         beast::Journal journal,
         OverlayImpl& overlay);
 
@@ -102,7 +102,7 @@ private:
     static boost::asio::ip::tcp::endpoint
     parseEndpoint(std::string const& s, boost::system::error_code& ec)
     {
-        beast::IP::Endpoint bep;
+        beast::ip::Endpoint bep;
         std::istringstream is(s);
         is >> bep;
         if (is.fail())
diff --git a/src/xrpld/overlay/detail/Handshake.cpp b/src/xrpld/overlay/detail/Handshake.cpp
index a860d2d604..a12923d1c3 100644
--- a/src/xrpld/overlay/detail/Handshake.cpp
+++ b/src/xrpld/overlay/detail/Handshake.cpp
@@ -186,8 +186,8 @@ buildHandshake(
     boost::beast::http::fields& h,
     xrpl::uint256 const& sharedValue,
     std::optional networkID,
-    beast::IP::Address publicIp,
-    beast::IP::Address remoteIp,
+    beast::ip::Address publicIp,
+    beast::ip::Address remoteIp,
     Application& app)
 {
     if (networkID)
@@ -213,7 +213,7 @@ buildHandshake(
     if (!app.config().serverDomain.empty())
         h.insert("Server-Domain", app.config().serverDomain);
 
-    if (beast::IP::isPublic(remoteIp))
+    if (beast::ip::isPublic(remoteIp))
         h.insert("Remote-IP", remoteIp.to_string());
 
     if (!publicIp.is_unspecified())
@@ -231,8 +231,8 @@ verifyHandshake(
     boost::beast::http::fields const& headers,
     xrpl::uint256 const& sharedValue,
     std::optional networkID,
-    beast::IP::Address publicIp,
-    beast::IP::Address remote,
+    beast::ip::Address publicIp,
+    beast::ip::Address remote,
     Application& app)
 {
     if (auto const iter = headers.find("Server-Domain"); iter != headers.end())
@@ -331,7 +331,7 @@ verifyHandshake(
         if (ec)
             throw std::runtime_error("Invalid Local-IP");
 
-        if (beast::IP::isPublic(remote) && remote != localIp)
+        if (beast::ip::isPublic(remote) && remote != localIp)
         {
             throw std::runtime_error(
                 "Incorrect Local-IP: " + remote.to_string() + " instead of " + localIp.to_string());
@@ -346,7 +346,7 @@ verifyHandshake(
         if (ec)
             throw std::runtime_error("Invalid Remote-IP");
 
-        if (beast::IP::isPublic(remote) && !beast::IP::isUnspecified(publicIp))
+        if (beast::ip::isPublic(remote) && !beast::ip::isUnspecified(publicIp))
         {
             // We know our public IP and peer reports our connection came
             // from some other IP.
@@ -374,7 +374,7 @@ makeRequest(
     m.method(boost::beast::http::verb::get);
     m.target("/");
     m.version(11);
-    m.insert("User-Agent", BuildInfo::getFullVersionString());
+    m.insert("User-Agent", build_info::getFullVersionString());
     m.insert("Upgrade", supportedProtocolVersions());
     m.insert("Connection", "Upgrade");
     m.insert("Connect-As", "Peer");
@@ -390,8 +390,8 @@ http_response_type
 makeResponse(
     bool crawlPublic,
     http_request_type const& req,
-    beast::IP::Address publicIp,
-    beast::IP::Address remoteIp,
+    beast::ip::Address publicIp,
+    beast::ip::Address remoteIp,
     uint256 const& sharedValue,
     std::optional networkID,
     ProtocolVersion protocol,
@@ -403,7 +403,7 @@ makeResponse(
     resp.insert("Connection", "Upgrade");
     resp.insert("Upgrade", to_string(protocol));
     resp.insert("Connect-As", "Peer");
-    resp.insert("Server", BuildInfo::getFullVersionString());
+    resp.insert("Server", build_info::getFullVersionString());
     resp.insert("Crawl", crawlPublic ? "public" : "private");
     resp.insert(
         "X-Protocol-Ctl",
diff --git a/src/xrpld/overlay/detail/Handshake.h b/src/xrpld/overlay/detail/Handshake.h
index 9a4e5ba507..d54cd3a0ea 100644
--- a/src/xrpld/overlay/detail/Handshake.h
+++ b/src/xrpld/overlay/detail/Handshake.h
@@ -47,8 +47,8 @@ buildHandshake(
     boost::beast::http::fields& h,
     uint256 const& sharedValue,
     std::optional networkID,
-    beast::IP::Address publicIp,
-    beast::IP::Address remoteIp,
+    beast::ip::Address publicIp,
+    beast::ip::Address remoteIp,
     Application& app);
 
 /**
@@ -68,8 +68,8 @@ verifyHandshake(
     boost::beast::http::fields const& headers,
     uint256 const& sharedValue,
     std::optional networkID,
-    beast::IP::Address publicIp,
-    beast::IP::Address remote,
+    beast::ip::Address publicIp,
+    beast::ip::Address remote,
     Application& app);
 
 /**
@@ -109,8 +109,8 @@ http_response_type
 makeResponse(
     bool crawlPublic,
     http_request_type const& req,
-    beast::IP::Address publicIp,
-    beast::IP::Address remoteIp,
+    beast::ip::Address publicIp,
+    beast::ip::Address remoteIp,
     uint256 const& sharedValue,
     std::optional networkID,
     ProtocolVersion version,
diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp
index c6e0511515..a6af525620 100644
--- a/src/xrpld/overlay/detail/Message.cpp
+++ b/src/xrpld/overlay/detail/Message.cpp
@@ -82,7 +82,6 @@ Message::compress()
             case protocol::mtGET_LEDGER:
             case protocol::mtLEDGER_DATA:
             case protocol::mtGET_OBJECTS:
-            case protocol::mtVALIDATOR_LIST:
             case protocol::mtVALIDATOR_LIST_COLLECTION:
             case protocol::mtREPLAY_DELTA_RESPONSE:
             case protocol::mtTRANSACTIONS:
diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp
index 1b169fc617..eeb597ad75 100644
--- a/src/xrpld/overlay/detail/OverlayImpl.cpp
+++ b/src/xrpld/overlay/detail/OverlayImpl.cpp
@@ -10,8 +10,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 #include 
 #include 
 #include 
@@ -39,11 +37,15 @@
 #include 
 #include 
 #include 
+#include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -92,13 +94,13 @@
 
 namespace xrpl {
 
-namespace CrawlOptions {
+namespace crawl_options {
 static constexpr auto kDisabled = 0;
 static constexpr auto kOverlay = (1 << 0);
 static constexpr auto kServerInfo = (1 << 1);
 static constexpr auto kServerCounts = (1 << 2);
 static constexpr auto kUnl = (1 << 3);
-}  // namespace CrawlOptions
+}  // namespace crawl_options
 
 //------------------------------------------------------------------------------
 
@@ -154,7 +156,7 @@ OverlayImpl::Timer::onTimer(error_code ec)
     if (overlay_.app_.config().txReduceRelayEnable)
         overlay_.sendTxQueue();
 
-    if ((++overlay_.timerCount_ % Tuning::kCheckIdlePeers) == 0)
+    if ((++overlay_.timerCount_ % tuning::kCheckIdlePeers) == 0)
         overlay_.deleteIdlePeers();
 
     asyncWait();
@@ -166,7 +168,7 @@ OverlayImpl::OverlayImpl(
     Application& app,
     Setup setup,
     ServerHandler& serverHandler,
-    Resource::Manager& resourceManager,
+    resource::Manager& resourceManager,
     Resolver& resolver,
     boost::asio::io_context& ioContext,
     BasicConfig const& config,
@@ -179,12 +181,13 @@ OverlayImpl::OverlayImpl(
     , journal_(app_.getJournal("Overlay"))
     , serverHandler_(serverHandler)
     , resourceManager_(resourceManager)
+    , store_(app_.getJournal("PeerFinder"))
     , peerFinder_(
-          PeerFinder::makeManager(
+          peer_finder::makeManager(
               ioContext,
               stopwatch(),
               app_.getJournal("PeerFinder"),
-              config,
+              store_,
               collector))
     , resolver_(resolver)
     , nextId_(1)
@@ -201,6 +204,7 @@ OverlayImpl::OverlayImpl(
               return ret;
           }())
 {
+    store_.open(config);
     beast::PropertyStream::Source::add(peerFinder_.get());
 }
 
@@ -305,7 +309,7 @@ OverlayImpl::onHandoff(
             bool const reserved = static_cast(app_.getCluster().member(publicKey)) ||
                 app_.getPeerReservations().contains(publicKey);
             auto const result = peerFinder_->activate(slot, publicKey, reserved);
-            if (result != PeerFinder::Result::Success)
+            if (result != peer_finder::Result::Success)
             {
                 peerFinder_->onClosed(slot);
                 JLOG(journal.debug())
@@ -378,14 +382,14 @@ OverlayImpl::makePrefix(std::uint32_t id)
 
 std::shared_ptr
 OverlayImpl::makeRedirectResponse(
-    std::shared_ptr const& slot,
+    std::shared_ptr const& slot,
     http_request_type const& request,
     address_type remoteAddress)
 {
     boost::beast::http::response msg;
     msg.version(request.version());
     msg.result(boost::beast::http::status::service_unavailable);
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     {
         std::ostringstream ostr;
         ostr << remoteAddress;
@@ -405,7 +409,7 @@ OverlayImpl::makeRedirectResponse(
 
 std::shared_ptr
 OverlayImpl::makeErrorResponse(
-    std::shared_ptr const& slot,
+    std::shared_ptr const& slot,
     http_request_type const& request,
     address_type remoteAddress,
     std::string const& text)
@@ -414,7 +418,7 @@ OverlayImpl::makeErrorResponse(
     msg.version(request.version());
     msg.result(boost::beast::http::status::bad_request);
     msg.reason("Bad Request (" + text + ")");
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Remote-Address", remoteAddress.to_string());
     msg.insert(boost::beast::http::field::connection, "close");
     msg.prepare_payload();
@@ -424,7 +428,7 @@ OverlayImpl::makeErrorResponse(
 //------------------------------------------------------------------------------
 
 void
-OverlayImpl::connect(beast::IP::Endpoint const& remoteEndpoint)
+OverlayImpl::connect(beast::ip::Endpoint const& remoteEndpoint)
 {
     XRPL_ASSERT(work_, "xrpl::OverlayImpl::connect : work is set");
 
@@ -494,7 +498,7 @@ OverlayImpl::addActive(std::shared_ptr const& peer)
 }
 
 void
-OverlayImpl::remove(std::shared_ptr const& slot)
+OverlayImpl::remove(std::shared_ptr const& slot)
 {
     std::scoped_lock const lock(mutex_);
     auto const iter = peers_.find(slot);
@@ -505,7 +509,7 @@ OverlayImpl::remove(std::shared_ptr const& slot)
 void
 OverlayImpl::start()
 {
-    PeerFinder::Config const config = PeerFinder::Config::makeConfig(
+    peer_finder::Config const config = peer_finder::makeConfig(
         app_.config(),
         serverHandler_.setup().overlay.port(),
         app_.getValidationPublicKey().has_value(),
@@ -538,7 +542,7 @@ OverlayImpl::start()
 
     resolver_.resolve(
         bootstrapIps,
-        [this](std::string const& name, std::vector const& addresses) {
+        [this](std::string const& name, std::vector const& addresses) {
             std::vector ips;
             ips.reserve(addresses.size());
             for (auto const& addr : addresses)
@@ -563,8 +567,8 @@ OverlayImpl::start()
     {
         resolver_.resolve(
             app_.config().ipsFixed,
-            [this](std::string const& name, std::vector const& addresses) {
-                std::vector ips;
+            [this](std::string const& name, std::vector const& addresses) {
+                std::vector ips;
                 ips.reserve(addresses.size());
 
                 for (auto& addr : addresses)
@@ -667,25 +671,51 @@ OverlayImpl::onManifests(
     std::shared_ptr const& m,
     std::shared_ptr const& from)
 {
-    auto const n = m->list_size();
     auto const& journal = from->pJournal();
 
+    // Process every trusted manifest, but stop processing untrusted ones once
+    // the configured untrusted count has been handled, so the work stays
+    // bounded. Trusted manifests are always processed: dropping one would delay
+    // a validator key rotation reaching this node.
+    auto const maxUntrusted = untrustedManifestCount(app_.config().maxUntrustedCount);
+    auto const total = static_cast(m->list_size());
+    std::size_t untrusted = 0;
+    bool skippedUntrusted = false;
+
     protocol::TMManifests relay;
 
-    for (std::size_t i = 0; i < n; ++i)
+    for (std::size_t i = 0; i < total; ++i)
     {
         auto& s = m->list().Get(i).stobject();
 
         if (auto mo = deserializeManifest(s))
         {
             auto const serialized = mo->serialized;
+            // Resolve trust before applyManifest takes the manifest-cache
+            // lock: listed() takes the validator-list lock, so ordering it
+            // first avoids holding the two locks in opposite orders.
+            bool const isTrusted = app_.getValidators().listed(mo->masterKey);
 
-            auto const result = app_.getValidatorManifests().applyManifest(std::move(*mo));
+            // Bound untrusted work: process at most maxUntrusted untrusted
+            // manifests, but never skip a trusted one. Trusted manifests are
+            // not counted against the cap.
+            if (!isTrusted)
+            {
+                if (untrusted >= maxUntrusted)
+                {
+                    skippedUntrusted = true;
+                    continue;
+                }
+                ++untrusted;
+            }
+
+            auto const result = app_.getValidatorManifests().applyManifest(
+                std::move(*mo),
+                isTrusted ? ManifestRateLimitCapPolicy::Uncapped
+                          : ManifestRateLimitCapPolicy::Capped);
 
             if (result == ManifestDisposition::Accepted)
             {
-                relay.add_list()->set_stobject(s);
-
                 // N.B.: this is important; the applyManifest call above moves
                 //       the loaded Manifest out of the optional so we need to
                 //       reload it here.
@@ -696,13 +726,17 @@ OverlayImpl::onManifests(
                     "deserialization succeeded");
                 // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above
                 app_.getOPs().pubManifest(*mo);
+                // NOLINTEND(bugprone-unchecked-optional-access)
 
-                if (app_.getValidators().listed(mo->masterKey))
+                relay.add_list()->set_stobject(s);
+
+                // Persist to the wallet DB only for trusted keys, so untrusted
+                // gossip never survives a restart.
+                if (isTrusted)
                 {
                     auto db = app_.getWalletDB().checkoutDb();
                     addValidatorManifest(*db, serialized);
                 }
-                // NOLINTEND(bugprone-unchecked-optional-access)
             }
         }
         else
@@ -712,6 +746,17 @@ OverlayImpl::onManifests(
         }
     }
 
+    if (skippedUntrusted)
+    {
+        // The sender exceeded the untrusted per-message cap. Charge it (once,
+        // here) so a flood of untrusted manifests is penalized.
+        from->charge(resource::kFeeMalformedRequest, "too many untrusted manifests");
+
+        JLOG(journal.warn()) << "Manifests: message had " << total
+                             << " entries; processed all trusted plus the first " << maxUntrusted
+                             << " untrusted";
+    }
+
     if (!relay.list().empty())
     {
         forEach([m2 = std::make_shared(relay, protocol::mtMANIFESTS)](
@@ -869,30 +914,30 @@ OverlayImpl::json()
 bool
 OverlayImpl::processCrawl(http_request_type const& req, Handoff& handoff)
 {
-    if (req.target() != "/crawl" || setup_.crawlOptions == CrawlOptions::kDisabled)
+    if (req.target() != "/crawl" || setup_.crawlOptions == crawl_options::kDisabled)
         return false;
 
     boost::beast::http::response msg;
     msg.version(req.version());
     msg.result(boost::beast::http::status::ok);
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Content-Type", "application/json");
     msg.insert("Connection", "close");
     msg.body()["version"] = json::Value(2u);
 
-    if ((setup_.crawlOptions & CrawlOptions::kOverlay) != 0u)
+    if ((setup_.crawlOptions & crawl_options::kOverlay) != 0u)
     {
         msg.body()["overlay"] = getOverlayInfo();
     }
-    if ((setup_.crawlOptions & CrawlOptions::kServerInfo) != 0u)
+    if ((setup_.crawlOptions & crawl_options::kServerInfo) != 0u)
     {
         msg.body()["server"] = getServerInfo();
     }
-    if ((setup_.crawlOptions & CrawlOptions::kServerCounts) != 0u)
+    if ((setup_.crawlOptions & crawl_options::kServerCounts) != 0u)
     {
         msg.body()["counts"] = getServerCounts();
     }
-    if ((setup_.crawlOptions & CrawlOptions::kUnl) != 0u)
+    if ((setup_.crawlOptions & crawl_options::kUnl) != 0u)
     {
         msg.body()["unl"] = getUnlInfo();
     }
@@ -916,7 +961,7 @@ OverlayImpl::processValidatorList(http_request_type const& req, Handoff& handoff
 
     boost::beast::http::response msg;
     msg.version(req.version());
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Content-Type", "application/json");
     msg.insert("Connection", "close");
 
@@ -973,7 +1018,7 @@ OverlayImpl::processHealth(http_request_type const& req, Handoff& handoff)
         return false;
     boost::beast::http::response msg;
     msg.version(req.version());
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Content-Type", "application/json");
     msg.insert("Connection", "close");
 
@@ -1214,15 +1259,63 @@ OverlayImpl::getManifestsMessage()
 
     if (auto seq = app_.getValidatorManifests().sequence(); seq != manifestListSeq_)
     {
-        protocol::TMManifests tm;
-
+        // Phase 1: snapshot the cache under its own lock. Do not call
+        // Validators::listed() here — that takes the validator-list lock, and
+        // forEachManifest holds the manifest-cache lock, so consulting trust
+        // inside the callback would invert the lock order used elsewhere
+        // (see onManifests) and risk deadlock. Capture the manifest hash now,
+        // while we have the Manifest object, for the suppression key.
+        struct CachedManifest
+        {
+            PublicKey masterKey;
+            std::string serialized;
+            uint256 hash;
+        };
+        std::vector cached;
         app_.getValidatorManifests().forEachManifest(
-            [&tm](std::size_t s) { tm.mutable_list()->Reserve(s); },
-            [&tm, &hr = app_.getHashRouter()](Manifest const& manifest) {
-                tm.add_list()->set_stobject(manifest.serialized.data(), manifest.serialized.size());
-                hr.addSuppression(manifest.hash());
+            [&cached](std::size_t s) { cached.reserve(s); },
+            [&cached](Manifest const& manifest) {
+                cached.push_back(
+                    {.masterKey = manifest.masterKey,
+                     .serialized = manifest.serialized,
+                     .hash = manifest.hash()});
             });
 
+        // Phase 2: no cache lock held, so trust checks are safe. Include every
+        // trusted manifest, then fill any remaining headroom up to the
+        // configured untrusted count with gossip. Trusted manifests are never
+        // dropped; the trusted count only sizes the accepted message.
+        std::vector selected;
+        std::vector untrusted;
+        for (auto const& e : cached)
+        {
+            if (app_.getValidators().listed(e.masterKey))
+            {
+                selected.push_back(&e);
+            }
+            else
+            {
+                untrusted.push_back(&e);
+            }
+        }
+
+        // Cap untrusted only; trusted manifests are all included above.
+        auto const take =
+            std::min(untrustedManifestCount(app_.config().maxUntrustedCount), untrusted.size());
+        selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take);
+
+        // Shuffle the order. Cryptographic randomness is not needed here.
+        std::shuffle(selected.begin(), selected.end(), defaultPrng());
+
+        protocol::TMManifests tm;
+        auto& hr = app_.getHashRouter();
+        tm.mutable_list()->Reserve(static_cast(selected.size()));
+        for (auto const* e : selected)
+        {
+            tm.add_list()->set_stobject(e->serialized.data(), e->serialized.size());
+            hr.addSuppression(e->hash);
+        }
+
         manifestMessage_.reset();
 
         if (tm.list_size() != 0)
@@ -1608,7 +1701,7 @@ setupOverlay(BasicConfig const& config, beast::Journal j)
         {
             boost::system::error_code ec;
             setup.publicIp = boost::asio::ip::make_address(ip, ec);
-            if (ec || !beast::IP::isPublic(setup.publicIp))
+            if (ec || !beast::ip::isPublic(setup.publicIp))
                 Throw("Configured public IP is invalid");
         }
 
@@ -1650,19 +1743,19 @@ setupOverlay(BasicConfig const& config, beast::Journal j)
         {
             if (get(section, Keys::kOverlay, true))
             {
-                setup.crawlOptions |= CrawlOptions::kOverlay;
+                setup.crawlOptions |= crawl_options::kOverlay;
             }
             if (get(section, Keys::kServer, true))
             {
-                setup.crawlOptions |= CrawlOptions::kServerInfo;
+                setup.crawlOptions |= crawl_options::kServerInfo;
             }
             if (get(section, Keys::kCounts, false))
             {
-                setup.crawlOptions |= CrawlOptions::kServerCounts;
+                setup.crawlOptions |= crawl_options::kServerCounts;
             }
             if (get(section, Keys::kUnl, true))
             {
-                setup.crawlOptions |= CrawlOptions::kUnl;
+                setup.crawlOptions |= crawl_options::kUnl;
             }
         }
     }
@@ -1705,7 +1798,7 @@ makeOverlay(
     Application& app,
     Overlay::Setup const& setup,
     ServerHandler& serverHandler,
-    Resource::Manager& resourceManager,
+    resource::Manager& resourceManager,
     Resolver& resolver,
     boost::asio::io_context& ioContext,
     BasicConfig const& config,
diff --git a/src/xrpld/overlay/detail/OverlayImpl.h b/src/xrpld/overlay/detail/OverlayImpl.h
index 5de7603d9f..1c74052dfa 100644
--- a/src/xrpld/overlay/detail/OverlayImpl.h
+++ b/src/xrpld/overlay/detail/OverlayImpl.h
@@ -1,6 +1,7 @@
 #pragma once
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -8,8 +9,7 @@
 #include 
 #include 
 #include 
-#include 
-#include 
+#include 
 #include 
 
 #include 
@@ -23,6 +23,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -53,6 +55,13 @@
 
 namespace xrpl {
 
+// The largest counts an operator can configure must still imply a message size
+// within the overall protocol message limit. The same check for the defaults
+// lives in Message.h.
+static_assert(
+    maximumManifestsMessageSize(Config::kMaxManifestCount, Config::kMaxManifestCount) <
+    kMaximumMessageSize);
+
 class PeerImp;
 class BasicConfig;
 
@@ -107,10 +116,11 @@ private:
     Setup setup_;
     beast::Journal const journal_;
     ServerHandler& serverHandler_;
-    Resource::Manager& resourceManager_;
-    std::unique_ptr peerFinder_;
+    resource::Manager& resourceManager_;
+    peer_finder::StoreSqdb store_;
+    std::unique_ptr peerFinder_;
     TrafficCount traffic_;
-    hash_map, std::weak_ptr> peers_;
+    hash_map, std::weak_ptr> peers_;
     hash_map> ids_;
     Resolver& resolver_;
     std::atomic nextId_;
@@ -138,7 +148,7 @@ public:
         Application& app,
         Setup setup,
         ServerHandler& serverHandler,
-        Resource::Manager& resourceManager,
+        resource::Manager& resourceManager,
         Resolver& resolver,
         boost::asio::io_context& ioContext,
         BasicConfig const& config,
@@ -154,13 +164,13 @@ public:
     void
     stop() override;
 
-    PeerFinder::Manager&
+    peer_finder::Manager&
     peerFinder()
     {
         return *peerFinder_;
     }
 
-    Resource::Manager&
+    resource::Manager&
     resourceManager()
     {
         return resourceManager_;
@@ -179,7 +189,7 @@ public:
         endpoint_type remoteEndpoint) override;
 
     void
-    connect(beast::IP::Endpoint const& remoteEndpoint) override;
+    connect(beast::ip::Endpoint const& remoteEndpoint) override;
 
     int
     limit() override;
@@ -249,7 +259,7 @@ public:
     addActive(std::shared_ptr const& peer);
 
     void
-    remove(std::shared_ptr const& slot);
+    remove(std::shared_ptr const& slot);
 
     /**
      * Called when a peer has connected successfully
@@ -503,13 +513,13 @@ private:
 
     std::shared_ptr
     makeRedirectResponse(
-        std::shared_ptr const& slot,
+        std::shared_ptr const& slot,
         http_request_type const& request,
         address_type remoteAddress);
 
     static std::shared_ptr
     makeErrorResponse(
-        std::shared_ptr const& slot,
+        std::shared_ptr const& slot,
         http_request_type const& request,
         address_type remoteAddress,
         std::string const& msg);
diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp
index e9edbf1caf..42e6d0bcde 100644
--- a/src/xrpld/overlay/detail/PeerImp.cpp
+++ b/src/xrpld/overlay/detail/PeerImp.cpp
@@ -5,10 +5,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -19,8 +19,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 
 #include 
 #include 
@@ -37,12 +35,15 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -60,7 +61,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 
@@ -123,11 +126,11 @@ constexpr std::chrono::seconds kPeerTimerInterval{60};
 PeerImp::PeerImp(
     Application& app,
     id_t id,
-    std::shared_ptr const& slot,
+    std::shared_ptr const& slot,
     http_request_type&& request,
     PublicKey const& publicKey,
     ProtocolVersion protocol,
-    Resource::Consumer consumer,
+    resource::Consumer consumer,
     std::unique_ptr&& streamPtr,
     OverlayImpl& overlay)
     : Child(overlay)
@@ -155,7 +158,7 @@ PeerImp::PeerImp(
     , creationTime_(clock_type::now())
     , squelchStore_(app_.getJournal("SquelchStore"), stopwatch())
     , usage_(consumer)
-    , fee_{.fee = Resource::kFeeTrivialPeer, .context = ""}
+    , fee_{.fee = resource::kFeeTrivialPeer, .context = ""}
     , slot_(slot)
     , request_(std::move(request))
     , headers_(request_)
@@ -301,7 +304,7 @@ PeerImp::send(std::shared_ptr const& m)
 
         auto sendqSize = self->sendQueue_.size();
 
-        if (sendqSize < Tuning::kTargetSendQueue)
+        if (sendqSize < tuning::kTargetSendQueue)
         {
             // To detect a peer that does not read from their
             // side of the connection, we expect a peer to have
@@ -310,7 +313,7 @@ PeerImp::send(std::shared_ptr const& m)
         }
         else if (
             auto sink = self->journal_.debug();
-            sink && (sendqSize % Tuning::kSendQueueLogFreq) == 0)
+            sink && (sendqSize % tuning::kSendQueueLogFreq) == 0)
         {
             std::string const n = self->name();
             sink << n << " sendq: " << sendqSize;
@@ -372,10 +375,10 @@ PeerImp::removeTxQueue(uint256 const& hash)
 }
 
 void
-PeerImp::charge(Resource::Charge const& fee, std::string const& context)
+PeerImp::charge(resource::Charge const& fee, std::string const& context)
 {
     dispatch(strand_, [self = shared_from_this(), fee, context]() {
-        if ((self->usage_.charge(fee, context) == Resource::Disposition::Drop) &&
+        if ((self->usage_.charge(fee, context) == resource::Disposition::Drop) &&
             self->usage_.disconnect(self->pJournal_))
         {
             // Idempotent: only the first worker to observe Drop counts the
@@ -539,10 +542,8 @@ PeerImp::supportsFeature(ProtocolFeature f) const
 {
     switch (f)
     {
-        case ProtocolFeature::ValidatorListPropagation:
-            return protocol_ >= makeProtocol(2, 1);
-        case ProtocolFeature::ValidatorList2Propagation:
-            return protocol_ >= makeProtocol(2, 2);
+        case ProtocolFeature::LedgerNodeDepth:
+            return protocol_ >= makeProtocol(2, 3);
         case ProtocolFeature::LedgerReplay:
             return ledgerReplayEnabled_;
     }
@@ -720,7 +721,7 @@ PeerImp::onTimer(error_code const& ec)
         return;
     }
 
-    if (largeSendq_++ >= Tuning::kSendqIntervals)
+    if (largeSendq_++ >= tuning::kSendqIntervals)
     {
         fail("Large send queue");
         return;
@@ -886,7 +887,7 @@ PeerImp::doProtocolStart()
     onReadMessage(error_code(), 0);
 
     // Send all the validator lists that have been loaded
-    if (inbound_ && supportsFeature(ProtocolFeature::ValidatorListPropagation))
+    if (inbound_)
     {
         app_.getValidators().forEachAvailable(
             [&](std::string const& manifest,
@@ -950,7 +951,7 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
 
     readBuffer_.commit(bytesTransferred);
 
-    auto hint = Tuning::kReadBufferBytes;
+    auto hint = tuning::kReadBufferBytes;
 
     while (readBuffer_.size() > 0)
     {
@@ -982,7 +983,7 @@ PeerImp::onReadMessage(error_code ec, std::size_t bytesTransferred)
 
     // Timeout on writes only
     stream_.async_read_some(
-        readBuffer_.prepare(std::max(Tuning::kReadBufferBytes, hint)),
+        readBuffer_.prepare(std::max(tuning::kReadBufferBytes, hint)),
         bind_executor(
             strand_,
             [self = shared_from_this()](error_code const& ec, std::size_t bytesTransferred) {
@@ -1058,7 +1059,7 @@ PeerImp::onMessageBegin(
 {
     auto const name = protocolMessageName(type);
     loadEvent_ = app_.getJobQueue().makeLoadEvent(JtPeer, name);
-    fee_ = {.fee = Resource::kFeeTrivialPeer, .context = name};
+    fee_ = {.fee = resource::kFeeTrivialPeer, .context = name};
 
     auto const category =
         TrafficCount::categorize(*m, static_cast(type), true);
@@ -1102,13 +1103,16 @@ PeerImp::onMessage(std::shared_ptr const& m)
 
     if (s == 0)
     {
-        fee_.update(Resource::kFeeUselessData, "empty");
+        fee_.update(resource::kFeeUselessData, "empty");
         return;
     }
 
     if (s > 100)
-        fee_.update(Resource::kFeeModerateBurdenPeer, "oversize");
+        fee_.update(resource::kFeeModerateBurdenPeer, "oversize");
 
+    // OverlayImpl::onManifests bounds the untrusted work and charges the fee
+    // if the untrusted count exceeds the per-message cap; trusted manifests
+    // are always processed and not counted against it.
     app_.getJobQueue().addJob(JtManifest, "RcvManifests", [this, that = shared_from_this(), m]() {
         overlay_.onManifests(m, that);
     });
@@ -1119,10 +1123,13 @@ PeerImp::onMessage(std::shared_ptr const& m)
 {
     if (m->type() == protocol::TMPing::ptPING)
     {
-        // We have received a ping request, reply with a pong
-        fee_.update(Resource::kFeeModerateBurdenPeer, "ping request");
-        m->set_type(protocol::TMPing::ptPONG);
-        send(std::make_shared(*m, protocol::mtPING));
+        // We have received a ping request, reply with a pong.
+        fee_.update(resource::kFeeModerateBurdenPeer, "ping request");
+        protocol::TMPing pong;
+        pong.set_type(protocol::TMPing::ptPONG);
+        if (m->has_seq())
+            pong.set_seq(m->seq());
+        send(std::make_shared(pong, protocol::mtPING));
         return;
     }
 
@@ -1161,7 +1168,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     // VFALCO NOTE I think we should drop the peer immediately
     if (!cluster())
     {
-        fee_.update(Resource::kFeeUselessData, "unknown cluster");
+        fee_.update(resource::kFeeUselessData, "unknown cluster");
         return;
     }
 
@@ -1188,15 +1195,15 @@ PeerImp::onMessage(std::shared_ptr const& m)
     int const loadSources = m->loadsources().size();
     if (loadSources != 0)
     {
-        Resource::Gossip gossip;
+        resource::Gossip gossip;
         gossip.items.reserve(loadSources);
         for (int i = 0; i < m->loadsources().size(); ++i)
         {
             protocol::TMLoadSource const& node = m->loadsources(i);
-            Resource::Gossip::Item item;
-            item.address = beast::IP::Endpoint::fromString(node.name());
+            resource::Gossip::Item item;
+            item.address = beast::ip::Endpoint::fromString(node.name());
             item.balance = node.cost();
-            if (item.address != beast::IP::Endpoint())
+            if (item.address != beast::ip::Endpoint())
                 gossip.items.push_back(item);
         }
         overlay_.resourceManager().importConsumers(name(), gossip);
@@ -1236,17 +1243,17 @@ PeerImp::onMessage(std::shared_ptr const& m)
     // implication for the protocol.
     if (m->endpoints_v2().size() >= 1024)
     {
-        fee_.update(Resource::kFeeUselessData, "endpoints too large");
+        fee_.update(resource::kFeeUselessData, "endpoints too large");
         return;
     }
 
-    std::vector endpoints;
+    std::vector endpoints;
     endpoints.reserve(m->endpoints_v2().size());
 
     auto malformed = 0;
     for (auto const& tm : m->endpoints_v2())
     {
-        auto result = beast::IP::Endpoint::fromStringChecked(tm.endpoint());
+        auto result = beast::ip::Endpoint::fromStringChecked(tm.endpoint());
 
         if (!result)
         {
@@ -1258,7 +1265,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
 
         // If hops == 0, this Endpoint describes the peer we are connected
         // to -- in that case, we take the remote address seen on the
-        // socket and store that in the IP::Endpoint. If this is the first
+        // socket and store that in the ip::Endpoint. If this is the first
         // time, then we'll verify that their listener can receive incoming
         // by performing a connectivity test.  if hops > 0, then we just
         // take the address/port we were given
@@ -1273,7 +1280,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     if (malformed > 0)
     {
         fee_.update(
-            Resource::kFeeInvalidData * malformed,
+            resource::kFeeInvalidData * malformed,
             std::to_string(malformed) + " malformed endpoints");
     }
 
@@ -1334,7 +1341,7 @@ PeerImp::handleTransaction(
         {
             JLOG(pJournal_.warn()) << "Ignoring Network relayed Tx containing "
                                       "tfInnerBatchTxn (handleTransaction).";
-            fee_.update(Resource::kFeeModerateBurdenPeer, "inner batch txn");
+            fee_.update(resource::kFeeModerateBurdenPeer, "inner batch txn");
             return;
         }
         // LCOV_EXCL_STOP
@@ -1347,7 +1354,7 @@ PeerImp::handleTransaction(
             // we have seen this transaction recently
             if (any(flags & HashRouterFlags::BAD))
             {
-                fee_.update(Resource::kFeeUselessData, "known bad");
+                fee_.update(resource::kFeeUselessData, "known bad");
                 JLOG(pJournal_.debug()) << "Ignoring known bad tx " << txID;
             }
 
@@ -1421,7 +1428,7 @@ void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
     auto badData = [&](std::string const& msg) {
-        fee_.update(Resource::kFeeInvalidData, "get_ledger " + msg);
+        fee_.update(resource::kFeeInvalidData, "get_ledger " + msg);
         JLOG(pJournal_.warn()) << "TMGetLedger: " << msg;
     };
     auto const itype{m->itype()};
@@ -1483,23 +1490,12 @@ PeerImp::onMessage(std::shared_ptr const& m)
         }
     }
 
-    // Verify ledger node IDs
-    if (itype != protocol::liBASE)
+    // Verify ledger node counts. Full parsing of the node IDs is deferred to the job, so the I/O
+    // thread is not burdened with SHAMapNodeID deserialization for every TMGetLedger message.
+    if (itype != protocol::liBASE && m->nodeids_size() <= 0)
     {
-        if (m->nodeids_size() <= 0)
-        {
-            badData("Invalid ledger node IDs");
-            return;
-        }
-
-        for (auto const& nodeId : m->nodeids())
-        {
-            if (deserializeSHAMapNodeID(nodeId) == std::nullopt)
-            {
-                badData("Invalid SHAMap node ID");
-                return;
-            }
-        }
+        badData("Invalid ledger node IDs");
+        return;
     }
 
     // Verify query type
@@ -1512,18 +1508,64 @@ PeerImp::onMessage(std::shared_ptr const& m)
     // Verify query depth
     if (m->has_querydepth())
     {
-        if (m->querydepth() > Tuning::kMaxQueryDepth || itype == protocol::liBASE)
+        if (m->querydepth() > tuning::kMaxQueryDepth || itype == protocol::liBASE)
         {
             badData("Invalid query depth");
             return;
         }
     }
 
-    // Queue a job to process the request
+    // Queue a job to process the request.
     std::weak_ptr const weak = shared_from_this();
-    app_.getJobQueue().addJob(JtLedgerReq, "RcvGetLedger", [weak, m]() {
-        if (auto peer = weak.lock())
-            peer->processLedgerRequest(m);
+    app_.getJobQueue().addJob(JtLedgerReq, "RcvGetLedger", [weak, m, itype]() {
+        auto peer = weak.lock();
+        if (!peer)
+            return;
+
+        std::vector nodeIDs;
+        bool tooManyNodeIds = false;
+        if (itype != protocol::liBASE)
+        {
+            nodeIDs.reserve(std::min(m->nodeids_size(), tuning::kSoftMaxReplyNodes));
+            for (auto const& nodeId : m->nodeids())
+            {
+                if (nodeIDs.size() >= tuning::kSoftMaxReplyNodes)
+                {
+                    // The peer requested too many node IDs. Continue processing the received node
+                    // IDs up to the limit. If the request is legitimate then at least they will get
+                    // a response and won't have to resend these nodes in their next request.
+                    tooManyNodeIds = true;
+                    break;
+                }
+                auto parsed = deserializeSHAMapNodeID(nodeId);
+                if (!parsed)
+                {
+                    peer->charge(resource::kFeeInvalidData, "TMGetLedger: Invalid node ID");
+                    return;
+                }
+                nodeIDs.push_back(std::move(*parsed));
+            }
+        }
+
+        // These are two distinct infractions and are charged independently: requesting too many
+        // node IDs is charged even for a relay response, while the base "get ledger request" charge
+        // below is skipped for relay responses.
+        if (tooManyNodeIds)
+        {
+            peer->charge(resource::kFeeModerateBurdenPeer, "TMGetLedger: too many node IDs");
+
+            // Truncate the request to what was actually parsed and charged for, so that if this
+            // request ends up being relayed to another peer, we don't forward the oversized list.
+            m->mutable_nodeids()->DeleteSubrange(
+                static_cast(nodeIDs.size()),
+                m->nodeids_size() - static_cast(nodeIDs.size()));
+        }
+        if (!m->has_requestcookie())
+        {
+            peer->charge(resource::kFeeModerateBurdenPeer, "TMGetLedger: get ledger request");
+        }
+
+        peer->processLedgerRequest(m, std::move(nodeIDs));
     });
 }
 
@@ -1533,11 +1575,11 @@ PeerImp::onMessage(std::shared_ptr const& m)
     JLOG(pJournal_.trace()) << "onMessage, TMProofPathRequest";
     if (!ledgerReplayEnabled_)
     {
-        fee_.update(Resource::kFeeMalformedRequest, "proof_path_request disabled");
+        fee_.update(resource::kFeeMalformedRequest, "proof_path_request disabled");
         return;
     }
 
-    fee_.update(Resource::kFeeModerateBurdenPeer, "received a proof path request");
+    fee_.update(resource::kFeeModerateBurdenPeer, "received a proof path request");
     std::weak_ptr const weak = shared_from_this();
     app_.getJobQueue().addJob(JtReplayReq, "RcvProofPReq", [weak, m]() {
         if (auto peer = weak.lock())
@@ -1547,11 +1589,11 @@ PeerImp::onMessage(std::shared_ptr const& m)
             {
                 if (reply.error() == protocol::TMReplyError::reBAD_REQUEST)
                 {
-                    peer->charge(Resource::kFeeMalformedRequest, "proof_path_request");
+                    peer->charge(resource::kFeeMalformedRequest, "proof_path_request");
                 }
                 else
                 {
-                    peer->charge(Resource::kFeeRequestNoReply, "proof_path_request");
+                    peer->charge(resource::kFeeRequestNoReply, "proof_path_request");
                 }
             }
             else
@@ -1567,13 +1609,20 @@ PeerImp::onMessage(std::shared_ptr const& m)
 {
     if (!ledgerReplayEnabled_)
     {
-        fee_.update(Resource::kFeeMalformedRequest, "proof_path_response disabled");
+        fee_.update(resource::kFeeMalformedRequest, "proof_path_response disabled");
         return;
     }
 
-    if (!ledgerReplayMsgHandler_.processProofPathResponse(m))
+    switch (ledgerReplayMsgHandler_.processProofPathResponse(m))
     {
-        fee_.update(Resource::kFeeInvalidData, "proof_path_response");
+        case ReplayMsgStatus::Ok:
+            break;
+        case ReplayMsgStatus::BadData:
+            fee_.update(resource::kFeeInvalidData, "proof_path_response");
+            break;
+        case ReplayMsgStatus::Malformed:
+            fee_.update(resource::kFeeMalformedData, "proof_path_response malformed");
+            break;
     }
 }
 
@@ -1583,11 +1632,11 @@ PeerImp::onMessage(std::shared_ptr const& m)
     JLOG(pJournal_.trace()) << "onMessage, TMReplayDeltaRequest";
     if (!ledgerReplayEnabled_)
     {
-        fee_.update(Resource::kFeeMalformedRequest, "replay_delta_request disabled");
+        fee_.update(resource::kFeeMalformedRequest, "replay_delta_request disabled");
         return;
     }
 
-    fee_.fee = Resource::kFeeModerateBurdenPeer;
+    fee_.fee = resource::kFeeModerateBurdenPeer;
     std::weak_ptr const weak = shared_from_this();
     app_.getJobQueue().addJob(JtReplayReq, "RcvReplDReq", [weak, m]() {
         if (auto peer = weak.lock())
@@ -1597,11 +1646,11 @@ PeerImp::onMessage(std::shared_ptr const& m)
             {
                 if (reply.error() == protocol::TMReplyError::reBAD_REQUEST)
                 {
-                    peer->charge(Resource::kFeeMalformedRequest, "replay_delta_request");
+                    peer->charge(resource::kFeeMalformedRequest, "replay_delta_request");
                 }
                 else
                 {
-                    peer->charge(Resource::kFeeRequestNoReply, "replay_delta_request");
+                    peer->charge(resource::kFeeRequestNoReply, "replay_delta_request");
                 }
             }
             else
@@ -1617,13 +1666,20 @@ PeerImp::onMessage(std::shared_ptr const& m)
 {
     if (!ledgerReplayEnabled_)
     {
-        fee_.update(Resource::kFeeMalformedRequest, "replay_delta_response disabled");
+        fee_.update(resource::kFeeMalformedRequest, "replay_delta_response disabled");
         return;
     }
 
-    if (!ledgerReplayMsgHandler_.processReplayDeltaResponse(m))
+    switch (ledgerReplayMsgHandler_.processReplayDeltaResponse(m))
     {
-        fee_.update(Resource::kFeeInvalidData, "replay_delta_response");
+        case ReplayMsgStatus::Ok:
+            break;
+        case ReplayMsgStatus::BadData:
+            fee_.update(resource::kFeeInvalidData, "replay_delta_response");
+            break;
+        case ReplayMsgStatus::Malformed:
+            fee_.update(resource::kFeeMalformedData, "replay_delta_response malformed");
+            break;
     }
 }
 
@@ -1631,7 +1687,7 @@ void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
     auto badData = [&](std::string const& msg) {
-        fee_.update(Resource::kFeeInvalidData, msg);
+        fee_.update(resource::kFeeInvalidData, msg);
         JLOG(pJournal_.warn()) << "TMLedgerData: " << msg;
     };
 
@@ -1682,18 +1738,125 @@ PeerImp::onMessage(std::shared_ptr const& m)
     }
 
     // Verify ledger nodes.
-    if (m->nodes_size() <= 0 || m->nodes_size() > Tuning::kHardMaxReplyNodes)
+    if (m->nodes_size() <= 0 || m->nodes_size() > tuning::kHardMaxReplyNodes)
     {
         badData("Invalid Ledger/TXset nodes " + std::to_string(m->nodes_size()));
         return;
     }
 
-    // If there is a request cookie, attempt to relay the message
+    // If there is a request cookie, attempt to relay the message.
     if (m->has_requestcookie())
     {
         if (auto peer = overlay_.findPeerByShortID(m->requestcookie()))
         {
             m->clear_requestcookie();
+
+            // If the original requester doesn't support the new depth-based format, rewrite any
+            // nodes that use it back to the legacy nodeid format before relaying. Once all nodes
+            // have upgraded, the old protocol version and this code can be removed. Make sure that
+            // the format of the nodes is consistent - either all use the legacy format or the new
+            // format, unless it is liBASE data in which case none of these fields should be set.
+            auto const peerSupportsNodeDepth =
+                peer->supportsFeature(ProtocolFeature::LedgerNodeDepth);
+            enum class MessageType { Unknown, Base, Legacy, Depth };
+            MessageType messageType = MessageType::Unknown;
+            for (int i = 0; i < m->nodes_size(); ++i)
+            {
+                auto* ledgerNode = m->mutable_nodes(i);
+
+                // All nodes should have non-empty data. The field is required so we don't need to
+                // check for presence first.
+                if (ledgerNode->nodedata().empty())
+                {
+                    badData(
+                        "Received node with empty data while relaying ledger data for " +
+                        to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                        std::to_string(peer->id()));
+                    return;
+                }
+
+                MessageType msgType = MessageType::Unknown;
+                if (m->type() == protocol::liBASE)
+                {
+                    if (ledgerNode->has_nodeid() || ledgerNode->has_id() || ledgerNode->has_depth())
+                    {
+                        badData(
+                            "Received liBASE message with node reference while relaying ledger "
+                            "data for " +
+                            to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                            std::to_string(peer->id()));
+                        return;
+                    }
+                    msgType = MessageType::Base;
+                }
+                else
+                {
+                    msgType = ledgerNode->has_nodeid() ? MessageType::Legacy : MessageType::Depth;
+                }
+                if (messageType != MessageType::Unknown && messageType != msgType)
+                {
+                    badData(
+                        "Received mixed mode message while relaying ledger data for " +
+                        to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                        std::to_string(peer->id()));
+                    return;
+                }
+                messageType = msgType;
+
+                if (peerSupportsNodeDepth || msgType != MessageType::Depth)
+                    continue;
+
+                SOMETIMES(
+                    !peerSupportsNodeDepth,
+                    "xrpl::PeerImp : relaying depth-format ledger data to pre-2.3 peer");
+                switch (ledgerNode->reference_case())
+                {
+                    case protocol::TMLedgerNode::kId: {
+                        // We can directly copy the `id` field, because it uses the same wire format
+                        // as the legacy `nodeid` field.
+                        REACHABLE("xrpl::PeerImp : relay downgrade id to nodeid");
+                        ledgerNode->set_nodeid(ledgerNode->id());
+                        ledgerNode->clear_id();
+                        break;
+                    }
+                    case protocol::TMLedgerNode::kDepth: {
+                        // We need to regenerate the node ID from the node data and depth.
+                        auto treeNode = getTreeNode(ledgerNode->nodedata());
+                        if (!treeNode)
+                        {
+                            badData(
+                                "Unable to get tree node while relaying ledger data for " +
+                                to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                                std::to_string(peer->id()));
+                            return;
+                        }
+
+                        auto const nodeID = getSHAMapNodeID(*ledgerNode, *treeNode);
+                        if (!nodeID)
+                        {
+                            badData(
+                                "Unable to get node ID while relaying ledger data for " +
+                                to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                                std::to_string(peer->id()));
+                            return;
+                        }
+
+                        REACHABLE("xrpl::PeerImp : relay downgrade depth to nodeid");
+                        ledgerNode->set_nodeid(nodeID->getRawString());
+                        ledgerNode->clear_depth();
+                        break;
+                    }
+                    default: {
+                        SOMETIMES(true, "xrpl::PeerImp : relay node has empty reference");
+                        badData(
+                            "Empty node reference while relaying ledger data for " +
+                            to_string(uint256::fromRaw(m->ledgerhash())) + " to peer " +
+                            std::to_string(peer->id()));
+                        return;
+                    }
+                }
+            }
+
             peer->send(std::make_shared(*m, protocol::mtLEDGER_DATA));
         }
         else
@@ -1735,14 +1898,14 @@ PeerImp::onMessage(std::shared_ptr const& m)
         (publicKeyType(makeSlice(set.nodepubkey())) != KeyType::Secp256k1))
     {
         JLOG(pJournal_.warn()) << "Proposal: malformed";
-        fee_.update(Resource::kFeeInvalidSignature, " signature can't be longer than 72 bytes");
+        fee_.update(resource::kFeeInvalidSignature, " signature can't be longer than 72 bytes");
         return;
     }
 
     if (!stringIsUInt256Sized(set.currenttxhash()) || !stringIsUInt256Sized(set.previousledger()))
     {
         JLOG(pJournal_.warn()) << "Proposal: malformed";
-        fee_.update(Resource::kFeeMalformedRequest, "bad hashes");
+        fee_.update(resource::kFeeMalformedRequest, "bad hashes");
         return;
     }
 
@@ -2022,13 +2185,13 @@ PeerImp::checkTracking(std::uint32_t seq1, std::uint32_t seq2)
 {
     std::uint32_t const diff = std::max(seq1, seq2) - std::min(seq1, seq2);
 
-    if (diff < Tuning::kConvergedLedgerLimit)
+    if (diff < tuning::kConvergedLedgerLimit)
     {
         // The peer's ledger sequence is close to the validation's
         tracking_ = Tracking::Converged;
     }
 
-    if ((diff > Tuning::kDivergedLedgerLimit) && (tracking_.load() != Tracking::Diverged))
+    if ((diff > tuning::kDivergedLedgerLimit) && (tracking_.load() != Tracking::Diverged))
     {
         // The peer's ledger sequence is way off the validation's
         std::scoped_lock const sl(recentLock_);
@@ -2043,7 +2206,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
 {
     if (!stringIsUInt256Sized(m->hash()))
     {
-        fee_.update(Resource::kFeeMalformedRequest, "bad hash");
+        fee_.update(resource::kFeeMalformedRequest, "bad hash");
         return;
     }
 
@@ -2055,7 +2218,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
 
         if (std::ranges::find(recentTxSets_, hash) != recentTxSets_.end())
         {
-            fee_.update(Resource::kFeeUselessData, "duplicate (tsHAVE)");
+            fee_.update(resource::kFeeUselessData, "duplicate (tsHAVE)");
             return;
         }
 
@@ -2076,7 +2239,7 @@ PeerImp::onValidatorListMessage(
     {
         JLOG(pJournal_.warn()) << "Ignored malformed " << messageType;
         // This shouldn't ever happen with a well-behaved peer
-        fee_.update(Resource::kFeeHeavyBurdenPeer, "no blobs");
+        fee_.update(resource::kFeeHeavyBurdenPeer, "no blobs");
         return;
     }
 
@@ -2090,7 +2253,7 @@ PeerImp::onValidatorListMessage(
         // Charging this fee here won't hurt the peer in the normal
         // course of operation (ie. refresh every 5 minutes), but
         // will add up if the peer is misbehaving.
-        fee_.update(Resource::kFeeUselessData, "duplicate");
+        fee_.update(resource::kFeeUselessData, "duplicate");
         return;
     }
 
@@ -2181,27 +2344,27 @@ PeerImp::onValidatorListMessage(
             // Charging this fee here won't hurt the peer in the normal
             // course of operation (ie. refresh every 5 minutes), but
             // will add up if the peer is misbehaving.
-            fee_.update(Resource::kFeeUselessData, " duplicate (same_sequence or known_sequence)");
+            fee_.update(resource::kFeeUselessData, " duplicate (same_sequence or known_sequence)");
             break;
         case ListDisposition::Stale:
             // There are very few good reasons for a peer to send an
             // old list, particularly more than once.
-            fee_.update(Resource::kFeeInvalidData, "expired");
+            fee_.update(resource::kFeeInvalidData, "expired");
             break;
         case ListDisposition::Untrusted:
             // Charging this fee here won't hurt the peer in the normal
             // course of operation (ie. refresh every 5 minutes), but
             // will add up if the peer is misbehaving.
-            fee_.update(Resource::kFeeUselessData, "untrusted");
+            fee_.update(resource::kFeeUselessData, "untrusted");
             break;
         case ListDisposition::Invalid:
             // This shouldn't ever happen with a well-behaved peer
-            fee_.update(Resource::kFeeInvalidSignature, "invalid list disposition");
+            fee_.update(resource::kFeeInvalidSignature, "invalid list disposition");
             break;
         case ListDisposition::UnsupportedVersion:
             // During a version transition, this may be legitimate.
             // If it happens frequently, that's probably bad.
-            fee_.update(Resource::kFeeInvalidData, "version");
+            fee_.update(resource::kFeeInvalidData, "version");
             break;
         // LCOV_EXCL_START
         default:
@@ -2259,50 +2422,18 @@ PeerImp::onValidatorListMessage(
     }
 }
 
-void
-PeerImp::onMessage(std::shared_ptr const& m)
-{
-    try
-    {
-        if (!supportsFeature(ProtocolFeature::ValidatorListPropagation))
-        {
-            JLOG(pJournal_.debug()) << "ValidatorList: received validator list from peer using "
-                                    << "protocol version " << to_string(protocol_)
-                                    << " which shouldn't support this feature.";
-            fee_.update(Resource::kFeeUselessData, "unsupported peer");
-            return;
-        }
-        onValidatorListMessage(
-            "ValidatorList", m->manifest(), m->version(), ValidatorList::parseBlobs(*m));
-    }
-    catch (std::exception const& e)
-    {
-        JLOG(pJournal_.warn()) << "ValidatorList: Exception, " << e.what();
-        using namespace std::string_literals;
-        fee_.update(Resource::kFeeInvalidData, e.what());
-    }
-}
-
 void
 PeerImp::onMessage(std::shared_ptr const& m)
 {
     try
     {
-        if (!supportsFeature(ProtocolFeature::ValidatorList2Propagation))
-        {
-            JLOG(pJournal_.debug()) << "ValidatorListCollection: received validator list from peer "
-                                    << "using protocol version " << to_string(protocol_)
-                                    << " which shouldn't support this feature.";
-            fee_.update(Resource::kFeeUselessData, "unsupported peer");
-            return;
-        }
         if (m->version() < 2)
         {
             JLOG(pJournal_.debug())
                 << "ValidatorListCollection: received invalid validator list "
                    "version "
                 << m->version() << " from peer using protocol version " << to_string(protocol_);
-            fee_.update(Resource::kFeeInvalidData, "wrong version");
+            fee_.update(resource::kFeeInvalidData, "wrong version");
             return;
         }
         onValidatorListMessage(
@@ -2312,7 +2443,7 @@ PeerImp::onMessage(std::shared_ptr const& m
     {
         JLOG(pJournal_.warn()) << "ValidatorListCollection: Exception, " << e.what();
         using namespace std::string_literals;
-        fee_.update(Resource::kFeeInvalidData, e.what());
+        fee_.update(resource::kFeeInvalidData, e.what());
     }
 }
 
@@ -2322,7 +2453,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     if (m->validation().size() < 50)
     {
         JLOG(pJournal_.warn()) << "Validation: Too small";
-        fee_.update(Resource::kFeeMalformedRequest, "too small");
+        fee_.update(resource::kFeeMalformedRequest, "too small");
         return;
     }
 
@@ -2333,12 +2464,22 @@ PeerImp::onMessage(std::shared_ptr const& m)
         std::shared_ptr val;
         {
             SerialIter sit(makeSlice(m->validation()));
-            val = std::make_shared(
-                std::ref(sit),
-                [this](PublicKey const& pk) {
-                    return calcNodeID(app_.getValidatorManifests().getMasterKey(pk));
-                },
-                false);
+            try
+            {
+                val = std::make_shared(
+                    std::ref(sit),
+                    [this](PublicKey const& pk) {
+                        return calcNodeID(app_.getValidatorManifests().getMasterKey(pk));
+                    },
+                    STValidation::DeserializeOptions{
+                        .checkSignature = false, .requireCanonicalOrder = true});
+            }
+            catch (std::exception const& e)
+            {
+                JLOG(pJournal_.warn()) << "Validation: Exception, " << e.what();
+                fee_.update(resource::kFeeInvalidData, e.what());
+                return;
+            }
             val->setSeen(closeTime);
         }
 
@@ -2349,7 +2490,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
                 val->getSeenTime()))
         {
             JLOG(pJournal_.trace()) << "Validation: Not current";
-            fee_.update(Resource::kFeeUselessData, "not current");
+            fee_.update(resource::kFeeUselessData, "not current");
             return;
         }
 
@@ -2421,7 +2562,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     {
         JLOG(pJournal_.warn()) << "Exception processing validation: " << e.what();
         using namespace std::string_literals;
-        fee_.update(Resource::kFeeMalformedRequest, e.what());
+        fee_.update(resource::kFeeMalformedRequest, e.what());
     }
 }
 
@@ -2436,7 +2577,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     if (packet.query())
     {
         // this is a query
-        if (sendQueue_.size() >= Tuning::kDropSendQueue)
+        if (sendQueue_.size() >= tuning::kDropSendQueue)
         {
             JLOG(pJournal_.debug()) << "GetObject: Large send queue";
             return;
@@ -2453,7 +2594,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
             if (!txReduceRelayEnabled())
             {
                 JLOG(pJournal_.error()) << "TMGetObjectByHash: tx reduce-relay is disabled";
-                fee_.update(Resource::kFeeMalformedRequest, "disabled");
+                fee_.update(resource::kFeeMalformedRequest, "disabled");
                 return;
             }
 
@@ -2470,19 +2611,19 @@ PeerImp::onMessage(std::shared_ptr const& m)
             if (!stringIsUInt256Sized(packet.ledgerhash()))
             {
                 JLOG(pJournal_.debug()) << "GetObj: malformed ledgerhash from peer " << id_;
-                fee_.update(Resource::kFeeMalformedRequest, "get object ledger hash");
+                fee_.update(resource::kFeeMalformedRequest, "get object ledger hash");
                 return;
             }
         }
         // Reject oversized requests before touching the NodeStore.
         // The legitimate upper bound (InboundLedger::getNeededHashes())
         // is 8 hashes; anything beyond kHardMaxReplyNodes is non-conforming.
-        if (packet.objects_size() > Tuning::kHardMaxReplyNodes)
+        if (packet.objects_size() > tuning::kHardMaxReplyNodes)
         {
             JLOG(pJournal_.warn())
                 << "GetObj: oversized request from peer " << id_ << " (" << packet.objects_size()
-                << " > " << Tuning::kHardMaxReplyNodes << ")";
-            fee_.update(Resource::kFeeInvalidData, "oversized get object request");
+                << " > " << tuning::kHardMaxReplyNodes << ")";
+            fee_.update(resource::kFeeInvalidData, "oversized get object request");
             return;
         }
 
@@ -2504,7 +2645,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
                 // back through the resource model so a misbehaving peer
                 // is still accountable rather than silently dropped.
                 JLOG(peer->pJournal_.warn()) << "GetObj: handler threw: " << e.what();
-                peer->charge(Resource::kFeeRequestNoReply, "get object handler exception");
+                peer->charge(resource::kFeeRequestNoReply, "get object handler exception");
             }
         });
         if (!queued)
@@ -2521,7 +2662,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
         // uncharged DoS window. Charge the base burden up-front (after
         // a successful enqueue); the per-lookup differential is added
         // in the worker.
-        fee_.update(Resource::kFeeModerateBurdenPeer, "received a get object by hash request");
+        fee_.update(resource::kFeeModerateBurdenPeer, "received a get object by hash request");
     }
     else
     {
@@ -2600,7 +2741,7 @@ PeerImp::processGetObjectByHash(std::shared_ptr con
     // a peer cannot drive unbounded NodeStore lookups by sending
     // non-existent hashes.
     int const requested = packet.objects_size();
-    int const iterLimit = std::min(requested, Tuning::kHardMaxReplyNodes);
+    int const iterLimit = std::min(requested, tuning::kHardMaxReplyNodes);
 
     for (int i = 0; i < iterLimit; ++i)
     {
@@ -2631,7 +2772,7 @@ PeerImp::processGetObjectByHash(std::shared_ptr con
     // JobQueue worker thread.
     charge(
         // We pass `requested` directly here, instead of actual lookups done. Which could be
-        // std::min(packet.objects_size(), static_cast(Tuning::kHardMaxReplyNodes));
+        // std::min(packet.objects_size(), static_cast(tuning::kHardMaxReplyNodes));
         // Because we want to charge as per the request size, to discourage large requests.
         computeGetObjectByHashFee(requested, reply.objects_size()),
         "processed get object by hash request");
@@ -2646,7 +2787,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     if (!txReduceRelayEnabled())
     {
         JLOG(pJournal_.error()) << "TMHaveTransactions: tx reduce-relay is disabled";
-        fee_.update(Resource::kFeeMalformedRequest, "disabled");
+        fee_.update(resource::kFeeMalformedRequest, "disabled");
         return;
     }
 
@@ -2671,7 +2812,7 @@ PeerImp::handleHaveTransactions(std::shared_ptr co
         if (!stringIsUInt256Sized(m->hashes(i)))
         {
             JLOG(pJournal_.error()) << "TMHaveTransactions with invalid hash size";
-            fee_.update(Resource::kFeeMalformedRequest, "hash size");
+            fee_.update(resource::kFeeMalformedRequest, "hash size");
             return;
         }
 
@@ -2709,7 +2850,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
     if (!txReduceRelayEnabled())
     {
         JLOG(pJournal_.error()) << "TMTransactions: tx reduce-relay is disabled";
-        fee_.update(Resource::kFeeMalformedRequest, "disabled");
+        fee_.update(resource::kFeeMalformedRequest, "disabled");
         return;
     }
 
@@ -2733,14 +2874,14 @@ PeerImp::onMessage(std::shared_ptr const& m)
     dispatch(strand_, [self = shared_from_this(), m]() {
         if (!m->has_validatorpubkey())
         {
-            self->fee_.update(Resource::kFeeInvalidData, "squelch no pubkey");
+            self->fee_.update(resource::kFeeInvalidData, "squelch no pubkey");
             return;
         }
         auto validator = m->validatorpubkey();
         auto const slice{makeSlice(validator)};
         if (!publicKeyType(slice))
         {
-            self->fee_.update(Resource::kFeeInvalidData, "squelch bad pubkey");
+            self->fee_.update(resource::kFeeInvalidData, "squelch bad pubkey");
             return;
         }
         PublicKey const key(slice);
@@ -2760,7 +2901,7 @@ PeerImp::onMessage(std::shared_ptr const& m)
             (duration < reduce_relay::kMinUnsquelchExpire ||
              duration > reduce_relay::kMaxUnsquelchExpirePeers))
         {
-            self->fee_.update(Resource::kFeeInvalidData, "squelch duration");
+            self->fee_.update(resource::kFeeInvalidData, "squelch duration");
             return;
         }
 
@@ -2809,11 +2950,11 @@ PeerImp::doFetchPack(std::shared_ptr const& packet)
     if (!stringIsUInt256Sized(packet->ledgerhash()))
     {
         JLOG(pJournal_.warn()) << "FetchPack hash size malformed";
-        fee_.update(Resource::kFeeMalformedRequest, "hash size");
+        fee_.update(resource::kFeeMalformedRequest, "hash size");
         return;
     }
 
-    fee_.fee = Resource::kFeeHeavyBurdenPeer;
+    fee_.fee = resource::kFeeHeavyBurdenPeer;
 
     uint256 const hash = uint256::fromRaw(packet->ledgerhash());
 
@@ -2836,7 +2977,7 @@ PeerImp::doTransactions(std::shared_ptr const& pack
     if (packet->objects_size() > reduce_relay::kMaxTxQueueSize)
     {
         JLOG(pJournal_.error()) << "doTransactions, invalid number of hashes";
-        fee_.update(Resource::kFeeMalformedRequest, "too big");
+        fee_.update(resource::kFeeMalformedRequest, "too big");
         return;
     }
 
@@ -2846,7 +2987,7 @@ PeerImp::doTransactions(std::shared_ptr const& pack
 
         if (!stringIsUInt256Sized(obj.hash()))
         {
-            fee_.update(Resource::kFeeMalformedRequest, "hash size");
+            fee_.update(resource::kFeeMalformedRequest, "hash size");
             return;
         }
 
@@ -2858,7 +2999,7 @@ PeerImp::doTransactions(std::shared_ptr const& pack
         {
             JLOG(pJournal_.error())
                 << "doTransactions, transaction not found " << Slice(hash.data(), hash.size());
-            fee_.update(Resource::kFeeMalformedRequest, "tx not found");
+            fee_.update(resource::kFeeMalformedRequest, "tx not found");
             return;
         }
 
@@ -2909,7 +3050,7 @@ PeerImp::checkTransaction(
         {
             JLOG(pJournal_.warn()) << "Ignoring Network relayed Tx containing "
                                       "tfInnerBatchTxn (checkSignature).";
-            charge(Resource::kFeeModerateBurdenPeer, "inner batch txn");
+            charge(resource::kFeeModerateBurdenPeer, "inner batch txn");
             return;
         }
         // LCOV_EXCL_STOP
@@ -2921,7 +3062,7 @@ PeerImp::checkTransaction(
             JLOG(pJournal_.info()) << "Marking transaction " << stx->getTransactionID()
                                    << "as BAD because it's expired";
             app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
-            charge(Resource::kFeeUselessData, "expired tx");
+            charge(resource::kFeeUselessData, "expired tx");
             return;
         }
 
@@ -2952,7 +3093,7 @@ PeerImp::checkTransaction(
                 if (!batch)
                 {
                     JLOG(pJournal_.debug()) << "Charging for pseudo-transaction tx " << tx->getID();
-                    charge(Resource::kFeeUselessData, "pseudo tx");
+                    charge(resource::kFeeUselessData, "pseudo tx");
                 }
 
                 return;
@@ -2974,7 +3115,7 @@ PeerImp::checkTransaction(
                 // Probably not necessary to set HashRouterFlags::BAD, but
                 // doesn't hurt.
                 app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
-                charge(Resource::kFeeInvalidSignature, "check transaction signature failure");
+                charge(resource::kFeeInvalidSignature, "check transaction signature failure");
                 return;
             }
         }
@@ -2993,7 +3134,7 @@ PeerImp::checkTransaction(
                 JLOG(pJournal_.debug()) << "Exception checking transaction: " << reason;
             }
             app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
-            charge(Resource::kFeeInvalidSignature, "tx (impossible)");
+            charge(resource::kFeeInvalidSignature, "tx (impossible)");
             return;
         }
 
@@ -3005,7 +3146,7 @@ PeerImp::checkTransaction(
         JLOG(pJournal_.warn()) << "Exception in " << __func__ << ": " << ex.what();
         app_.getHashRouter().setFlags(stx->getTransactionID(), HashRouterFlags::BAD);
         using namespace std::string_literals;
-        charge(Resource::kFeeInvalidData, "tx "s + ex.what());
+        charge(resource::kFeeInvalidData, "tx "s + ex.what());
     }
 }
 
@@ -3024,7 +3165,7 @@ PeerImp::checkPropose(
     {
         std::string const desc{"Proposal fails sig check"};
         JLOG(pJournal_.warn()) << desc;
-        charge(Resource::kFeeInvalidSignature, desc);
+        charge(resource::kFeeInvalidSignature, desc);
         return;
     }
 
@@ -3065,7 +3206,7 @@ PeerImp::checkValidation(
     {
         std::string const desc{"Validation forwarded by peer is invalid"};
         JLOG(pJournal_.debug()) << desc;
-        charge(Resource::kFeeInvalidSignature, desc);
+        charge(resource::kFeeInvalidSignature, desc);
         return;
     }
 
@@ -3090,7 +3231,7 @@ PeerImp::checkValidation(
     {
         JLOG(pJournal_.trace()) << "Exception processing validation: " << ex.what();
         using namespace std::string_literals;
-        charge(Resource::kFeeMalformedRequest, "validation "s + ex.what());
+        charge(resource::kFeeMalformedRequest, "validation "s + ex.what());
     }
 }
 
@@ -3247,7 +3388,7 @@ PeerImp::getLedger(std::shared_ptr const& m)
             {
                 // Do not resource charge a peer responding to a relay
                 if (!m->has_requestcookie())
-                    charge(Resource::kFeeMalformedRequest, "get_ledger ledgerSeq");
+                    charge(resource::kFeeMalformedRequest, "get_ledger ledgerSeq");
 
                 ledger.reset();
                 JLOG(pJournal_.warn()) << "getLedger: Invalid ledger sequence " << ledgerSeq;
@@ -3300,12 +3441,10 @@ PeerImp::getTxSet(std::shared_ptr const& m) const
 }
 
 void
-PeerImp::processLedgerRequest(std::shared_ptr const& m)
+PeerImp::processLedgerRequest(
+    std::shared_ptr const& m,
+    std::vector nodeIDs)
 {
-    // Do not resource charge a peer responding to a relay
-    if (!m->has_requestcookie())
-        charge(Resource::kFeeModerateBurdenPeer, "received a get ledger request");
-
     std::shared_ptr ledger;
     std::shared_ptr sharedMap;
     SHAMap const* map{nullptr};
@@ -3331,7 +3470,7 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m)
     }
     else
     {
-        if (sendQueue_.size() >= Tuning::kDropSendQueue)
+        if (sendQueue_.size() >= tuning::kDropSendQueue)
         {
             JLOG(pJournal_.debug()) << "processLedgerRequest: Large send queue";
             return;
@@ -3385,37 +3524,54 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m)
     }
 
     // Add requested node data to reply
-    if (m->nodeids_size() > 0)
+    if (!nodeIDs.empty())
     {
         std::uint32_t const defaultDepth = isHighLatency() ? 2 : 1;
         auto const queryDepth{m->has_querydepth() ? m->querydepth() : defaultDepth};
 
-        std::vector> data;
+        std::vector data;
+        data.reserve(tuning::kSoftMaxReplyNodes);
+        auto const useLedgerNodeDepth = supportsFeature(ProtocolFeature::LedgerNodeDepth);
 
-        for (int i = 0;
-             i < m->nodeids_size() && ledgerData.nodes_size() < Tuning::kSoftMaxReplyNodes;
-             ++i)
+        for (auto const& nodeID : nodeIDs)
         {
-            auto const shaMapNodeId{deserializeSHAMapNodeID(m->nodeids(i))};
+            if (ledgerData.nodes_size() >= tuning::kSoftMaxReplyNodes)
+                break;
 
             data.clear();
-            data.reserve(Tuning::kSoftMaxReplyNodes);
 
             try
             {
-                // NOLINTNEXTLINE(bugprone-unchecked-optional-access) nodeids checked in onGetLedger
-                if (map->getNodeFat(*shaMapNodeId, data, fatLeaves, queryDepth))
+                if (map->getNodeFat(nodeID, data, fatLeaves, queryDepth))
                 {
                     JLOG(pJournal_.trace())
                         << "processLedgerRequest: getNodeFat got " << data.size() << " nodes";
 
                     for (auto const& d : data)
                     {
-                        if (ledgerData.nodes_size() >= Tuning::kHardMaxReplyNodes)
+                        if (ledgerData.nodes_size() >= tuning::kHardMaxReplyNodes)
                             break;
+
                         protocol::TMLedgerNode* node{ledgerData.add_nodes()};
-                        node->set_nodeid(d.first.getRawString());
-                        node->set_nodedata(d.second.data(), d.second.size());
+                        node->set_nodedata(d.data.data(), d.data.size());
+
+                        // When the LedgerNodeDepth protocol feature is not supported by the peer,
+                        // we always set the `nodeid` field. However, when it is supported then we
+                        // set the `id` field for inner nodes and the `depth` field for leaf nodes.
+                        if (!useLedgerNodeDepth)
+                        {
+                            node->set_nodeid(d.nodeID.getRawString());
+                        }
+                        else if (d.isLeaf)
+                        {
+                            REACHABLE("xrpl::PeerImp : emit leaf depth in reply");
+                            node->set_depth(d.nodeID.getDepth());
+                        }
+                        else
+                        {
+                            REACHABLE("xrpl::PeerImp : emit inner id in reply");
+                            node->set_id(d.nodeID.getRawString());
+                        }
                     }
                 }
                 else
@@ -3454,13 +3610,13 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m)
                     info += ", no hash specified";
 
                 JLOG(pJournal_.warn())
-                    << "processLedgerRequest: getNodeFat with nodeId " << *shaMapNodeId
+                    << "processLedgerRequest: getNodeFat with nodeId " << nodeID
                     << " and ledger info type " << info << " throws exception: " << e.what();
             }
         }
 
         JLOG(pJournal_.info()) << "processLedgerRequest: Got request for " << m->nodeids_size()
-                               << " nodes at depth " << queryDepth << ", return "
+                               << " node IDs at depth " << queryDepth << ", return "
                                << ledgerData.nodes_size() << " nodes";
     }
 
@@ -3491,30 +3647,30 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m)
 //
 // Misses are billed first against the billable budget because a node store
 // seek dominates a cache hit and because invalid hashes are ~100% miss by construction.
-Resource::Charge
+resource::Charge
 PeerImp::computeGetObjectByHashFee(int const requested, int const found)
 {
-    int const billable = std::max(0, requested - static_cast(Tuning::kFreeObjectsPerRequest));
+    int const billable = std::max(0, requested - static_cast(tuning::kFreeObjectsPerRequest));
     // Clamp `missed` so a future caller passing found > requested cannot
     // produce a negative value that flips the hits/misses split.
     int const missed = std::max(0, requested - found);
     int const billableMisses = std::min(missed, billable);
     int const billableHits = billable - billableMisses;
 
-    int sizeBand = Tuning::kCostBandSmall;
-    if (requested > Tuning::kBandMediumMax)
+    int sizeBand = tuning::kCostBandSmall;
+    if (requested > tuning::kBandMediumMax)
     {
-        sizeBand = Tuning::kCostBandLarge;
+        sizeBand = tuning::kCostBandLarge;
     }
-    else if (requested > Tuning::kBandSmallMax)
+    else if (requested > tuning::kBandSmallMax)
     {
-        sizeBand = Tuning::kCostBandMedium;
+        sizeBand = tuning::kCostBandMedium;
     }
 
-    int const dynamic = (billableHits * Tuning::kCostPerLookupHit) +
-        (billableMisses * Tuning::kCostPerLookupMiss) + sizeBand;
+    int const dynamic = (billableHits * tuning::kCostPerLookupHit) +
+        (billableMisses * tuning::kCostPerLookupMiss) + sizeBand;
 
-    return Resource::Charge(dynamic, "GetObject differential");
+    return resource::Charge(dynamic, "GetObject differential");
 }
 
 int
diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h
index ef3ef6da68..55bc4b978b 100644
--- a/src/xrpld/overlay/detail/PeerImp.h
+++ b/src/xrpld/overlay/detail/PeerImp.h
@@ -9,8 +9,6 @@
 #include 
 #include 
 #include 
-#include 
-#include 
 
 #include 
 #include 
@@ -24,6 +22,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -32,6 +32,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 
 #include 
 #include 
@@ -96,7 +98,7 @@ private:
 
     // Updated at each stage of the connection process to reflect
     // the current conditions as closely as possible.
-    beast::IP::Endpoint const remoteAddress_;
+    beast::ip::Endpoint const remoteAddress_;
 
     // These are up here to prevent warnings about order of initializations
     //
@@ -160,11 +162,11 @@ private:
 
     struct ChargeWithContext
     {
-        Resource::Charge fee = Resource::kFeeTrivialPeer;
+        resource::Charge fee = resource::kFeeTrivialPeer;
         std::string context{};  // NOLINT(readability-redundant-member-init)
 
         void
-        update(Resource::Charge f, std::string const& add)
+        update(resource::Charge f, std::string const& add)
         {
             XRPL_ASSERT(f >= fee, "xrpl::PeerImp::ChargeWithContext::update : fee increases");
             fee = f;
@@ -178,7 +180,7 @@ private:
 
     std::mutex mutable recentLock_;
     protocol::TMStatusChange lastStatus_;
-    Resource::Consumer usage_;
+    resource::Consumer usage_;
     ChargeWithContext fee_;
 
     // One-shot guard so concurrent JobQueue workers cannot double-count
@@ -186,7 +188,7 @@ private:
     // post duplicate fail() calls) when several queued requests cross
     // kDropThreshold before the first fail() lands on the strand.
     std::atomic chargeDisconnectFired_{false};
-    std::shared_ptr const slot_;
+    std::shared_ptr const slot_;
     boost::beast::multi_buffer readBuffer_;
     http_request_type request_;
     http_response_type response_;
@@ -257,11 +259,11 @@ public:
     PeerImp(
         Application& app,
         id_t id,
-        std::shared_ptr const& slot,
+        std::shared_ptr const& slot,
         http_request_type&& request,
         PublicKey const& publicKey,
         ProtocolVersion protocol,
-        Resource::Consumer consumer,
+        resource::Consumer consumer,
         std::unique_ptr&& streamPtr,
         OverlayImpl& overlay);
 
@@ -274,9 +276,9 @@ public:
         Application& app,
         std::unique_ptr&& streamPtr,
         Buffers const& buffers,
-        std::shared_ptr&& slot,
+        std::shared_ptr&& slot,
         http_response_type&& response,
-        Resource::Consumer usage,
+        resource::Consumer usage,
         PublicKey const& publicKey,
         ProtocolVersion protocol,
         id_t id,
@@ -290,7 +292,7 @@ public:
         return pJournal_;
     }
 
-    std::shared_ptr const&
+    std::shared_ptr const&
     slot()
     {
         return slot_;
@@ -338,16 +340,18 @@ public:
     void
     sendEndpoints(FwdIt first, FwdIt last)
         requires(
-            std::is_same_v::value_type, PeerFinder::Endpoint>);
+            std::is_same_v< //
+                typename std::iterator_traits::value_type,
+                peer_finder::Endpoint>);
 
-    beast::IP::Endpoint
+    beast::ip::Endpoint
     getRemoteAddress() const override
     {
         return remoteAddress_;
     }
 
     void
-    charge(Resource::Charge const& fee, std::string const& context) override;
+    charge(resource::Charge const& fee, std::string const& context) override;
 
     //
     // Identity
@@ -426,9 +430,10 @@ public:
     // Ledger
     //
 
-    uint256 const&
+    uint256
     getClosedLedgerHash() const override
     {
+        std::scoped_lock const sl{recentLock_};
         return closedLedgerHash_;
     }
 
@@ -463,6 +468,21 @@ public:
         return compressionEnabled_ == Compressed::On;
     }
 
+    /**
+     * Largest TMManifests message this node accepts, in bytes.
+     *
+     * Read by invokeProtocolMessage to drop oversized messages before
+     * parsing. Not part of the Peer interface: the message handler is a
+     * template parameter, so only PeerImp needs to provide this.
+     */
+    [[nodiscard]] std::size_t
+    maxManifestsMessageSize() const
+    {
+        return maximumManifestsMessageSize(
+            trustedManifestCount(app_.config().maxTrustedCount),
+            untrustedManifestCount(app_.config().maxUntrustedCount));
+    }
+
     bool
     txReduceRelayEnabled() const override
     {
@@ -611,8 +631,6 @@ public:
     void
     onMessage(std::shared_ptr const& m);
     void
-    onMessage(std::shared_ptr const& m);
-    void
     onMessage(std::shared_ptr const& m);
     void
     onMessage(std::shared_ptr const& m);
@@ -687,7 +705,9 @@ private:
     getTxSet(std::shared_ptr const& m) const;
 
     void
-    processLedgerRequest(std::shared_ptr const& m);
+    processLedgerRequest(
+        std::shared_ptr const& m,
+        std::vector nodeIDs);
 
 protected:
     // Kept `protected` so test subclasses (see
@@ -702,7 +722,7 @@ protected:
      *
      * Dispatched from `onMessage(TMGetObjectByHash)` to the JobQueue
      * (`JtLedgerReq`) so synchronous NodeStore lookups do not block the
-     * peer's I/O strand. Caps iteration at `Tuning::kHardMaxReplyNodes`
+     * peer's I/O strand. Caps iteration at `tuning::kHardMaxReplyNodes`
      * regardless of hit/miss outcome and applies differential pricing
      * via `computeGetObjectByHashFee()` after the fetch loop completes.
      *
@@ -716,25 +736,25 @@ protected:
      * request based on how much work was actually performed.
      *
      * The charge has three components on top of the base
-     * `Resource::kFeeModerateBurdenPeer`:
+     * `resource::kFeeModerateBurdenPeer`:
      *   - per-hit lookup cost (cheap; usually served from cache)
      *   - per-miss lookup cost (expensive node store seeks)
      *   - request-size band surcharge (escalates abusive batch sizes)
      *
-     * The first `Tuning::kFreeObjectsPerRequest` objects are free so
+     * The first `tuning::kFreeObjectsPerRequest` objects are free so
      * that legitimate `InboundLedger::getNeededHashes()` traffic
      * (at most 8 objects) is unaffected.
      *
      * @param requested Number of objects requested by the message. This
      *                  value is used for request-size pricing and may
-     *                  exceed `Tuning::kHardMaxReplyNodes` when this
+     *                  exceed `tuning::kHardMaxReplyNodes` when this
      *                  helper is called directly, even though processing
-     *                  caps the iterations to `Tuning::kHardMaxReplyNodes`.
+     *                  caps the iterations to `tuning::kHardMaxReplyNodes`.
      * @param found     Number of objects successfully returned in the
      *                  reply.
-     * @return A `Resource::Charge` whose cost reflects the work performed.
+     * @return A `resource::Charge` whose cost reflects the work performed.
      */
-    static Resource::Charge
+    static resource::Charge
     computeGetObjectByHashFee(int const requested, int const found);
 
     /**
@@ -745,9 +765,9 @@ protected:
      * full JobQueue handler. Production callers should never read this back —
      * the value is consumed by `charge()`/`disconnect()` internally.
      *
-     * @return The current `Resource::Charge` accumulated on `fee_`.
+     * @return The current `resource::Charge` accumulated on `fee_`.
      */
-    Resource::Charge
+    resource::Charge
     currentFeeCharge() const
     {
         return fee_.fee;
@@ -761,9 +781,9 @@ PeerImp::PeerImp(
     Application& app,
     std::unique_ptr&& streamPtr,
     Buffers const& buffers,
-    std::shared_ptr&& slot,
+    std::shared_ptr&& slot,
     http_response_type&& response,
-    Resource::Consumer usage,
+    resource::Consumer usage,
     PublicKey const& publicKey,
     ProtocolVersion protocol,
     id_t id,
@@ -793,7 +813,7 @@ PeerImp::PeerImp(
     , creationTime_(clock_type::now())
     , squelchStore_(app_.getJournal("SquelchStore"), stopwatch())
     , usage_(usage)
-    , fee_{.fee = Resource::kFeeTrivialPeer}
+    , fee_{.fee = resource::kFeeTrivialPeer}
     , slot_(std::move(slot))
     , response_(std::move(response))
     , headers_(response_)
@@ -820,7 +840,8 @@ PeerImp::PeerImp(
 template 
 void
 PeerImp::sendEndpoints(FwdIt first, FwdIt last)
-    requires(std::is_same_v::value_type, PeerFinder::Endpoint>)
+    requires(
+        std::is_same_v::value_type, peer_finder::Endpoint>)
 {
     protocol::TMEndpoints tm;
 
diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h
index ef1bc8cb2b..88f50e1e2e 100644
--- a/src/xrpld/overlay/detail/ProtocolMessage.h
+++ b/src/xrpld/overlay/detail/ProtocolMessage.h
@@ -71,8 +71,6 @@ protocolMessageName(int type)
             return "status";
         case protocol::mtHAVE_SET:
             return "have_set";
-        case protocol::mtVALIDATOR_LIST:
-            return "validator_list";
         case protocol::mtVALIDATOR_LIST_COLLECTION:
             return "validator_list_collection";
         case protocol::mtVALIDATION:
@@ -359,6 +357,13 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
         return result;
     }
 
+    if (header->messageType == protocol::mtPING &&
+        header->uncompressedSize + header->headerSize > kMaximumPingMessageSize)
+    {
+        result.second = make_error_code(boost::system::errc::message_size);
+        return result;
+    }
+
     // We don't have the whole message yet. This isn't an error but we have
     // nothing to do.
     if (header->totalWireSize > size)
@@ -367,6 +372,19 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
         return result;
     }
 
+    // Drop an oversized TMManifests without penalty: consume the bytes and
+    // return no error, so the connection is preserved. The limit follows this
+    // node's configured manifests-per-message count.
+    if (header->messageType == protocol::mtMANIFESTS)
+    {
+        auto const maxSize = handler.maxManifestsMessageSize();
+        if (header->payloadWireSize > maxSize || header->uncompressedSize > maxSize)
+        {
+            result.first = header->totalWireSize;
+            return result;
+        }
+    }
+
     bool success = false;
 
     switch (header->messageType)
@@ -404,9 +422,6 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin
         case protocol::mtVALIDATION:
             success = detail::invoke(*header, buffers, handler);
             break;
-        case protocol::mtVALIDATOR_LIST:
-            success = detail::invoke(*header, buffers, handler);
-            break;
         case protocol::mtVALIDATOR_LIST_COLLECTION:
             success =
                 detail::invoke(*header, buffers, handler);
diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp
index 347e59accb..74dad61828 100644
--- a/src/xrpld/overlay/detail/ProtocolVersion.cpp
+++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp
@@ -14,7 +14,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -27,35 +29,21 @@ namespace xrpl {
  */
 
 constexpr ProtocolVersion const kSupportedProtocolList[]{
-    {2, 1},
     {2, 2},
+    {2, 3},
 };
 
-// This ugly construct ensures that supportedProtocolList is sorted in strictly
-// ascending order and doesn't contain any duplicates.
-// FIXME: With C++20 we can use std::is_sorted with an appropriate comparator
+// There should be at least one protocol we're willing to speak.
 static_assert(
-    []() constexpr -> bool {
-        auto const len =
-            std::distance(std::begin(kSupportedProtocolList), std::end(kSupportedProtocolList));
+    !std::ranges::empty(kSupportedProtocolList),
+    "There must be at least one supported protocol.");
 
-        // There should be at least one protocol we're willing to speak.
-        if (len == 0)
-            return false;
-
-        // A list with only one entry is, by definition, sorted so we don't
-        // need to check it.
-        if (len != 1)
-        {
-            for (auto i = 0; i != len - 1; ++i)
-            {
-                if (kSupportedProtocolList[i] >= kSupportedProtocolList[i + 1])
-                    return false;
-            }
-        }
-
-        return true;
-    }(),
+// Searching for an adjacent pair where the first element is not less than the
+// second one proves the list is sorted in strictly ascending order, which in
+// turn means it holds no duplicates.
+static_assert(
+    std::ranges::adjacent_find(kSupportedProtocolList, std::ranges::greater_equal{}) ==
+        std::ranges::end(kSupportedProtocolList),
     "The list of supported protocols isn't properly sorted.");
 
 std::string
@@ -65,7 +53,7 @@ to_string(ProtocolVersion const& p)
 }
 
 std::vector
-parseProtocolVersions(boost::beast::string_view const& value)
+parseProtocolVersions(std::string_view value)
 {
     static boost::regex const kRE(
         "^"                        // start of line
@@ -132,7 +120,7 @@ negotiateProtocolVersion(std::vector const& versions)
 }
 
 std::optional
-negotiateProtocolVersion(boost::beast::string_view const& versions)
+negotiateProtocolVersion(std::string_view versions)
 {
     auto const them = parseProtocolVersions(versions);
 
diff --git a/src/xrpld/overlay/detail/ProtocolVersion.h b/src/xrpld/overlay/detail/ProtocolVersion.h
index b56871318a..5c05f63e2a 100644
--- a/src/xrpld/overlay/detail/ProtocolVersion.h
+++ b/src/xrpld/overlay/detail/ProtocolVersion.h
@@ -1,10 +1,9 @@
 #pragma once
 
-#include 
-
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -43,7 +42,7 @@ to_string(ProtocolVersion const& p);
  *       no duplicates and will be sorted in ascending protocol order.
  */
 std::vector
-parseProtocolVersions(boost::beast::string_view const& s);
+parseProtocolVersions(std::string_view s);
 
 /**
  * Given a list of supported protocol versions, choose the one we prefer.
@@ -55,7 +54,7 @@ negotiateProtocolVersion(std::vector const& versions);
  * Given a list of supported protocol versions, choose the one we prefer.
  */
 std::optional
-negotiateProtocolVersion(boost::beast::string_view const& versions);
+negotiateProtocolVersion(std::string_view versions);
 
 /**
  * The list of all the protocol versions we support.
diff --git a/src/xrpld/overlay/detail/TrafficCount.cpp b/src/xrpld/overlay/detail/TrafficCount.cpp
index bdce9e68f0..90d5c0b4ff 100644
--- a/src/xrpld/overlay/detail/TrafficCount.cpp
+++ b/src/xrpld/overlay/detail/TrafficCount.cpp
@@ -14,7 +14,6 @@ std::unordered_map const kTypeLoo
     {protocol::mtMANIFESTS, TrafficCount::Category::Manifests},
     {protocol::mtENDPOINTS, TrafficCount::Category::Overlay},
     {protocol::mtTRANSACTION, TrafficCount::Category::Transaction},
-    {protocol::mtVALIDATOR_LIST, TrafficCount::Category::Validatorlist},
     {protocol::mtVALIDATOR_LIST_COLLECTION, TrafficCount::Category::Validatorlist},
     {protocol::mtVALIDATION, TrafficCount::Category::Validation},
     {protocol::mtPROPOSE_LEDGER, TrafficCount::Category::Proposal},
diff --git a/src/xrpld/overlay/detail/Tuning.h b/src/xrpld/overlay/detail/Tuning.h
index 5488fab07b..7561a4f385 100644
--- a/src/xrpld/overlay/detail/Tuning.h
+++ b/src/xrpld/overlay/detail/Tuning.h
@@ -5,7 +5,7 @@
 #include 
 #include 
 
-namespace xrpl::Tuning {
+namespace xrpl::tuning {
 
 /**
  * How many ledgers off a server can be and we will
@@ -74,7 +74,7 @@ constexpr std::size_t kReadBufferBytes = 16384;
  * while a hit is usually served from cache. On top of that, a size-band
  * surcharge kicks in for larger requests so an attacker who crams a
  * single message with thousands of hashes blows past
- * `Resource::kDropThreshold` and gets disconnected.
+ * `resource::kDropThreshold` and gets disconnected.
  *
  * The numbers below are picked to keep three things true given
  * `kDropThreshold = 25000`:
@@ -165,4 +165,4 @@ static constexpr auto kLegitHashesPerType = 4;
 static constexpr auto kBandSmallMax = kLegitHashesPerType * SHAMapInnerNode::kBranchFactor;
 static constexpr auto kBandMediumMax = kBandSmallMax * SHAMapInnerNode::kBranchFactor;
 
-}  // namespace xrpl::Tuning
+}  // namespace xrpl::tuning
diff --git a/src/xrpld/overlay/make_Overlay.h b/src/xrpld/overlay/make_Overlay.h
index a62d4b49de..c730a05c54 100644
--- a/src/xrpld/overlay/make_Overlay.h
+++ b/src/xrpld/overlay/make_Overlay.h
@@ -27,7 +27,7 @@ makeOverlay(
     Application& app,
     Overlay::Setup const& setup,
     ServerHandler& serverHandler,
-    Resource::Manager& resourceManager,
+    resource::Manager& resourceManager,
     Resolver& resolver,
     boost::asio::io_context& ioContext,
     BasicConfig const& config,
diff --git a/src/xrpld/peerfinder/PeerfinderManager.h b/src/xrpld/peerfinder/PeerfinderManager.h
index 0530343641..5934e3bc4a 100644
--- a/src/xrpld/peerfinder/PeerfinderManager.h
+++ b/src/xrpld/peerfinder/PeerfinderManager.h
@@ -1,368 +1,19 @@
 #pragma once
 
 #include 
-#include 
-#include 
 
-#include 
-#include 
-#include 
-#include 
+#include 
 
-#include 
-
-#include 
-#include 
 #include 
-#include 
-#include 
-#include 
-#include 
-#include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
-using clock_type = beast::AbstractClock;
+Config
+makeConfig(
+    xrpl::Config const& config,
+    std::uint16_t port,
+    bool validationPublicKey,
+    int ipLimit,
+    bool verifyEndpoints);
 
-/**
- * Represents a set of addresses.
- */
-using IPAddresses = std::vector;
-
-//------------------------------------------------------------------------------
-
-/**
- * PeerFinder configuration settings.
- */
-struct Config
-{
-    /**
-     * The largest number of public peer slots to allow.
-     * This includes both inbound and outbound, but does not include
-     * fixed peers.
-     */
-    std::size_t maxPeers{Tuning::kDefaultMaxPeers};
-
-    /**
-     * The number of automatic outbound connections to maintain.
-     * Outbound connections are only maintained if autoConnect
-     * is `true`.
-     */
-    std::size_t outPeers;
-
-    /**
-     * The number of automatic inbound connections to maintain.
-     * Inbound connections are only maintained if wantIncoming
-     * is `true`.
-     */
-    std::size_t inPeers{0};
-
-    /**
-     * `true` if we want our IP address kept private.
-     */
-    bool peerPrivate = true;
-
-    /**
-     * `true` if we want to accept incoming connections.
-     */
-    bool wantIncoming{true};
-
-    /**
-     * `true` if we want to establish connections automatically
-     */
-    bool autoConnect{true};
-
-    /**
-     * The listening port number.
-     */
-    std::uint16_t listeningPort{0};
-
-    /**
-     * The set of features we advertise.
-     */
-    std::string features;
-
-    /**
-     * Limit how many incoming connections we allow per IP
-     */
-    int ipLimit{0};
-
-    /**
-     * `true` if we want to verify endpoints in TMEndpoints messages
-     */
-    bool verifyEndpoints = true;
-
-    //--------------------------------------------------------------------------
-
-    /**
-     * Create a configuration with default values.
-     */
-    Config();
-
-    /**
-     * Returns a suitable value for outPeers according to the rules.
-     */
-    [[nodiscard]] std::size_t
-    calcOutPeers() const;
-
-    /**
-     * Adjusts the values so they follow the business rules.
-     */
-    void
-    applyTuning();
-
-    /**
-     * Write the configuration into a property stream
-     */
-    void
-    onWrite(beast::PropertyStream::Map& map) const;
-
-    /**
-     * Make PeerFinder::Config from configuration parameters
-     * @param config server's configuration
-     * @param port server's listening port
-     * @param validationPublicKey true if validation public key is not empty
-     * @param ipLimit limit of incoming connections per IP
-     * @param verifyEndpoints `true` if we want to verify endpoints in
-     * TMEndpoints messages
-     * @return PeerFinder::Config
-     */
-    static Config
-    makeConfig(
-        xrpl::Config const& config,
-        std::uint16_t port,
-        bool validationPublicKey,
-        int ipLimit,
-        bool verifyEndpoints);
-
-    friend bool
-    operator==(Config const& lhs, Config const& rhs) = default;
-};
-
-//------------------------------------------------------------------------------
-
-/**
- * Describes a connectable peer address along with some metadata.
- */
-struct Endpoint
-{
-    Endpoint() = default;
-
-    Endpoint(beast::IP::Endpoint ep, std::uint32_t hops);
-
-    std::uint32_t hops = 0;
-    beast::IP::Endpoint address;
-};
-
-inline bool
-operator<(Endpoint const& lhs, Endpoint const& rhs)
-{
-    return lhs.address < rhs.address;
-}
-
-/**
- * A set of Endpoint used for connecting.
- */
-using Endpoints = std::vector;
-
-//------------------------------------------------------------------------------
-
-/**
- * Possible results from activating a slot.
- */
-enum class Result { InboundDisabled, DuplicatePeer, IpLimitExceeded, Full, Success };
-
-/**
- * @brief Converts a `Result` enum value to its string representation.
- *
- * This function provides a human-readable string for a given `Result` enum,
- * which is useful for logging, debugging, or displaying status messages.
- *
- * @param result The `Result` enum value to convert.
- * @return A `std::string_view` representing the enum value. Returns "unknown"
- * if the enum value is not explicitly handled.
- *
- * @note This function returns a `std::string_view` for performance.
- * A `std::string` would need to allocate memory on the heap and copy the
- * string literal into it every time the function is called.
- */
-inline std::string_view
-to_string(Result result) noexcept
-{
-    switch (result)
-    {
-        case Result::InboundDisabled:
-            return "inbound disabled";
-        case Result::DuplicatePeer:
-            return "peer already connected";
-        case Result::IpLimitExceeded:
-            return "ip limit exceeded";
-        case Result::Full:
-            return "slots full";
-        case Result::Success:
-            return "success";
-    }
-
-    return "unknown";
-}
-
-/**
- * Maintains a set of IP addresses used for getting into the network.
- */
-class Manager : public beast::PropertyStream::Source
-{
-protected:
-    Manager() noexcept;
-
-public:
-    /**
-     * Destroy the object.
-     * Any pending source fetch operations are aborted.
-     * There may be some listener calls made before the
-     * destructor returns.
-     */
-    ~Manager() override = default;
-
-    /**
-     * Set the configuration for the manager.
-     * The new settings will be applied asynchronously.
-     * Thread safety:
-     *     Can be called from any threads at any time.
-     */
-    virtual void
-    setConfig(Config const& config) = 0;
-
-    /**
-     * Transition to the started state, synchronously.
-     */
-    virtual void
-    start() = 0;
-
-    /**
-     * Transition to the stopped state, synchronously.
-     */
-    virtual void
-    stop() = 0;
-
-    /**
-     * Returns the configuration for the manager.
-     */
-    virtual Config
-    config() = 0;
-
-    /**
-     * Add a peer that should always be connected.
-     * This is useful for maintaining a private cluster of peers.
-     * The string is the name as specified in the configuration
-     * file, along with the set of corresponding IP addresses.
-     */
-    virtual void
-    addFixedPeer(std::string_view name, std::vector const& addresses) = 0;
-
-    /**
-     * 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.
-     * @param name A label used for diagnostics.
-     */
-    /* VFALCO NOTE Unimplemented
-    virtual void addFallbackURL (std::string const& name,
-        std::string const& url) = 0;
-    */
-
-    //--------------------------------------------------------------------------
-
-    /**
-     * Create a new inbound slot with the specified remote endpoint.
-     * If nullptr is returned, then the slot could not be assigned.
-     * Usually this is because of a detected self-connection.
-     */
-    virtual std::pair, Result>
-    newInboundSlot(
-        beast::IP::Endpoint const& localEndpoint,
-        beast::IP::Endpoint const& remoteEndpoint) = 0;
-
-    /**
-     * Create a new outbound slot with the specified remote endpoint.
-     * If nullptr is returned, then the slot could not be assigned.
-     * Usually this is because of a duplicate connection.
-     */
-    virtual std::pair, Result>
-    newOutboundSlot(beast::IP::Endpoint const& remoteEndpoint) = 0;
-
-    /**
-     * Called when mtENDPOINTS is received.
-     */
-    virtual void
-    onEndpoints(std::shared_ptr const& slot, Endpoints const& endpoints) = 0;
-
-    /**
-     * Called when the slot is closed.
-     * This always happens when the socket is closed, unless the socket
-     * was canceled.
-     */
-    virtual void
-    onClosed(std::shared_ptr const& slot) = 0;
-
-    /**
-     * Called when an outbound connection is deemed to have failed
-     */
-    virtual void
-    onFailure(std::shared_ptr const& slot) = 0;
-
-    /**
-     * Called when we received redirect IPs from a busy peer.
-     */
-    virtual void
-    onRedirects(
-        boost::asio::ip::tcp::endpoint const& remoteAddress,
-        std::vector const& eps) = 0;
-
-    //--------------------------------------------------------------------------
-
-    /**
-     * Called when an outbound connection attempt succeeds.
-     * The local endpoint must be valid. If the caller receives an error
-     * when retrieving the local endpoint from the socket, it should
-     * proceed as if the connection attempt failed by calling on_closed
-     * instead of on_connected.
-     * @return `true` if the connection should be kept
-     */
-    virtual bool
-    onConnected(std::shared_ptr const& slot, beast::IP::Endpoint const& localEndpoint) = 0;
-
-    /**
-     * Request an active slot type.
-     */
-    virtual Result
-    activate(std::shared_ptr const& slot, PublicKey const& key, bool reserved) = 0;
-
-    /**
-     * Returns a set of endpoints suitable for redirection.
-     */
-    virtual std::vector
-    redirect(std::shared_ptr const& slot) = 0;
-
-    /**
-     * Return a set of addresses we should connect to.
-     */
-    virtual std::vector
-    autoconnect() = 0;
-
-    virtual std::vector, std::vector>>
-    buildEndpointsForPeers() = 0;
-
-    /**
-     * Perform periodic activity.
-     * This should be called once per second.
-     */
-    virtual void
-    oncePerSecond() = 0;
-};
-
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/xrpld/peerfinder/detail/Endpoint.cpp b/src/xrpld/peerfinder/detail/Endpoint.cpp
deleted file mode 100644
index 15de5cd153..0000000000
--- a/src/xrpld/peerfinder/detail/Endpoint.cpp
+++ /dev/null
@@ -1,17 +0,0 @@
-#include 
-#include 
-
-#include 
-
-#include 
-#include 
-#include 
-
-namespace xrpl::PeerFinder {
-
-Endpoint::Endpoint(beast::IP::Endpoint ep, std::uint32_t hops)
-    : hops(std::min(hops, Tuning::kMaxHops + 1)), address(std::move(ep))
-{
-}
-
-}  // namespace xrpl::PeerFinder
diff --git a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
index 5d276dc9c5..b222f6c077 100644
--- a/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
+++ b/src/xrpld/peerfinder/detail/PeerfinderConfig.cpp
@@ -1,124 +1,39 @@
 #include 
 #include 
-#include 
 
-#include 
+#include 
 
-#include 
-#include 
 #include 
 
-namespace xrpl::PeerFinder {
-
-Config::Config() : outPeers(calcOutPeers())
-
-{
-}
-
-std::size_t
-Config::calcOutPeers() const
-{
-    return std::max(
-        ((maxPeers * Tuning::kOutPercent) + 50) / 100, std::size_t(Tuning::kMinOutCount));
-}
-
-void
-Config::applyTuning()
-{
-    if (ipLimit == 0)
-    {
-        // Unless a limit is explicitly set, we allow between
-        // 2 and 5 connections from non RFC-1918 "private"
-        // IP addresses.
-        ipLimit = 2;
-
-        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,
-    // unless we only have one incoming slot available.
-    ipLimit = std::max(1, std::min(ipLimit, static_cast(inPeers / 2)));
-}
-
-void
-Config::onWrite(beast::PropertyStream::Map& map) const
-{
-    map["max_peers"] = maxPeers;
-    map["out_peers"] = outPeers;
-    map["want_incoming"] = wantIncoming;
-    map["auto_connect"] = autoConnect;
-    map["port"] = listeningPort;
-    map["features"] = features;
-    map["ip_limit"] = ipLimit;
-    map["verify_endpoints"] = verifyEndpoints;
-}
+namespace xrpl::peer_finder {
 
 Config
-Config::makeConfig(
+makeConfig(
     xrpl::Config const& cfg,
     std::uint16_t port,
     bool validationPublicKey,
     int ipLimit,
     bool verifyEndpoints)
 {
-    PeerFinder::Config config;
-
-    config.peerPrivate = cfg.peerPrivate;
-
-    // Servers with peer privacy don't want to allow incoming connections
-    config.wantIncoming = (!config.peerPrivate) && (port != 0);
-
+    PeerLimitConfig limits;
     if ((cfg.peersOutMax == 0u) && (cfg.peersInMax == 0u))
     {
-        if (cfg.peersMax != 0)
-            config.maxPeers = cfg.peersMax;
-
-        config.maxPeers = std::max(config.maxPeers, Tuning::kMinOutCount);
-        config.outPeers = config.calcOutPeers();
-
-        // Calculate the number of outbound peers we want. If we dont want
-        // or can't accept incoming, this will simply be equal to maxPeers.
-        if (!config.wantIncoming)
-            config.outPeers = config.maxPeers;
-
-        // Calculate the largest number of inbound connections we could
-        // take.
-        if (config.maxPeers >= config.outPeers)
-        {
-            config.inPeers = config.maxPeers - config.outPeers;
-        }
-        else
-        {
-            config.inPeers = 0;
-        }
+        limits.maxPeers = cfg.peersMax;
     }
     else
     {
-        config.outPeers = cfg.peersOutMax;
-        config.inPeers = cfg.peersInMax;
-        config.maxPeers = 0;
+        limits.inPeers = cfg.peersInMax;
+        limits.outPeers = cfg.peersOutMax;
     }
 
-    // This will cause servers configured as validators to request that
-    // peers they connect to never report their IP address. We set this
-    // after we set the 'wantIncoming' because we want a "soft" version
-    // of peer privacy unless the operator explicitly asks for it.
-    if (validationPublicKey)
-        config.peerPrivate = true;
-
-    // if it's a private peer or we are running as standalone
-    // automatic connections would defeat the purpose.
-    config.autoConnect = !cfg.standalone() && !cfg.peerPrivate;
-    config.listeningPort = port;
-    config.features = "";
-    config.ipLimit = ipLimit;
-    config.verifyEndpoints = verifyEndpoints;
-
-    // Enforce business rules
-    config.applyTuning();
-
-    return config;
+    return Config::makeConfig(
+        cfg.peerPrivate,
+        cfg.standalone(),
+        limits,
+        port,
+        validationPublicKey,
+        ipLimit,
+        verifyEndpoints);
 }
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/xrpld/peerfinder/detail/StoreSqdb.h b/src/xrpld/peerfinder/detail/StoreSqdb.h
index b17d2fdc5b..ce13d72c15 100644
--- a/src/xrpld/peerfinder/detail/StoreSqdb.h
+++ b/src/xrpld/peerfinder/detail/StoreSqdb.h
@@ -1,11 +1,11 @@
 #pragma once
 
 #include 
-#include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -14,7 +14,7 @@
 #include 
 #include 
 
-namespace xrpl::PeerFinder {
+namespace xrpl::peer_finder {
 
 /**
  * Database persistence for PeerFinder using SQLite
@@ -50,7 +50,7 @@ public:
         std::size_t n(0);
 
         readPeerFinderDB(sqlDb_, [&](std::string const& s, int valence) {
-            beast::IP::Endpoint const endpoint(beast::IP::Endpoint::fromString(s));
+            beast::ip::Endpoint const endpoint(beast::ip::Endpoint::fromString(s));
 
             if (!isUnspecified(endpoint))
             {
@@ -90,4 +90,4 @@ private:
     }
 };
 
-}  // namespace xrpl::PeerFinder
+}  // namespace xrpl::peer_finder
diff --git a/src/xrpld/peerfinder/detail/iosformat.h b/src/xrpld/peerfinder/detail/iosformat.h
deleted file mode 100644
index 46c69ef602..0000000000
--- a/src/xrpld/peerfinder/detail/iosformat.h
+++ /dev/null
@@ -1,201 +0,0 @@
-#pragma once
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace beast {
-
-// A collection of handy stream manipulators and
-// functions to produce nice looking log output.
-
-/**
- * Left justifies a field at the specified width.
- */
-struct Leftw
-{
-    explicit Leftw(int width) : width(width)
-    {
-    }
-    int const width;
-    template 
-    friend std::basic_ios&
-    operator<<(std::basic_ios& ios, Leftw const& p)
-    {
-        ios.setf(std::ios_base::left, std::ios_base::adjustfield);
-        ios.width(p.width);
-        return ios;
-    }
-};
-
-/**
- * Produce a section heading and fill the rest of the line with dashes.
- */
-template 
-std::basic_string
-heading(std::basic_string title, int width = 80, CharT fill = CharT('-'))
-{
-    title.reserve(width);
-    title.push_back(CharT(' '));
-    title.resize(width, fill);
-    return title;
-}
-
-/**
- * Produce a dashed line separator, with a specified or default size.
- */
-struct Divider
-{
-    using CharT = char;
-    explicit Divider(int width = 80, CharT fill = CharT('-')) : width(width), fill(fill)
-    {
-    }
-    int const width;
-    CharT const fill;
-    template 
-    friend std::basic_ostream&
-    operator<<(std::basic_ostream& os, Divider const& d)
-    {
-        os << std::basic_string(d.width, d.fill);
-        return os;
-    }
-};
-
-/**
- * Creates a padded field with an optional fill character.
- */
-struct Fpad
-{
-    explicit Fpad(int width, int pad = 0, char fill = ' ') : width(width + pad), fill(fill)
-    {
-    }
-    int const width;
-    char const fill;
-    template 
-    friend std::basic_ostream&
-    operator<<(std::basic_ostream& os, Fpad const& f)
-    {
-        os << std::basic_string(f.width, f.fill);
-        return os;
-    }
-};
-
-//------------------------------------------------------------------------------
-
-namespace detail {
-
-template 
-std::string
-to_string(T const& t)
-{
-    std::stringstream ss;
-    ss << t;
-    return ss.str();
-}
-
-}  // namespace detail
-
-/**
- * Justifies a field at the specified width.
- */
-/** @{ */
-template <
-    class CharT,
-    class Traits = std::char_traits,
-    class Allocator = std::allocator>
-class FieldT
-{
-public:
-    using string_t = std::basic_string;
-    FieldT(string_t const& text, int width, int pad, bool right)
-        : text(text), width(width), pad(pad), right(right)
-    {
-    }
-    string_t const text;
-    int const width;
-    int const pad;
-    bool const right;
-    template 
-    friend std::basic_ostream&
-    operator<<(std::basic_ostream& os, FieldT const& f)
-    {
-        std::size_t const length(f.text.length());
-        if (f.right)
-        {
-            if (length < f.width)
-                os << std::basic_string(f.width - length, CharT2(' '));
-            os << f.text;
-        }
-        else
-        {
-            os << f.text;
-            if (length < f.width)
-                os << std::basic_string(f.width - length, CharT2(' '));
-        }
-        if (f.pad != 0)
-            os << string_t(f.pad, CharT(' '));
-        return os;
-    }
-};
-
-template 
-FieldT
-field(
-    std::basic_string const& text,
-    int width = 8,
-    int pad = 0,
-    bool right = false)
-{
-    return FieldT(text, width, pad, right);
-}
-
-template 
-FieldT
-field(CharT const* text, int width = 8, int pad = 0, bool right = false)
-{
-    return FieldT, std::allocator>(
-        std::basic_string, std::allocator>(text),
-        width,
-        pad,
-        right);
-}
-
-template 
-FieldT
-field(T const& t, int width = 8, int pad = 0, bool right = false)
-{
-    std::string const text(detail::to_string(t));
-    return field(text, width, pad, right);
-}
-
-template 
-FieldT
-rField(std::basic_string const& text, int width = 8, int pad = 0)
-{
-    return FieldT(text, width, pad, true);
-}
-
-template 
-FieldT
-rField(CharT const* text, int width = 8, int pad = 0)
-{
-    return FieldT, std::allocator>(
-        std::basic_string, std::allocator>(text),
-        width,
-        pad,
-        true);
-}
-
-template 
-FieldT
-rField(T const& t, int width = 8, int pad = 0)
-{
-    std::string const text(detail::to_string(t));
-    return field(text, width, pad, true);
-}
-/** @} */
-
-}  // namespace beast
diff --git a/src/xrpld/peerfinder/make_Manager.h b/src/xrpld/peerfinder/make_Manager.h
deleted file mode 100644
index 1c13d7a4ca..0000000000
--- a/src/xrpld/peerfinder/make_Manager.h
+++ /dev/null
@@ -1,26 +0,0 @@
-#pragma once
-
-#include 
-
-#include 
-#include 
-#include 
-
-#include 
-
-#include 
-
-namespace xrpl::PeerFinder {
-
-/**
- * Create a new Manager.
- */
-std::unique_ptr
-makeManager(
-    boost::asio::io_context& ioContext,
-    clock_type& clock,
-    beast::Journal journal,
-    BasicConfig const& config,
-    beast::insight::Collector::ptr const& collector);
-
-}  // namespace xrpl::PeerFinder
diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp
index 3aa7e38ea2..2777e0dcdb 100644
--- a/src/xrpld/perflog/detail/PerfLogImp.cpp
+++ b/src/xrpld/perflog/detail/PerfLogImp.cpp
@@ -17,11 +17,9 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,6 +27,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -220,10 +219,10 @@ PerfLogImp::openLog()
         logFile_.close();
 
     auto logDir = setup_.perfLog.parent_path();
-    if (!boost::filesystem::is_directory(logDir))
+    if (!std::filesystem::is_directory(logDir))
     {
-        boost::system::error_code ec;
-        boost::filesystem::create_directories(logDir, ec);
+        std::error_code ec;
+        std::filesystem::create_directories(logDir, ec);
         if (ec)
         {
             JLOG(j_.fatal()) << "Unable to create performance log "
@@ -478,17 +477,17 @@ PerfLogImp::stop()
 //-----------------------------------------------------------------------------
 
 PerfLog::Setup
-setupPerfLog(Section const& section, boost::filesystem::path const& configDir)
+setupPerfLog(Section const& section, std::filesystem::path const& configDir)
 {
     PerfLog::Setup setup;
     std::string perfLog;
     set(perfLog, "perf_log", section);
     if (!perfLog.empty())
     {
-        setup.perfLog = boost::filesystem::path(perfLog);
+        setup.perfLog = std::filesystem::path(perfLog);
         if (setup.perfLog.is_relative())
         {
-            setup.perfLog = boost::filesystem::absolute(setup.perfLog, configDir);
+            setup.perfLog = std::filesystem::absolute(configDir / setup.perfLog);
         }
     }
 
diff --git a/src/xrpld/perflog/detail/PerfLogImp.h b/src/xrpld/perflog/detail/PerfLogImp.h
index 14477512ff..7efdbe1b7f 100644
--- a/src/xrpld/perflog/detail/PerfLogImp.h
+++ b/src/xrpld/perflog/detail/PerfLogImp.h
@@ -109,7 +109,7 @@ class PerfLogImp : public PerfLog
     Application& app_;
     beast::Journal const j_;
     std::function const signalStop_;
-    Counters counters_{xrpl::RPC::getHandlerNames(), JobTypes::instance()};
+    Counters counters_{xrpl::rpc::getHandlerNames(), JobTypes::instance()};
     std::ofstream logFile_;
     std::thread thread_;
     std::mutex mutex_;
diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h
index 3c10ece78f..16f7ea8e43 100644
--- a/src/xrpld/rpc/BookChanges.h
+++ b/src/xrpld/rpc/BookChanges.h
@@ -32,7 +32,7 @@ class Transaction;
 class TxMeta;
 class STTx;
 
-namespace RPC {
+namespace rpc {
 
 template 
 json::Value
@@ -233,5 +233,5 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
     return jvObj;
 }
 
-}  // namespace RPC
+}  // namespace rpc
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/CTID.h b/src/xrpld/rpc/CTID.h
index 7566e0e143..71ded5834f 100644
--- a/src/xrpld/rpc/CTID.h
+++ b/src/xrpld/rpc/CTID.h
@@ -11,7 +11,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 // CTID stands for Concise Transaction ID.
 //
@@ -111,4 +111,4 @@ decodeCTID(T const ctid) noexcept
     return std::make_tuple(ledgerSeq, txnIndex, networkID);
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/Context.h b/src/xrpld/rpc/Context.h
index 81ba068d8f..e85bf6dfe6 100644
--- a/src/xrpld/rpc/Context.h
+++ b/src/xrpld/rpc/Context.h
@@ -18,7 +18,7 @@ class Application;
 class NetworkOPs;
 class LedgerMaster;
 
-namespace RPC {
+namespace rpc {
 
 /**
  * The context of information needed to call an RPC.
@@ -27,10 +27,10 @@ struct Context
 {
     beast::Journal const j;
     Application& app;
-    Resource::Charge& loadType;
+    resource::Charge& loadType;
     NetworkOPs& netOps;
     LedgerMaster& ledgerMaster;
-    Resource::Consumer& consumer;
+    resource::Consumer& consumer;
     Role role;
     std::shared_ptr coro;
     InfoSub::pointer infoSub;
@@ -59,5 +59,5 @@ struct GRPCContext : public Context
     RequestType params;
 };
 
-}  // namespace RPC
+}  // namespace rpc
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/DeliveredAmount.h b/src/xrpld/rpc/DeliveredAmount.h
index dc635c6861..a7878070d6 100644
--- a/src/xrpld/rpc/DeliveredAmount.h
+++ b/src/xrpld/rpc/DeliveredAmount.h
@@ -17,7 +17,7 @@ class Transaction;
 class TxMeta;
 class STTx;
 
-namespace RPC {
+namespace rpc {
 
 struct JsonContext;
 
@@ -41,23 +41,23 @@ insertDeliveredAmount(
 void
 insertDeliveredAmount(
     json::Value& meta,
-    RPC::JsonContext const&,
+    rpc::JsonContext const&,
     std::shared_ptr const&,
     TxMeta const&);
 void
 insertDeliveredAmount(
     json::Value& meta,
-    RPC::JsonContext const&,
+    rpc::JsonContext const&,
     std::shared_ptr const&,
     TxMeta const&);
 
 std::optional
 getDeliveredAmount(
-    RPC::Context const& context,
+    rpc::Context const& context,
     std::shared_ptr const& serializedTx,
     TxMeta const& transactionMeta,
     LedgerIndex const& ledgerIndex);
 /** @} */
 
-}  // namespace RPC
+}  // namespace rpc
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/GRPCHandlers.h b/src/xrpld/rpc/GRPCHandlers.h
index 9dc7e0b13a..cabd55d53b 100644
--- a/src/xrpld/rpc/GRPCHandlers.h
+++ b/src/xrpld/rpc/GRPCHandlers.h
@@ -14,22 +14,22 @@ namespace xrpl {
 
 /*
  * These handlers are for gRPC. They each take in a protobuf message that is
- * nested inside RPC::GRPCContext, where T is the request type
+ * nested inside rpc::GRPCContext, where T is the request type
  * The return value is the response type, as well as a status
  * If the status is not Status::OK (meaning an error occurred), then only
  * the status will be sent to the client, and the response will be omitted
  */
 
 std::pair
-doLedgerGrpc(RPC::GRPCContext& context);
+doLedgerGrpc(rpc::GRPCContext& context);
 
 std::pair
-doLedgerEntryGrpc(RPC::GRPCContext& context);
+doLedgerEntryGrpc(rpc::GRPCContext& context);
 
 std::pair
-doLedgerDataGrpc(RPC::GRPCContext& context);
+doLedgerDataGrpc(rpc::GRPCContext& context);
 
 std::pair
-doLedgerDiffGrpc(RPC::GRPCContext& context);
+doLedgerDiffGrpc(rpc::GRPCContext& context);
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/MPTokenIssuanceID.h b/src/xrpld/rpc/MPTokenIssuanceID.h
index f56826bfb8..678fda0369 100644
--- a/src/xrpld/rpc/MPTokenIssuanceID.h
+++ b/src/xrpld/rpc/MPTokenIssuanceID.h
@@ -8,7 +8,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 /**
  * Add a `mpt_issuance_id` field to the `meta` input/output parameter.
@@ -32,4 +32,4 @@ insertMPTokenIssuanceID(
     TxMeta const& transactionMeta);
 /** @} */
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/Output.h b/src/xrpld/rpc/Output.h
index 30b5c090d7..f528efef56 100644
--- a/src/xrpld/rpc/Output.h
+++ b/src/xrpld/rpc/Output.h
@@ -5,7 +5,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 using Output = std::function;
 
@@ -15,4 +15,4 @@ stringOutput(std::string& s)
     return [&](boost::string_ref const& b) { s.append(b.data(), b.size()); };
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/RPCCall.h b/src/xrpld/rpc/RPCCall.h
index a72b35e344..2fec78f93b 100644
--- a/src/xrpld/rpc/RPCCall.h
+++ b/src/xrpld/rpc/RPCCall.h
@@ -26,7 +26,7 @@ namespace xrpl {
 /**
  * Processes XRPL RPC calls.
  */
-namespace RPCCall {
+namespace rpc_call {
 
 int
 fromCommandLine(Config const& config, std::vector const& vCmd, Logs& logs);
@@ -47,7 +47,7 @@ fromNetwork(
     std::function callbackFuncP =
         std::function(),
     std::unordered_map headers = {});
-}  // namespace RPCCall
+}  // namespace rpc_call
 
 json::Value
 rpcCmdToJson(
diff --git a/src/xrpld/rpc/RPCHandler.h b/src/xrpld/rpc/RPCHandler.h
index fcd0f54265..637a492943 100644
--- a/src/xrpld/rpc/RPCHandler.h
+++ b/src/xrpld/rpc/RPCHandler.h
@@ -8,7 +8,7 @@
 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 struct JsonContext;
 
@@ -16,9 +16,9 @@ struct JsonContext;
  * Execute an RPC command and store the results in a json::Value.
  */
 Status
-doCommand(RPC::JsonContext&, json::Value&);
+doCommand(rpc::JsonContext&, json::Value&);
 
 Role
 roleRequired(unsigned int version, bool betaEnabled, std::string const& method);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/Role.h b/src/xrpld/rpc/Role.h
index 2c2ae6b781..48c89333bd 100644
--- a/src/xrpld/rpc/Role.h
+++ b/src/xrpld/rpc/Role.h
@@ -40,13 +40,13 @@ requestRole(
     Role const& required,
     Port const& port,
     json::Value const& params,
-    beast::IP::Endpoint const& remoteIp,
+    beast::ip::Endpoint const& remoteIp,
     std::string_view user);
 
-Resource::Consumer
+resource::Consumer
 requestInboundEndpoint(
-    Resource::Manager& manager,
-    beast::IP::Endpoint const& remoteAddress,
+    resource::Manager& manager,
+    beast::ip::Endpoint const& remoteAddress,
     Role const& role,
     std::string_view user,
     std::string_view forwardedFor);
@@ -66,7 +66,7 @@ isUnlimited(Role const& role);
  */
 bool
 ipAllowed(
-    beast::IP::Address const& remoteIp,
+    beast::ip::Address const& remoteIp,
     std::vector const& nets4,
     std::vector const& nets6);
 
diff --git a/src/xrpld/rpc/ServerHandler.h b/src/xrpld/rpc/ServerHandler.h
index 054bec9b5b..a09fc1c18a 100644
--- a/src/xrpld/rpc/ServerHandler.h
+++ b/src/xrpld/rpc/ServerHandler.h
@@ -80,7 +80,7 @@ private:
     using stream_type = boost::beast::ssl_stream;
 
     Application& app_;
-    Resource::Manager& resourceManager_;
+    resource::Manager& resourceManager_;
     beast::Journal journal_;
     NetworkOPs& networkOPs_;
     std::unique_ptr server_;
@@ -109,7 +109,7 @@ private:
         boost::asio::io_context&,
         JobQueue&,
         NetworkOPs&,
-        Resource::Manager&,
+        resource::Manager&,
         CollectorManager& cm);
 
 public:
@@ -120,7 +120,7 @@ public:
         boost::asio::io_context& ioContext,
         JobQueue& jobQueue,
         NetworkOPs& networkOPs,
-        Resource::Manager& resourceManager,
+        resource::Manager& resourceManager,
         CollectorManager& cm);
 
     ~ServerHandler();
@@ -196,7 +196,7 @@ private:
     processRequest(
         Port const& port,
         std::string const& request,
-        beast::IP::Endpoint const& remoteIPAddress,
+        beast::ip::Endpoint const& remoteIPAddress,
         Output const&,
         std::shared_ptr coro,
         std::string_view forwardedFor,
@@ -215,7 +215,7 @@ makeServerHandler(
     boost::asio::io_context&,
     JobQueue&,
     NetworkOPs&,
-    Resource::Manager&,
+    resource::Manager&,
     CollectorManager& cm);
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/Status.h b/src/xrpld/rpc/Status.h
index dda1e89d31..e2716bd579 100644
--- a/src/xrpld/rpc/Status.h
+++ b/src/xrpld/rpc/Status.h
@@ -11,7 +11,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 /**
  * Status represents the results of an operation that might fail.
@@ -56,9 +56,11 @@ public:
     {
     }
 
-    /* Returns a representation of the integer status Code as a string.
-       If the Status is OK, the result is an empty string.
-    */
+    /**
+     * If the Status is OK, the result is an empty string.
+     *
+     * @return a representation of the integer status Code as a string.
+     */
     [[nodiscard]] std::string
     codeString() const;
 
@@ -86,7 +88,7 @@ public:
     [[nodiscard]] TER
     toTER() const
     {
-        XRPL_ASSERT(type_ == Type::TER, "xrpl::RPC::Status::toTER : type is TER");
+        XRPL_ASSERT(type_ == Type::TER, "xrpl::rpc::Status::toTER : type is TER");
         return TER::fromInt(code_);
     }
 
@@ -97,7 +99,8 @@ public:
     [[nodiscard]] ErrorCodeI
     toErrorCode() const
     {
-        XRPL_ASSERT(type_ == Type::ErrorCodeI, "xrpl::RPC::Status::toTER : type is error code");
+        XRPL_ASSERT(
+            type_ == Type::ErrorCodeI, "xrpl::rpc::Status::toErrorCode : type is error code");
         return ErrorCodeI(code_);
     }
 
@@ -155,4 +158,4 @@ private:
     Strings messages_;
 };
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/DeliveredAmount.cpp b/src/xrpld/rpc/detail/DeliveredAmount.cpp
index 8d8aac33bf..7b8c5e0623 100644
--- a/src/xrpld/rpc/detail/DeliveredAmount.cpp
+++ b/src/xrpld/rpc/detail/DeliveredAmount.cpp
@@ -16,7 +16,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 /*
   GetLedgerIndex and GetCloseTime are lambdas that allow the close time and
@@ -114,7 +114,7 @@ insertDeliveredAmount(
 template 
 static std::optional
 getDeliveredAmount(
-    RPC::Context const& context,
+    rpc::Context const& context,
     std::shared_ptr const& serializedTx,
     TxMeta const& transactionMeta,
     GetLedgerIndex const& getLedgerIndex)
@@ -133,7 +133,7 @@ getDeliveredAmount(
 
 std::optional
 getDeliveredAmount(
-    RPC::Context const& context,
+    rpc::Context const& context,
     std::shared_ptr const& serializedTx,
     TxMeta const& transactionMeta,
     LedgerIndex const& ledgerIndex)
@@ -145,7 +145,7 @@ getDeliveredAmount(
 void
 insertDeliveredAmount(
     json::Value& meta,
-    RPC::JsonContext const& context,
+    rpc::JsonContext const& context,
     std::shared_ptr const& transaction,
     TxMeta const& transactionMeta)
 {
@@ -155,7 +155,7 @@ insertDeliveredAmount(
 void
 insertDeliveredAmount(
     json::Value& meta,
-    RPC::JsonContext const& context,
+    rpc::JsonContext const& context,
     std::shared_ptr const& transaction,
     TxMeta const& transactionMeta)
 {
@@ -178,4 +178,4 @@ insertDeliveredAmount(
     }
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/Handler.cpp b/src/xrpld/rpc/detail/Handler.cpp
index 4f5ce34c1f..326af4f4ee 100644
--- a/src/xrpld/rpc/detail/Handler.cpp
+++ b/src/xrpld/rpc/detail/Handler.cpp
@@ -18,7 +18,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 namespace {
 
 /**
@@ -33,8 +33,8 @@ byRef(Function const& f)
         if (result.type() != json::ValueType::Object)
         {
             // LCOV_EXCL_START
-            UNREACHABLE("xrpl::RPC::byRef : result is object");
-            result = RPC::makeObjectValue(result);
+            UNREACHABLE("xrpl::rpc::byRef : result is object");
+            result = rpc::makeObjectValue(result);
             // LCOV_EXCL_STOP
         }
 
@@ -49,7 +49,7 @@ handle(JsonContext& context, Object& object)
     XRPL_ASSERT(
         context.apiVersion >= HandlerImpl::minApiVer &&
             context.apiVersion <= HandlerImpl::maxApiVer,
-        "xrpl::RPC::handle : valid API version");
+        "xrpl::rpc::handle : valid API version");
     HandlerImpl handler(context);
 
     auto status = handler.check();
@@ -382,10 +382,10 @@ private:
         unsigned minVer,
         unsigned maxVer)
     {
-        XRPL_ASSERT(minVer <= maxVer, "xrpl::RPC::HandlerTable : valid API version range");
+        XRPL_ASSERT(minVer <= maxVer, "xrpl::rpc::HandlerTable : valid API version range");
         XRPL_ASSERT(
-            maxVer <= RPC::kApiMaximumValidVersion,
-            "xrpl::RPC::HandlerTable : valid max API version");
+            maxVer <= rpc::kApiMaximumValidVersion,
+            "xrpl::rpc::HandlerTable : valid max API version");
 
         return std::any_of(
             range.first,
@@ -427,8 +427,8 @@ public:
     [[nodiscard]] Handler const*
     getHandler(unsigned version, bool betaEnabled, std::string const& name) const
     {
-        if (version < RPC::kApiMinimumSupportedVersion ||
-            version > (betaEnabled ? RPC::kApiBetaVersion : RPC::kApiMaximumSupportedVersion))
+        if (version < rpc::kApiMinimumSupportedVersion ||
+            version > (betaEnabled ? rpc::kApiBetaVersion : rpc::kApiMaximumSupportedVersion))
             return nullptr;
 
         auto const range = table_.equal_range(name);
@@ -457,8 +457,8 @@ private:
     addHandler()
     {
         static_assert(HandlerImpl::minApiVer <= HandlerImpl::maxApiVer);
-        static_assert(HandlerImpl::maxApiVer <= RPC::kApiMaximumValidVersion);
-        static_assert(RPC::kApiMinimumSupportedVersion <= HandlerImpl::minApiVer);
+        static_assert(HandlerImpl::maxApiVer <= rpc::kApiMaximumValidVersion);
+        static_assert(rpc::kApiMinimumSupportedVersion <= HandlerImpl::minApiVer);
 
         if (overlappingApiVersion(
                 table_.equal_range(HandlerImpl::name),
@@ -488,4 +488,4 @@ getHandlerNames()
     return HandlerTable::instance().getHandlerNames();
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/Handler.h b/src/xrpld/rpc/detail/Handler.h
index 37259c8648..7342c5fcbf 100644
--- a/src/xrpld/rpc/detail/Handler.h
+++ b/src/xrpld/rpc/detail/Handler.h
@@ -20,7 +20,7 @@ namespace json {
 class Object;
 }  // namespace json
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 // Under what condition can we call this RPC?
 enum class Condition {
@@ -38,7 +38,7 @@ struct Handler
     char const* name;
     Method valueMethod;
     Role role;
-    RPC::Condition condition;
+    rpc::Condition condition;
 
     unsigned minApiVer = kApiMinimumSupportedVersion;
     unsigned maxApiVer = kApiMaximumValidVersion;
@@ -92,7 +92,7 @@ conditionMet(Condition conditionRequired, T& context)
 
     if (!context.app.config().standalone() && conditionRequired != Condition::NoCondition)
     {
-        if (context.ledgerMaster.getValidatedLedgerAge() > Tuning::kMaxValidatedLedgerAge)
+        if (context.ledgerMaster.getValidatedLedgerAge() > tuning::kMaxValidatedLedgerAge)
         {
             if (context.apiVersion == 1)
                 return RpcNoCurrent;
@@ -122,4 +122,4 @@ conditionMet(Condition conditionRequired, T& context)
     return RpcSuccess;
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/LegacyPathFind.cpp b/src/xrpld/rpc/detail/LegacyPathFind.cpp
index 0bfa19a1f4..837d98084e 100644
--- a/src/xrpld/rpc/detail/LegacyPathFind.cpp
+++ b/src/xrpld/rpc/detail/LegacyPathFind.cpp
@@ -9,7 +9,7 @@
 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 LegacyPathFind::LegacyPathFind(bool isAdmin, Application& app)
 {
@@ -21,13 +21,13 @@ LegacyPathFind::LegacyPathFind(bool isAdmin, Application& app)
     }
 
     auto const& jobCount = app.getJobQueue().getJobCountGE(JtClient);
-    if (jobCount > Tuning::kMaxPathfindJobCount || app.getFeeTrack().isLoadedLocal())
+    if (jobCount > tuning::kMaxPathfindJobCount || app.getFeeTrack().isLoadedLocal())
         return;
 
     while (true)
     {
         int prevVal = inProgress.load();
-        if (prevVal >= Tuning::kMaxPathfindsInProgress)
+        if (prevVal >= tuning::kMaxPathfindsInProgress)
             return;
 
         if (inProgress.compare_exchange_strong(
@@ -47,4 +47,4 @@ LegacyPathFind::~LegacyPathFind()
 
 std::atomic LegacyPathFind::inProgress(0);
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/LegacyPathFind.h b/src/xrpld/rpc/detail/LegacyPathFind.h
index 226191848f..30e176f245 100644
--- a/src/xrpld/rpc/detail/LegacyPathFind.h
+++ b/src/xrpld/rpc/detail/LegacyPathFind.h
@@ -6,7 +6,7 @@ namespace xrpl {
 
 class Application;
 
-namespace RPC {
+namespace rpc {
 
 class LegacyPathFind
 {
@@ -26,5 +26,5 @@ private:
     bool isOk_{false};
 };
 
-}  // namespace RPC
+}  // namespace rpc
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp b/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp
index e34980aee2..4f57bab9ab 100644
--- a/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp
+++ b/src/xrpld/rpc/detail/MPTokenIssuanceID.cpp
@@ -16,7 +16,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 bool
 canHaveMPTokenIssuanceID(
@@ -67,4 +67,4 @@ insertMPTokenIssuanceID(
         response[jss::mpt_issuance_id] = to_string(result.value());
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp
index 3c09917dad..fb132199bc 100644
--- a/src/xrpld/rpc/detail/PathRequest.cpp
+++ b/src/xrpld/rpc/detail/PathRequest.cpp
@@ -73,7 +73,7 @@ PathRequest::PathRequest(
 PathRequest::PathRequest(
     Application& app,
     std::function completion,
-    Resource::Consumer& consumer,
+    resource::Consumer& consumer,
     int id,
     PathRequestManager& owner,
     beast::Journal journal)
@@ -344,7 +344,7 @@ PathRequest::parseJson(json::Value const& jvParams)
     {
         json::Value const& jvSrcCurrencies = jvParams[jss::source_currencies];
         if (!jvSrcCurrencies.isArray() || jvSrcCurrencies.size() == 0 ||
-            jvSrcCurrencies.size() > RPC::Tuning::kMaxSrcCur)
+            jvSrcCurrencies.size() > rpc::tuning::kMaxSrcCur)
         {
             jvStatus_ = rpcError(RpcSrcCurMalformed);
             return PFR_PJ_INVALID;
@@ -556,7 +556,7 @@ PathRequest::findPaths(
                     [&](TAsset const& a) {
                         if (!sameAccount || a != saDstAmount_.asset())
                         {
-                            if (sourceAssets.size() >= RPC::Tuning::kMaxAutoSrcCur)
+                            if (sourceAssets.size() >= rpc::tuning::kMaxAutoSrcCur)
                                 return false;
                             if constexpr (std::is_same_v)
                             {
diff --git a/src/xrpld/rpc/detail/PathRequest.h b/src/xrpld/rpc/detail/PathRequest.h
index d40d9c82d6..f56b3d0652 100644
--- a/src/xrpld/rpc/detail/PathRequest.h
+++ b/src/xrpld/rpc/detail/PathRequest.h
@@ -64,7 +64,7 @@ public:
     PathRequest(
         Application& app,
         std::function completion,
-        Resource::Consumer& consumer,
+        resource::Consumer& consumer,
         int id,
         PathRequestManager&,
         beast::Journal journal);
@@ -137,7 +137,7 @@ private:
 
     std::weak_ptr wpSubscriber_;  // Who this request came from
     std::function fCompletion_;
-    Resource::Consumer& consumer_;  // Charge according to source currencies
+    resource::Consumer& consumer_;  // Charge according to source currencies
 
     json::Value jvId_;
     json::Value jvStatus_;  // Last result
diff --git a/src/xrpld/rpc/detail/PathRequestManager.cpp b/src/xrpld/rpc/detail/PathRequestManager.cpp
index 4953634181..117bfbda1e 100644
--- a/src/xrpld/rpc/detail/PathRequestManager.cpp
+++ b/src/xrpld/rpc/detail/PathRequestManager.cpp
@@ -254,7 +254,7 @@ json::Value
 PathRequestManager::makeLegacyPathRequest(
     PathRequest::pointer& req,
     std::function completion,
-    Resource::Consumer& consumer,
+    resource::Consumer& consumer,
     std::shared_ptr const& inLedger,
     json::Value const& request)
 {
@@ -285,7 +285,7 @@ PathRequestManager::makeLegacyPathRequest(
 
 json::Value
 PathRequestManager::doLegacyPathRequest(
-    Resource::Consumer& consumer,
+    resource::Consumer& consumer,
     std::shared_ptr const& inLedger,
     json::Value const& request)
 {
diff --git a/src/xrpld/rpc/detail/PathRequestManager.h b/src/xrpld/rpc/detail/PathRequestManager.h
index f6eb80d291..29a80e66c0 100644
--- a/src/xrpld/rpc/detail/PathRequestManager.h
+++ b/src/xrpld/rpc/detail/PathRequestManager.h
@@ -65,7 +65,7 @@ public:
     makeLegacyPathRequest(
         PathRequest::pointer& req,
         std::function completion,
-        Resource::Consumer& consumer,
+        resource::Consumer& consumer,
         std::shared_ptr const& inLedger,
         json::Value const& request);
 
@@ -73,7 +73,7 @@ public:
     // with the ledger specified by the caller
     json::Value
     doLegacyPathRequest(
-        Resource::Consumer& consumer,
+        resource::Consumer& consumer,
         std::shared_ptr const& inLedger,
         json::Value const& request);
 
diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp
index 5b7f1415a2..642b5c4253 100644
--- a/src/xrpld/rpc/detail/Pathfinder.cpp
+++ b/src/xrpld/rpc/detail/Pathfinder.cpp
@@ -962,14 +962,10 @@ Pathfinder::isNoRippleOut(STPath const& currentPath)
 void
 addUniquePath(STPathSet& pathSet, STPath const& path)
 {
-    // TODO(tom): building an STPathSet this way is quadratic in the size
-    // of the STPathSet!
-    for (auto const& p : pathSet)
+    if (!pathSet.contains(path))
     {
-        if (p == path)
-            return;
+        pathSet.pushBack(path);
     }
-    pathSet.pushBack(path);
 }
 
 void
diff --git a/src/xrpld/rpc/detail/RPCCall.cpp b/src/xrpld/rpc/detail/RPCCall.cpp
index b5d5c680cd..a752858527 100644
--- a/src/xrpld/rpc/detail/RPCCall.cpp
+++ b/src/xrpld/rpc/detail/RPCCall.cpp
@@ -74,7 +74,7 @@ createHTTPPost(
 
     // CHECKME this uses a different version than the replies below use. Is
     //         this by design or an accident or should it be using
-    //         BuildInfo::getFullVersionString () as well?
+    //         build_info::getFullVersionString () as well?
 
     s << "POST " << (strPath.empty() ? "/" : strPath) << " HTTP/1.0\r\n"
       << "User-Agent: " << systemName() << "-json-rpc/v1\r\n"
@@ -149,7 +149,7 @@ private:
             return jvResult;
         }
 
-        return RPC::makeParamError(
+        return rpc::makeParamError(
             std::string("Invalid currency/issuer '") + strCurrencyIssuer + "'");
     }
 
@@ -355,7 +355,7 @@ private:
             }
             catch (std::exception const&)
             {
-                return RPC::invalidFieldError(jss::limit);
+                return rpc::invalidFieldError(jss::limit);
             }
         }
 
@@ -369,7 +369,7 @@ private:
             }
             catch (std::exception const&)
             {
-                return RPC::invalidFieldError(jss::proof);
+                return rpc::invalidFieldError(jss::proof);
             }
         }
 
@@ -1182,7 +1182,7 @@ private:
 
         std::string param = jvParams[index++].asString();
         if (param.empty())
-            return RPC::makeParamError("Invalid first parameter");
+            return rpc::makeParamError("Invalid first parameter");
 
         if (param[0] != 'r')
         {
@@ -1196,7 +1196,7 @@ private:
             }
 
             if (size <= index)
-                return RPC::makeParamError("Invalid hotwallet");
+                return rpc::makeParamError("Invalid hotwallet");
 
             param = jvParams[index++].asString();
         }
@@ -1726,7 +1726,7 @@ rpcClient(
 
             {
                 boost::asio::io_context isService;
-                RPCCall::fromNetwork(
+                rpc_call::fromNetwork(
                     isService,
                     setup.client.ip,
                     setup.client.port,
@@ -1813,12 +1813,12 @@ rpcClient(
 
 //------------------------------------------------------------------------------
 
-namespace RPCCall {
+namespace rpc_call {
 
 int
 fromCommandLine(Config const& config, std::vector const& vCmd, Logs& logs)
 {
-    auto const result = rpcClient(vCmd, config, logs, RPC::kApiCommandLineVersion);
+    auto const result = rpcClient(vCmd, config, logs, rpc::kApiCommandLineVersion);
 
     std::cout << result.second.toStyledString();
 
@@ -1883,6 +1883,6 @@ fromNetwork(
         j);
 }
 
-}  // namespace RPCCall
+}  // namespace rpc_call
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/RPCHandler.cpp b/src/xrpld/rpc/detail/RPCHandler.cpp
index 6f46aed62d..96d6bf72d7 100644
--- a/src/xrpld/rpc/detail/RPCHandler.cpp
+++ b/src/xrpld/rpc/detail/RPCHandler.cpp
@@ -24,7 +24,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 namespace {
 
@@ -114,7 +114,7 @@ fillHandler(JsonContext& context, Handler const*& result)
     {
         // Count all jobs at jtCLIENT priority or higher.
         int const jobCount = context.app.getJobQueue().getJobCountGE(JtClient);
-        if (jobCount > Tuning::kMaxJobQueueClients)
+        if (jobCount > tuning::kMaxJobQueueClients)
         {
             JLOG(context.j.debug()) << "Too busy for command: " << jobCount;
             return RpcTooBusy;
@@ -179,8 +179,8 @@ callMethod(JsonContext& context, Method method, std::string const& name, Object&
         perfLog.rpcError(name, curId);
         JLOG(context.j.info()) << "Caught throw: " << e.what();
 
-        if (context.loadType == Resource::kFeeReferenceRpc)
-            context.loadType = Resource::kFeeExceptionRpc;
+        if (context.loadType == resource::kFeeReferenceRpc)
+            context.loadType = resource::kFeeExceptionRpc;
 
         injectError(RpcInternal, result);
         return RpcInternal;
@@ -190,7 +190,7 @@ callMethod(JsonContext& context, Method method, std::string const& name, Object&
 }  // namespace
 
 Status
-doCommand(RPC::JsonContext& context, json::Value& result)
+doCommand(rpc::JsonContext& context, json::Value& result)
 {
     Handler const* handler = nullptr;
     if (auto error = fillHandler(context, handler))
@@ -226,7 +226,7 @@ doCommand(RPC::JsonContext& context, json::Value& result)
 Role
 roleRequired(unsigned int version, bool betaEnabled, std::string const& method)
 {
-    auto handler = RPC::getHandler(version, betaEnabled, method);
+    auto handler = rpc::getHandler(version, betaEnabled, method);
 
     if (handler == nullptr)
         return Role::FORBID;
@@ -234,4 +234,4 @@ roleRequired(unsigned int version, bool betaEnabled, std::string const& method)
     return handler->role;
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp
index 1764375812..321f8f5a3c 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCHelpers.cpp
@@ -37,12 +37,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 std::uint64_t
 getStartHint(SLE::const_ref sle, AccountID const& accountID)
@@ -116,7 +117,7 @@ parseAccountIds(json::Value const& jvArray)
 }
 
 std::optional
-readLimitField(unsigned int& limit, Tuning::LimitRange const& range, JsonContext const& context)
+readLimitField(unsigned int& limit, tuning::LimitRange const& range, JsonContext const& context)
 {
     limit = range.rDefault;
     if (!context.params.isMember(jss::limit) || context.params[jss::limit].isNull())
@@ -124,11 +125,11 @@ readLimitField(unsigned int& limit, Tuning::LimitRange const& range, JsonContext
 
     auto const& jvLimit = context.params[jss::limit];
     if (!jvLimit.isUInt() && (!jvLimit.isInt() || jvLimit.asInt() < 0))
-        return RPC::expectedFieldError(jss::limit, "unsigned integer");
+        return rpc::expectedFieldError(jss::limit, "unsigned integer");
 
     limit = jvLimit.asUInt();
     if (limit == 0)
-        return RPC::invalidFieldError(jss::limit);
+        return rpc::invalidFieldError(jss::limit);
 
     if (!isUnlimited(context.role))
         limit = std::max(range.rmin, std::min(range.rmax, limit));
@@ -184,7 +185,7 @@ getSeedFromRPC(json::Value const& params, json::Value& error)
 
     if (count != 1)
     {
-        error = RPC::makeParamError(
+        error = rpc::makeParamError(
             "Exactly one of the following must be specified: " + std::string(jss::passphrase) +
             ", " + std::string(jss::seed) + " or " + std::string(jss::seed_hex));
         return std::nullopt;
@@ -194,7 +195,7 @@ getSeedFromRPC(json::Value const& params, json::Value& error)
     auto const& param = params[seedType->first];
     if (!param.isString())
     {
-        error = RPC::expectedFieldError(seedType->first, "string");
+        error = rpc::expectedFieldError(seedType->first, "string");
         return std::nullopt;
     }
 
@@ -232,13 +233,13 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
 
     if (count == 0 || secretType == nullptr)
     {
-        error = RPC::missingFieldError(jss::secret);
+        error = rpc::missingFieldError(jss::secret);
         return {};
     }
 
     if (count > 1)
     {
-        error = RPC::makeParamError(
+        error = rpc::makeParamError(
             "Exactly one of the following must be specified: " + std::string(jss::passphrase) +
             ", " + std::string(jss::secret) + ", " + std::string(jss::seed) + " or " +
             std::string(jss::seed_hex));
@@ -252,7 +253,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
     {
         if (!params[jss::key_type].isString())
         {
-            error = RPC::expectedFieldError(jss::key_type, "string");
+            error = rpc::expectedFieldError(jss::key_type, "string");
             return {};
         }
 
@@ -262,11 +263,11 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
         {
             if (apiVersion > 1u)
             {
-                error = RPC::makeError(RpcBadKeyType);
+                error = rpc::makeError(RpcBadKeyType);
             }
             else
             {
-                error = RPC::invalidFieldError(jss::key_type);
+                error = rpc::invalidFieldError(jss::key_type);
             }
             return {};
         }
@@ -275,7 +276,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
         // https://developercommunity.visualstudio.com/t/assigning-constexpr-char--to-static-cha/10021357?entry=problem)
         if (strcmp(secretType, jss::secret.cStr()) == 0)
         {
-            error = RPC::makeParamError(
+            error = rpc::makeParamError(
                 "The secret field is not allowed if " + std::string(jss::key_type) + " is used.");
             return {};
         }
@@ -288,7 +289,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
     // https://developercommunity.visualstudio.com/t/assigning-constexpr-char--to-static-cha/10021357?entry=problem)
     if (strcmp(secretType, jss::seed_hex.cStr()) != 0)
     {
-        seed = RPC::parseXrplLibSeed(params[secretType]);
+        seed = rpc::parseXrplLibSeed(params[secretType]);
 
         if (seed)
         {
@@ -296,7 +297,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
             // requested another key type, return an error.
             if (keyType.value_or(KeyType::Ed25519) != KeyType::Ed25519)
             {
-                error = RPC::makeError(RpcBadSeed, "Specified seed is for an Ed25519 wallet.");
+                error = rpc::makeError(RpcBadSeed, "Specified seed is for an Ed25519 wallet.");
                 return {};
             }
 
@@ -317,7 +318,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
         {
             if (!params[jss::secret].isString())
             {
-                error = RPC::expectedFieldError(jss::secret, "string");
+                error = rpc::expectedFieldError(jss::secret, "string");
                 return {};
             }
 
@@ -329,7 +330,7 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
     {
         if (!containsError(error))
         {
-            error = RPC::makeError(RpcBadSeed, RPC::invalidFieldMessage(secretType));
+            error = rpc::makeError(RpcBadSeed, rpc::invalidFieldMessage(secretType));
         }
 
         return {};
@@ -341,10 +342,10 @@ keypairForSignature(json::Value const& params, json::Value& error, unsigned int
     return generateKeyPair(*keyType, *seed);
 }
 
-std::pair
+std::pair
 chooseLedgerEntryType(json::Value const& params)
 {
-    std::pair result{RPC::Status::kOK, ltANY};
+    std::pair result{rpc::Status::kOK, ltANY};
     if (params.isMember(jss::type))
     {
         static constexpr auto kTypes =
@@ -363,10 +364,10 @@ chooseLedgerEntryType(json::Value const& params)
         auto const& p = params[jss::type];
         if (!p.isString())
         {
-            result.first = RPC::Status{RpcInvalidParams, "Invalid field 'type', not string."};
+            result.first = rpc::Status{RpcInvalidParams, "Invalid field 'type', not string."};
             XRPL_ASSERT(
-                result.first.type() == RPC::Status::Type::ErrorCodeI,
-                "xrpl::RPC::chooseLedgerEntryType : first valid result type");
+                result.first.type() == rpc::Status::Type::ErrorCodeI,
+                "xrpl::rpc::chooseLedgerEntryType : first valid result type");
             return result;
         }
 
@@ -379,10 +380,10 @@ chooseLedgerEntryType(json::Value const& params)
         });
         if (iter == kTypes.end())
         {
-            result.first = RPC::Status{RpcInvalidParams, "Invalid field 'type'."};
+            result.first = rpc::Status{RpcInvalidParams, "Invalid field 'type'."};
             XRPL_ASSERT(
-                result.first.type() == RPC::Status::Type::ErrorCodeI,
-                "xrpl::RPC::chooseLedgerEntryType : second valid result "
+                result.first.type() == rpc::Status::Type::ErrorCodeI,
+                "xrpl::rpc::chooseLedgerEntryType : second valid result "
                 "type");
             return result;
         }
@@ -424,7 +425,7 @@ parseSubUnsubJson(
     if (jv.isMember(jss::mpt_issuance_id) &&
         (jv.isMember(jss::currency) || jv.isMember(jss::issuer)))
     {
-        JLOG(j.info()) << boost::format("Bad %s currency or MPT.") % name.cStr();
+        JLOG(j.info()) << std::format("Bad {} currency or MPT.", name.cStr());
         return RpcInvalidParams;
     }
 
@@ -435,7 +436,7 @@ parseSubUnsubJson(
         if (!jv.isMember(jss::currency) ||
             !toCurrency(issue.currency, jv[jss::currency].asString()))
         {
-            JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr();
+            JLOG(j.info()) << std::format("Bad {} currency.", name.cStr());
             return assetError;
         }
 
@@ -445,7 +446,7 @@ parseSubUnsubJson(
             // Don't allow illegal issuers.
             || (!issue.currency != !issue.account) || noAccount() == issue.account)
         {
-            JLOG(j.info()) << boost::format("Bad %s issuer.") % name.cStr();
+            JLOG(j.info()) << std::format("Bad {} issuer.", name.cStr());
             return issuerError;
         }
         asset = issue;
@@ -459,11 +460,11 @@ parseSubUnsubJson(
     }
     else
     {
-        JLOG(j.info()) << boost::format("Neither %s currency or MPT is present.") % name.cStr();
+        JLOG(j.info()) << std::format("Neither {} currency or MPT is present.", name.cStr());
         return assetError;
     }
 
     return RpcSuccess;
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/RPCHelpers.h b/src/xrpld/rpc/detail/RPCHelpers.h
index 881b758487..24c06021c0 100644
--- a/src/xrpld/rpc/detail/RPCHelpers.h
+++ b/src/xrpld/rpc/detail/RPCHelpers.h
@@ -27,7 +27,7 @@ namespace xrpl {
 
 class ReadView;
 
-namespace RPC {
+namespace rpc {
 
 struct JsonContext;
 
@@ -85,7 +85,7 @@ parseAccountIds(json::Value const& jvArray);
  * std::nullopt on success.
  */
 std::optional
-readLimitField(unsigned int& limit, Tuning::LimitRange const& range, JsonContext const& context);
+readLimitField(unsigned int& limit, tuning::LimitRange const& range, JsonContext const& context);
 
 /**
  * @brief Extracts a Seed from RPC parameters.
@@ -123,7 +123,7 @@ parseXrplLibSeed(json::Value const& params);
  * @param params The JSON value containing RPC parameters.
  * @return A pair consisting of the RPC status and the chosen LedgerEntryType.
  */
-std::pair
+std::pair
 chooseLedgerEntryType(json::Value const& params);
 
 /**
@@ -172,6 +172,6 @@ parseSubUnsubJson(
     json::StaticString const& name,
     beast::Journal j);
 
-}  // namespace RPC
+}  // namespace rpc
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
index 6843c34b19..19fe294924 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp
@@ -30,7 +30,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 namespace {
 
@@ -40,7 +40,7 @@ isValidatedOld(LedgerMaster& ledgerMaster, bool standalone)
     if (standalone)
         return false;
 
-    return ledgerMaster.getValidatedLedgerAge() > Tuning::kMaxValidatedLedgerAge;
+    return ledgerMaster.getValidatedLedgerAge() > tuning::kMaxValidatedLedgerAge;
 }
 
 template 
@@ -282,19 +282,19 @@ getLedger(T& ledger, LedgerShortcut shortcut, Context const& context)
             return {RpcNotSynced, "notSynced"};
         }
 
-        XRPL_ASSERT(!ledger->open(), "xrpl::RPC::getLedger : validated is not open");
+        XRPL_ASSERT(!ledger->open(), "xrpl::rpc::getLedger : validated is not open");
     }
     else
     {
         if (shortcut == LedgerShortcut::Current)
         {
             ledger = context.ledgerMaster.getCurrentLedger();
-            XRPL_ASSERT(ledger->open(), "xrpl::RPC::getLedger : current is open");
+            XRPL_ASSERT(ledger->open(), "xrpl::rpc::getLedger : current is open");
         }
         else if (shortcut == LedgerShortcut::Closed)
         {
             ledger = context.ledgerMaster.getClosedLedger();
-            XRPL_ASSERT(!ledger->open(), "xrpl::RPC::getLedger : closed is not open");
+            XRPL_ASSERT(!ledger->open(), "xrpl::rpc::getLedger : closed is not open");
         }
         else
         {
@@ -331,6 +331,13 @@ getLedger<>(std::shared_ptr&, LedgerShortcut shortcut, Context c
 template Status
 getLedger<>(std::shared_ptr&, uint256 const&, Context const&);
 
+// explicit instantiation of ledgerFromSpecifier
+template Status
+ledgerFromSpecifier<>(
+    std::shared_ptr&,
+    org::xrpl::rpc::v1::LedgerSpecifier const&,
+    Context const&);
+
 // The previous version of the lookupLedger command would accept the
 // "ledger_index" argument as a string and silently treat it as a request to
 // return the current ledger which, while not strictly wrong, could cause a lot
@@ -386,7 +393,7 @@ lookupLedger(std::shared_ptr& ledger, JsonContext const& context
 }
 
 std::expected, json::Value>
-getOrAcquireLedger(RPC::JsonContext const& context)
+getOrAcquireLedger(rpc::JsonContext const& context)
 {
     auto const hasHash = context.params.isMember(jss::ledger_hash);
     auto const hasIndex = context.params.isMember(jss::ledger_index);
@@ -398,7 +405,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
     if ((static_cast(hasHash) + static_cast(hasIndex)) != 1)
     {
         return std::unexpected(
-            RPC::makeParamError(
+            rpc::makeParamError(
                 "Exactly one of 'ledger_hash' or "
                 "'ledger_index' can be specified."));
     }
@@ -407,16 +414,16 @@ getOrAcquireLedger(RPC::JsonContext const& context)
     {
         auto const& jsonHash = context.params.get(jss::ledger_hash, json::ValueType::Null);
         if (!jsonHash.isString() || !ledgerHash.parseHex(jsonHash.asString()))
-            return std::unexpected(RPC::expectedFieldError(jss::ledger_hash, "hex string"));
+            return std::unexpected(rpc::expectedFieldError(jss::ledger_hash, "hex string"));
     }
     else
     {
         auto const& jsonIndex = context.params.get(jss::ledger_index, json::ValueType::Null);
         if (!jsonIndex.isInt() && !jsonIndex.isUInt())
-            return std::unexpected(RPC::expectedFieldError(jss::ledger_index, "number"));
+            return std::unexpected(rpc::expectedFieldError(jss::ledger_index, "number"));
 
         // We need a validated ledger to get the hash from the sequence
-        if (ledgerMaster.getValidatedLedgerAge() > RPC::Tuning::kMaxValidatedLedgerAge)
+        if (ledgerMaster.getValidatedLedgerAge() > rpc::tuning::kMaxValidatedLedgerAge)
         {
             if (context.apiVersion == 1)
                 return std::unexpected(rpcError(RpcNoCurrent));
@@ -427,9 +434,9 @@ getOrAcquireLedger(RPC::JsonContext const& context)
         auto ledger = ledgerMaster.getValidatedLedger();
 
         if (ledgerIndex >= ledger->header().seq)
-            return std::unexpected(RPC::makeParamError("Ledger index too large"));
+            return std::unexpected(rpc::makeParamError("Ledger index too large"));
         if (ledgerIndex <= 0)
-            return std::unexpected(RPC::makeParamError("Ledger index too small"));
+            return std::unexpected(rpc::makeParamError("Ledger index too small"));
 
         auto const j = context.app.getJournal("RPCHandler");
         // Try to get the hash of the desired ledger from the validated
@@ -441,7 +448,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
             // ledger
             auto const refIndex = getCandidateLedger(ledgerIndex);
             auto refHash = hashOfSeq(*ledger, refIndex, j);
-            XRPL_ASSERT(refHash, "xrpl::RPC::getOrAcquireLedger : nonzero ledger hash");
+            XRPL_ASSERT(refHash, "xrpl::rpc::getOrAcquireLedger : nonzero ledger hash");
 
             // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above
             ledger = ledgerMaster.getLedgerByHash(*refHash);
@@ -453,7 +460,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
                 if (auto il = context.app.getInboundLedgers().acquire(
                         *refHash, refIndex, InboundLedger::Reason::GENERIC))
                 {
-                    json::Value jvResult = RPC::makeError(
+                    json::Value jvResult = rpc::makeError(
                         RpcLgrNotFound, "acquiring ledger containing requested index");
                     jvResult[jss::acquiring] = getJson(LedgerFill(*il, &context));
                     return std::unexpected(jvResult);
@@ -462,7 +469,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
                 if (auto il = context.app.getInboundLedgers().find(*refHash))
                 // NOLINTEND(bugprone-unchecked-optional-access)
                 {
-                    json::Value jvResult = RPC::makeError(
+                    json::Value jvResult = rpc::makeError(
                         RpcLgrNotFound, "acquiring ledger containing requested index");
                     jvResult[jss::acquiring] = il->getJson(0);
                     return std::unexpected(jvResult);
@@ -474,7 +481,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
 
             neededHash = hashOfSeq(*ledger, ledgerIndex, j);
         }
-        XRPL_ASSERT(neededHash, "xrpl::RPC::getOrAcquireLedger : nonzero needed hash");
+        XRPL_ASSERT(neededHash, "xrpl::rpc::getOrAcquireLedger : nonzero needed hash");
         ledgerHash = neededHash ? *neededHash : beast::kZero;  // kludge
     }
 
@@ -494,7 +501,7 @@ getOrAcquireLedger(RPC::JsonContext const& context)
         return std::unexpected(il->getJson(0));
 
     return std::unexpected(
-        RPC::makeError(RpcNotReady, "findCreate failed to return an inbound ledger"));
+        rpc::makeError(RpcNotReady, "findCreate failed to return an inbound ledger"));
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.h b/src/xrpld/rpc/detail/RPCLedgerHelpers.h
index cbd47d38e6..cad5141917 100644
--- a/src/xrpld/rpc/detail/RPCLedgerHelpers.h
+++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.h
@@ -21,7 +21,7 @@ namespace xrpl {
 class ReadView;
 class Transaction;
 
-namespace RPC {
+namespace rpc {
 
 struct JsonContext;
 
@@ -172,8 +172,8 @@ ledgerFromSpecifier(
  *         On failure, contains a json::Value describing the error.
  */
 std::expected, json::Value>
-getOrAcquireLedger(RPC::JsonContext const& context);
+getOrAcquireLedger(rpc::JsonContext const& context);
 
-}  // namespace RPC
+}  // namespace rpc
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/RPCSub.cpp b/src/xrpld/rpc/detail/RPCSub.cpp
index 92e9849939..8cea8b6bdd 100644
--- a/src/xrpld/rpc/detail/RPCSub.cpp
+++ b/src/xrpld/rpc/detail/RPCSub.cpp
@@ -75,7 +75,7 @@ public:
         }
         path_ = pUrl.path;
 
-        JLOG(j_.info()) << "RPCCall::fromNetwork sub: ip=" << ip_ << " port=" << port_
+        JLOG(j_.info()) << "rpc_call::fromNetwork sub: ip=" << ip_ << " port=" << port_
                         << " ssl= " << (ssl_ ? "yes" : "no") << " path='" << path_ << "'";
     }
 
@@ -87,14 +87,14 @@ public:
         std::scoped_lock const sl(lock_);
 
         auto jm = broadcast ? j_.debug() : j_.info();
-        JLOG(jm) << "RPCCall::fromNetwork push: " << jvObj;
+        JLOG(jm) << "rpc_call::fromNetwork push: " << jvObj;
 
         deque_.emplace_back(seq_++, jvObj);
 
         if (!sending_)
         {
             // Start a sending thread.
-            JLOG(j_.info()) << "RPCCall::fromNetwork start";
+            JLOG(j_.info()) << "rpc_call::fromNetwork start";
 
             sending_ =
                 jobQueue_.addJob(JtClientSubscribe, "RPCSubSendThr", [this]() { sendThread(); });
@@ -156,9 +156,9 @@ private:
                 // XXX Might not need this in a try.
                 try
                 {
-                    JLOG(j_.info()) << "RPCCall::fromNetwork: " << ip_;
+                    JLOG(j_.info()) << "rpc_call::fromNetwork: " << ip_;
 
-                    RPCCall::fromNetwork(
+                    rpc_call::fromNetwork(
                         ioContext_,
                         ip_,
                         port_,
@@ -173,7 +173,7 @@ private:
                 }
                 catch (std::exception const& e)
                 {
-                    JLOG(j_.info()) << "RPCCall::fromNetwork exception: " << e.what();
+                    JLOG(j_.info()) << "rpc_call::fromNetwork exception: " << e.what();
                 }
             }
         } while (bSend);
diff --git a/src/xrpld/rpc/detail/Role.cpp b/src/xrpld/rpc/detail/Role.cpp
index 68c5fcc484..34970b0580 100644
--- a/src/xrpld/rpc/detail/Role.cpp
+++ b/src/xrpld/rpc/detail/Role.cpp
@@ -40,7 +40,7 @@ passwordUnrequiredOrSentCorrect(Port const& port, json::Value const& params)
 
 bool
 ipAllowed(
-    beast::IP::Address const& remoteIp,
+    beast::ip::Address const& remoteIp,
     std::vector const& nets4,
     std::vector const& nets6)
 {
@@ -78,7 +78,7 @@ ipAllowed(
 }
 
 bool
-isAdmin(Port const& port, json::Value const& params, beast::IP::Address const& remoteIp)
+isAdmin(Port const& port, json::Value const& params, beast::ip::Address const& remoteIp)
 {
     return ipAllowed(remoteIp, port.adminNetsV4, port.adminNetsV6) &&
         passwordUnrequiredOrSentCorrect(port, params);
@@ -89,7 +89,7 @@ requestRole(
     Role const& required,
     Port const& port,
     json::Value const& params,
-    beast::IP::Endpoint const& remoteIp,
+    beast::ip::Endpoint const& remoteIp,
     std::string_view user)
 {
     if (isAdmin(port, params, remoteIp.address()))
@@ -122,16 +122,16 @@ isUnlimited(
     Role const& required,
     Port const& port,
     json::Value const& params,
-    beast::IP::Endpoint const& remoteIp,
+    beast::ip::Endpoint const& remoteIp,
     std::string const& user)
 {
     return isUnlimited(requestRole(required, port, params, remoteIp, user));
 }
 
-Resource::Consumer
+resource::Consumer
 requestInboundEndpoint(
-    Resource::Manager& manager,
-    beast::IP::Endpoint const& remoteAddress,
+    resource::Manager& manager,
+    beast::ip::Endpoint const& remoteAddress,
     Role const& role,
     std::string_view user,
     std::string_view forwardedFor)
diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp
index 768cbf0dc0..28e7eebd63 100644
--- a/src/xrpld/rpc/detail/ServerHandler.cpp
+++ b/src/xrpld/rpc/detail/ServerHandler.cpp
@@ -8,6 +8,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -44,7 +45,6 @@
 #include 
 #include 
 
-#include 
 #include 
 #include 
 #include 
@@ -93,7 +93,7 @@ statusRequestResponse(http_request_type const& request, boost::beast::http::stat
     response msg;
     msg.version(request.version());
     msg.result(status);
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Content-Type", "text/html");
     msg.insert("Connection", "close");
     msg.body() = "Invalid protocol.";
@@ -113,7 +113,7 @@ authorized(Port const& port, std::map const& h)
     if ((it == h.end()) || (!it->second.starts_with("Basic ")))
         return false;
     std::string strUserPass64 = it->second.substr(6);
-    boost::trim(strUserPass64);
+    strUserPass64 = trimWhitespace(strUserPass64);
     std::string const strUserPass = base64Decode(strUserPass64);
     std::string::size_type const nColon = strUserPass.find(':');
     if (nColon == std::string::npos)
@@ -129,7 +129,7 @@ ServerHandler::ServerHandler(
     boost::asio::io_context& ioContext,
     JobQueue& jobQueue,
     NetworkOPs& networkOPs,
-    Resource::Manager& resourceManager,
+    resource::Manager& resourceManager,
     CollectorManager& cm)
     : app_(app)
     , resourceManager_(resourceManager)
@@ -264,7 +264,7 @@ ServerHandler::onHandoff(
 static inline json::Output
 makeOutput(Session& session)
 {
-    return [&](boost::beast::string_view const& b) { session.write(b.data(), b.size()); };
+    return [&](std::string_view b) { session.write(b.data(), b.size()); };
 }
 
 static std::map
@@ -337,7 +337,7 @@ ServerHandler::onWSMessage(
 {
     json::Value jv;
     auto const size = boost::asio::buffer_size(buffers);
-    if (size > RPC::Tuning::kMaxRequestSize || !json::Reader{}.parse(jv, buffers) || !jv.isObject())
+    if (size > rpc::tuning::kMaxRequestSize || !json::Reader{}.parse(jv, buffers) || !jv.isObject())
     {
         json::Value jvResult(json::ValueType::Object);
         jvResult[jss::type] = jss::error;
@@ -426,11 +426,11 @@ ServerHandler::processSession(
 
     // Requests without "command" are invalid.
     json::Value jr(json::ValueType::Object);
-    Resource::Charge loadType = Resource::kFeeReferenceRpc;
+    resource::Charge loadType = resource::kFeeReferenceRpc;
     try
     {
-        auto apiVersion = RPC::getAPIVersionNumber(jv, app_.config().betaRpcApi);
-        if (apiVersion == RPC::kApiInvalidVersion ||
+        auto apiVersion = rpc::getAPIVersionNumber(jv, app_.config().betaRpcApi);
+        if (apiVersion == rpc::kApiInvalidVersion ||
             (!jv.isMember(jss::command) && !jv.isMember(jss::method)) ||
             (jv.isMember(jss::command) && !jv[jss::command].isString()) ||
             (jv.isMember(jss::method) && !jv[jss::method].isString()) ||
@@ -439,7 +439,7 @@ ServerHandler::processSession(
         {
             jr[jss::type] = jss::response;
             jr[jss::status] = jss::error;
-            jr[jss::error] = apiVersion == RPC::kApiInvalidVersion ? jss::invalid_API_version
+            jr[jss::error] = apiVersion == rpc::kApiInvalidVersion ? jss::invalid_API_version
                                                                    : jss::missingCommand;
             jr[jss::request] = jv;
             if (jv.isMember(jss::id))
@@ -451,11 +451,11 @@ ServerHandler::processSession(
             if (jv.isMember(jss::api_version))
                 jr[jss::api_version] = jv[jss::api_version];
 
-            is->getConsumer().charge(Resource::kFeeMalformedRpc);
+            is->getConsumer().charge(resource::kFeeMalformedRpc);
             return jr;
         }
 
-        auto required = RPC::roleRequired(
+        auto required = rpc::roleRequired(
             apiVersion,
             app_.config().betaRpcApi,
             jv.isMember(jss::command) ? jv[jss::command].asString() : jv[jss::method].asString());
@@ -463,16 +463,16 @@ ServerHandler::processSession(
             required,
             session->port(),
             jv,
-            beast::IP::fromAsio(session->remoteEndpoint().address()),
+            beast::ip::fromAsio(session->remoteEndpoint().address()),
             is->user());
         if (Role::FORBID == role)
         {
-            loadType = Resource::kFeeMalformedRpc;
+            loadType = resource::kFeeMalformedRpc;
             jr[jss::result] = rpcError(RpcForbidden);
         }
         else
         {
-            RPC::JsonContext context{
+            rpc::JsonContext context{
                 {.j = app_.getJournal("RPCHandler"),
                  .app = app_,
                  .loadType = loadType,
@@ -487,7 +487,7 @@ ServerHandler::processSession(
                 {.user = is->user(), .forwardedFor = is->forwardedFor()}};
 
             auto start = std::chrono::system_clock::now();
-            RPC::doCommand(context, jr[jss::result]);
+            rpc::doCommand(context, jr[jss::result]);
             auto end = std::chrono::system_clock::now();
             logDuration(jv, end - start, journal_);
         }
@@ -495,7 +495,7 @@ ServerHandler::processSession(
     catch (std::exception const& ex)
     {
         // LCOV_EXCL_START
-        jr[jss::result] = RPC::makeError(RpcInternal);
+        jr[jss::result] = rpc::makeError(RpcInternal);
         JLOG(journal_.error()) << "Exception while processing WS: " << ex.what() << "\n"
                                << "Input JSON: " << json::Compact{json::Value{jv}};
         // LCOV_EXCL_STOP
@@ -564,11 +564,11 @@ ServerHandler::processSession(
         makeOutput(*session),
         coro,
         forwardedFor(session->request()),
-        [&] {
+        [&] -> std::string_view {
             auto const iter = session->request().find("X-User");
             if (iter != session->request().end())
                 return iter->value();
-            return boost::beast::string_view{};
+            return {};
         }());
 
     if (beast::rfc2616::isKeepAlive(session->request()))
@@ -601,7 +601,7 @@ void
 ServerHandler::processRequest(
     Port const& port,
     std::string const& request,
-    beast::IP::Endpoint const& remoteIPAddress,
+    beast::ip::Endpoint const& remoteIPAddress,
     Output const& output,
     std::shared_ptr coro,
     std::string_view forwardedFor,
@@ -612,7 +612,7 @@ ServerHandler::processRequest(
     json::Value jsonOrig;
     {
         json::Reader reader;
-        if ((request.size() > RPC::Tuning::kMaxRequestSize) || !reader.parse(request, jsonOrig) ||
+        if ((request.size() > rpc::tuning::kMaxRequestSize) || !reader.parse(request, jsonOrig) ||
             !jsonOrig || !jsonOrig.isObject())
         {
             httpReply(
@@ -652,21 +652,21 @@ ServerHandler::processRequest(
             continue;
         }
 
-        unsigned apiVersion = RPC::kApiVersionIfUnspecified;
+        unsigned apiVersion = rpc::kApiVersionIfUnspecified;
         if (jsonRPC.isMember(jss::params) && jsonRPC[jss::params].isArray() &&
             jsonRPC[jss::params].size() > 0 && jsonRPC[jss::params][0u].isObject())
         {
-            apiVersion = RPC::getAPIVersionNumber(
+            apiVersion = rpc::getAPIVersionNumber(
                 jsonRPC[jss::params][json::UInt(0)], app_.config().betaRpcApi);
         }
 
-        if (apiVersion == RPC::kApiVersionIfUnspecified && batch)
+        if (apiVersion == rpc::kApiVersionIfUnspecified && batch)
         {
             // for batch request, api_version may be at a different level
-            apiVersion = RPC::getAPIVersionNumber(jsonRPC, app_.config().betaRpcApi);
+            apiVersion = rpc::getAPIVersionNumber(jsonRPC, app_.config().betaRpcApi);
         }
 
-        if (apiVersion == RPC::kApiInvalidVersion)
+        if (apiVersion == rpc::kApiInvalidVersion)
         {
             if (!batch)
             {
@@ -685,7 +685,7 @@ ServerHandler::processRequest(
         auto required = Role::FORBID;
         if (jsonRPC.isMember(jss::method) && jsonRPC[jss::method].isString())
         {
-            required = RPC::roleRequired(
+            required = rpc::roleRequired(
                 apiVersion, app_.config().betaRpcApi, jsonRPC[jss::method].asString());
         }
 
@@ -700,7 +700,7 @@ ServerHandler::processRequest(
             role = requestRole(required, port, json::ValueType::Object, remoteIPAddress, user);
         }
 
-        Resource::Consumer usage;
+        resource::Consumer usage;
         if (isUnlimited(role))
         {
             usage = resourceManager_.newUnlimitedEndpoint(remoteIPAddress);
@@ -725,7 +725,7 @@ ServerHandler::processRequest(
 
         if (role == Role::FORBID)
         {
-            usage.charge(Resource::kFeeMalformedRpc);
+            usage.charge(resource::kFeeMalformedRpc);
             if (!batch)
             {
                 httpReply(403, "Forbidden", output, rpcJ);
@@ -739,7 +739,7 @@ ServerHandler::processRequest(
 
         if (!jsonRPC.isMember(jss::method) || jsonRPC[jss::method].isNull())
         {
-            usage.charge(Resource::kFeeMalformedRpc);
+            usage.charge(resource::kFeeMalformedRpc);
             if (!batch)
             {
                 httpReply(400, "Null method", output, rpcJ);
@@ -754,7 +754,7 @@ ServerHandler::processRequest(
         json::Value const& method = jsonRPC[jss::method];
         if (!method.isString())
         {
-            usage.charge(Resource::kFeeMalformedRpc);
+            usage.charge(resource::kFeeMalformedRpc);
             if (!batch)
             {
                 httpReply(400, "method is not string", output, rpcJ);
@@ -769,7 +769,7 @@ ServerHandler::processRequest(
         std::string const strMethod = method.asString();
         if (strMethod.empty())
         {
-            usage.charge(Resource::kFeeMalformedRpc);
+            usage.charge(resource::kFeeMalformedRpc);
             if (!batch)
             {
                 httpReply(400, "method is empty", output, rpcJ);
@@ -797,7 +797,7 @@ ServerHandler::processRequest(
             }
             else if (!params.isArray() || params.size() != 1)
             {
-                usage.charge(Resource::kFeeMalformedRpc);
+                usage.charge(resource::kFeeMalformedRpc);
                 httpReply(400, "params unparsable", output, rpcJ);
                 return;
             }
@@ -806,7 +806,7 @@ ServerHandler::processRequest(
                 params = std::move(params[0u]);
                 if (!params.isObjectOrNull())
                 {
-                    usage.charge(Resource::kFeeMalformedRpc);
+                    usage.charge(resource::kFeeMalformedRpc);
                     httpReply(400, "params unparsable", output, rpcJ);
                     return;
                 }
@@ -822,7 +822,7 @@ ServerHandler::processRequest(
         {
             if (!params[jss::ripplerpc].isString())
             {
-                usage.charge(Resource::kFeeMalformedRpc);
+                usage.charge(resource::kFeeMalformedRpc);
                 if (!batch)
                 {
                     httpReply(400, "ripplerpc is not a string", output, rpcJ);
@@ -853,9 +853,9 @@ ServerHandler::processRequest(
         params[jss::command] = strMethod;
         JLOG(journal_.trace()) << "doRpcCommand:" << strMethod << ":" << params;
 
-        Resource::Charge loadType = Resource::kFeeReferenceRpc;
+        resource::Charge loadType = resource::kFeeReferenceRpc;
 
-        RPC::JsonContext context{
+        rpc::JsonContext context{
             {.j = journal_,
              .app = app_,
              .loadType = loadType,
@@ -874,12 +874,12 @@ ServerHandler::processRequest(
 
         try
         {
-            RPC::doCommand(context, result);
+            rpc::doCommand(context, result);
         }
         catch (std::exception const& ex)
         {
             // LCOV_EXCL_START
-            result = RPC::makeError(RpcInternal);
+            result = rpc::makeError(RpcInternal);
             JLOG(journal_.error())
                 << "Internal error : " << ex.what()
                 << " when processing request: " << json::Compact{json::Value{params}};
@@ -984,7 +984,7 @@ ServerHandler::processRequest(
                 reply[jss::error][jss::error_code].isInt())
             {
                 int const errCode = reply[jss::error][jss::error_code].asInt();
-                return RPC::errorCodeHttpStatus(static_cast(errCode));
+                return rpc::errorCodeHttpStatus(static_cast(errCode));
             }
         }
         // Return OK.
@@ -1043,7 +1043,7 @@ ServerHandler::statusResponse(http_request_type const& request) const
         msg.body() = "Server cannot accept clients: " + reason + "";
     }
     msg.version(request.version());
-    msg.insert("Server", BuildInfo::getFullVersionString());
+    msg.insert("Server", build_info::getFullVersionString());
     msg.insert("Content-Type", "text/html");
     msg.insert("Connection", "close");
     msg.prepare_payload();
@@ -1208,7 +1208,7 @@ setupClient(ServerHandler::Setup& setup)
     if (iter == setup.ports.cend())
         return;
     setup.client.secure = iter->protocol.contains("https");
-    if (beast::IP::isUnspecified(iter->ip))
+    if (beast::ip::isUnspecified(iter->ip))
     {
         // VFALCO HACK! to make localhost work
         setup.client.ip = iter->ip.is_v6() ? "::1" : "127.0.0.1";
@@ -1256,7 +1256,7 @@ makeServerHandler(
     boost::asio::io_context& ioContext,
     JobQueue& jobQueue,
     NetworkOPs& networkOPs,
-    Resource::Manager& resourceManager,
+    resource::Manager& resourceManager,
     CollectorManager& cm)
 {
     return std::make_unique(
diff --git a/src/xrpld/rpc/detail/Status.cpp b/src/xrpld/rpc/detail/Status.cpp
index 58d2f8cb80..147f2b31e0 100644
--- a/src/xrpld/rpc/detail/Status.cpp
+++ b/src/xrpld/rpc/detail/Status.cpp
@@ -9,7 +9,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 std::string
 Status::codeString() const
@@ -25,7 +25,7 @@ Status::codeString() const
         std::string s1, s2;
 
         [[maybe_unused]] auto const success = transResultInfo(toTER(), s1, s2);
-        XRPL_ASSERT(success, "xrpl::RPC::codeString : valid TER result");
+        XRPL_ASSERT(success, "xrpl::rpc::codeString : valid TER result");
 
         return s1 + ": " + s2;
     }
@@ -39,7 +39,7 @@ Status::codeString() const
     }
 
     // LCOV_EXCL_START
-    UNREACHABLE("xrpl::RPC::codeString : invalid type");
+    UNREACHABLE("xrpl::rpc::codeString : invalid type");
     return "";
     // LCOV_EXCL_STOP
 }
@@ -85,4 +85,4 @@ Status::toString() const
     return "";
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp
index e1c5180b5c..9c97577b27 100644
--- a/src/xrpld/rpc/detail/TransactionSign.cpp
+++ b/src/xrpld/rpc/detail/TransactionSign.cpp
@@ -64,7 +64,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 namespace detail {
 
 // Used to pass extra parameters used when returning a
@@ -218,7 +218,7 @@ checkPayment(
         {
             if (txJson[jss::DeliverMax] != txJson[jss::Amount])
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     RpcInvalidParams, "Cannot specify differing 'Amount' and 'DeliverMax'");
             }
         }
@@ -231,31 +231,31 @@ checkPayment(
     }
 
     if (!txJson.isMember(jss::Amount))
-        return RPC::missingFieldError("tx_json.Amount");
+        return rpc::missingFieldError("tx_json.Amount");
 
     STAmount amount;
 
     if (!amountFromJsonNoThrow(amount, txJson[jss::Amount]))
-        return RPC::invalidFieldError("tx_json.Amount");
+        return rpc::invalidFieldError("tx_json.Amount");
 
     if (!txJson.isMember(jss::Destination))
-        return RPC::missingFieldError("tx_json.Destination");
+        return rpc::missingFieldError("tx_json.Destination");
 
     auto const dstAccountID = parseBase58(txJson[jss::Destination].asString());
     if (!dstAccountID)
-        return RPC::invalidFieldError("tx_json.Destination");
+        return rpc::invalidFieldError("tx_json.Destination");
 
     if (params.isMember(jss::build_path) &&
         (!doPath ||
          (!app.getOpenLedger().current()->rules().enabled(featureMPTokensV2) &&
           amount.holds())))
     {
-        return RPC::makeError(RpcInvalidParams, "Field 'build_path' not allowed in this context.");
+        return rpc::makeError(RpcInvalidParams, "Field 'build_path' not allowed in this context.");
     }
 
     if (txJson.isMember(jss::Paths) && params.isMember(jss::build_path))
     {
-        return RPC::makeError(
+        return rpc::makeError(
             RpcInvalidParams, "Cannot specify both 'tx_json.Paths' and 'build_path'");
     }
 
@@ -266,7 +266,7 @@ checkPayment(
         if (!txJson[sfDomainID.jsonName].isString() ||
             !num.parseHex(txJson[sfDomainID.jsonName].asString()))
         {
-            return RPC::makeError(RpcDomainMalformed, "Unable to parse 'DomainID'.");
+            return rpc::makeError(RpcDomainMalformed, "Unable to parse 'DomainID'.");
         }
 
         domain = num;
@@ -279,7 +279,7 @@ checkPayment(
         if (txJson.isMember(jss::SendMax))
         {
             if (!amountFromJsonNoThrow(sendMax, txJson[jss::SendMax]))
-                return RPC::invalidFieldError("tx_json.SendMax");
+                return rpc::invalidFieldError("tx_json.SendMax");
         }
         else
         {
@@ -291,7 +291,7 @@ checkPayment(
         }
 
         if (sendMax.native() && amount.native())
-            return RPC::makeError(RpcInvalidParams, "Cannot build XRP to XRP paths.");
+            return rpc::makeError(RpcInvalidParams, "Cannot build XRP to XRP paths.");
 
         {
             LegacyPathFind const lpf(isUnlimited(role), app);
@@ -357,19 +357,19 @@ checkTxJsonFields(
 
     if (!txJson.isObject())
     {
-        ret.first = RPC::objectFieldError(jss::tx_json);
+        ret.first = rpc::objectFieldError(jss::tx_json);
         return ret;
     }
 
     if (!txJson.isMember(jss::TransactionType))
     {
-        ret.first = RPC::missingFieldError("tx_json.TransactionType");
+        ret.first = rpc::missingFieldError("tx_json.TransactionType");
         return ret;
     }
 
     if (!txJson.isMember(jss::Account))
     {
-        ret.first = RPC::makeError(RpcSrcActMissing, RPC::missingFieldMessage("tx_json.Account"));
+        ret.first = rpc::makeError(RpcSrcActMissing, rpc::missingFieldMessage("tx_json.Account"));
         return ret;
     }
 
@@ -377,12 +377,12 @@ checkTxJsonFields(
 
     if (!srcAddressID)
     {
-        ret.first = RPC::makeError(RpcSrcActMalformed, RPC::invalidFieldMessage("tx_json.Account"));
+        ret.first = rpc::makeError(RpcSrcActMalformed, rpc::invalidFieldMessage("tx_json.Account"));
         return ret;
     }
 
     // Check for current ledger.
-    if (verify && !config.standalone() && (validatedLedgerAge > Tuning::kMaxValidatedLedgerAge))
+    if (verify && !config.standalone() && (validatedLedgerAge > tuning::kMaxValidatedLedgerAge))
     {
         if (apiVersion == 1)
         {
@@ -415,12 +415,12 @@ checkNetworkID(json::Value const& txJson, uint32_t appNetworkId)
         if (!txJson.isMember(jss::NetworkID))
         {
             return std::unexpected(
-                RPC::makeError(RpcInvalidParams, RPC::missingFieldMessage("tx_json.NetworkID")));
+                rpc::makeError(RpcInvalidParams, rpc::missingFieldMessage("tx_json.NetworkID")));
         }
         if (!txJson[jss::NetworkID].isIntegral() || txJson[jss::NetworkID].asUInt() != appNetworkId)
         {
             return std::unexpected(
-                RPC::makeError(RpcInvalidParams, RPC::invalidFieldMessage("tx_json.NetworkID")));
+                rpc::makeError(RpcInvalidParams, rpc::invalidFieldMessage("tx_json.NetworkID")));
         }
     }
     return std::expected();
@@ -490,13 +490,13 @@ transactionPreProcessImpl(
     {
         if (signatureTemplate == nullptr)
         {  // Invalid target field
-            return RPC::makeError(RpcInvalidParams, signatureTarget->get().getName());
+            return rpc::makeError(RpcInvalidParams, signatureTarget->get().getName());
         }
         signingArgs.setSignatureTarget(signatureTarget);
     }
 
     if (!params.isMember(jss::tx_json))
-        return RPC::missingFieldError(jss::tx_json);
+        return rpc::missingFieldError(jss::tx_json);
 
     json::Value& txJson(params[jss::tx_json]);
 
@@ -510,14 +510,14 @@ transactionPreProcessImpl(
         app.getFeeTrack(),
         getAPIVersionNumber(params, app.config().betaRpcApi));
 
-    if (RPC::containsError(txJsonResult))
+    if (rpc::containsError(txJsonResult))
         return std::move(txJsonResult);
 
     // This test covers the case where we're offline so the sequence number
     // cannot be determined locally.  If we're offline then the caller must
     // provide the sequence number.
     if (!verify && !txJson.isMember(jss::Sequence))
-        return RPC::missingFieldError("tx_json.Sequence");
+        return rpc::missingFieldError("tx_json.Sequence");
 
     SLE::const_pointer sle;
     if (verify)
@@ -565,7 +565,7 @@ transactionPreProcessImpl(
             app.getTxQ(),
             app);
 
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -573,7 +573,7 @@ transactionPreProcessImpl(
         json::Value err = checkPayment(
             params, txJson, srcAddressID, role, app, verify && signingArgs.editFields());
 
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -614,8 +614,8 @@ transactionPreProcessImpl(
 
                 if (!ptrDelegatedAddressID)
                 {
-                    return RPC::makeError(
-                        RpcSrcActMalformed, RPC::invalidFieldMessage("tx_json.Delegate"));
+                    return rpc::makeError(
+                        RpcSrcActMalformed, rpc::invalidFieldMessage("tx_json.Delegate"));
                 }
 
                 auto delegatedAddressID = *ptrDelegatedAddressID;
@@ -672,17 +672,17 @@ transactionPreProcessImpl(
     }
     catch (STObject::FieldErr const& err)
     {
-        return RPC::makeError(RpcInvalidParams, err.what());
+        return rpc::makeError(RpcInvalidParams, err.what());
     }
     catch (std::exception&)
     {
-        return RPC::makeError(
+        return rpc::makeError(
             RpcInternal, "Exception occurred constructing serialized transaction");
     }
 
     std::string reason;
     if (!passesLocalChecks(*stTx, reason))
-        return RPC::makeError(RpcInvalidParams, reason);
+        return rpc::makeError(RpcInvalidParams, reason);
 
     // If multisign then return multiSignature, else set TxnSignature field.
     if (signingArgs.isMultiSigning())
@@ -716,7 +716,7 @@ transactionConstructImpl(
         tpTrans = std::make_shared(stTx, reason, app);
         if (tpTrans->getStatus() != TransStatus::NEW)
         {
-            ret.first = RPC::makeError(RpcInternal, "Unable to construct transaction: " + reason);
+            ret.first = rpc::makeError(RpcInternal, "Unable to construct transaction: " + reason);
             return ret;
         }
     }
@@ -741,7 +741,7 @@ transactionConstructImpl(
             }
             if (checkValidity(app.getHashRouter(), *sttxNew, rules).first != Validity::Valid)
             {
-                ret.first = RPC::makeError(RpcInternal, "Invalid signature.");
+                ret.first = rpc::makeError(RpcInternal, "Invalid signature.");
                 return ret;
             }
 
@@ -766,7 +766,7 @@ transactionConstructImpl(
 
     if (!tpTrans)
     {
-        ret.first = RPC::makeError(RpcInternal, "Unable to sterilize transaction.");
+        ret.first = rpc::makeError(RpcInternal, "Unable to sterilize transaction.");
         return ret;
     }
     ret.second = std::move(tpTrans);
@@ -789,7 +789,7 @@ transactionFormatResultImpl(Transaction::pointer tpTrans, unsigned apiVersion)
             jvResult[jss::tx_json] = tpTrans->getJson(JsonOptions::Values::None);
         }
 
-        RPC::insertDeliverMax(
+        rpc::insertDeliverMax(
             jvResult[jss::tx_json], tpTrans->getSTransaction()->getTxnType(), apiVersion);
 
         jvResult[jss::tx_blob] = strHex(tpTrans->getSTransaction()->getSerializer().peekData());
@@ -808,7 +808,7 @@ transactionFormatResultImpl(Transaction::pointer tpTrans, unsigned apiVersion)
     }
     catch (std::exception&)
     {
-        jvResult = RPC::makeError(RpcInternal, "Exception occurred during JSON handling.");
+        jvResult = rpc::makeError(RpcInternal, "Exception occurred during JSON handling.");
     }
     return jvResult;
 }
@@ -922,7 +922,7 @@ getCurrentNetworkFee(
     {
         std::stringstream ss;
         ss << "Fee of " << fee << " exceeds the requested tx limit of " << *limit;
-        return RPC::makeError(RpcHighFee, ss.str());
+        return rpc::makeError(RpcHighFee, ss.str());
     }
 
     return fee.jsonClipped();
@@ -943,10 +943,10 @@ checkFee(
         return json::Value();
 
     if (!doAutoFill)
-        return RPC::missingFieldError("tx_json.Fee");
+        return rpc::missingFieldError("tx_json.Fee");
 
-    int mult = Tuning::kDefaultAutoFillFeeMultiplier;
-    int div = Tuning::kDefaultAutoFillFeeDivisor;
+    int mult = tuning::kDefaultAutoFillFeeMultiplier;
+    int div = tuning::kDefaultAutoFillFeeDivisor;
     if (request.isMember(jss::fee_mult_max))
     {
         if (request[jss::fee_mult_max].isInt())
@@ -954,15 +954,15 @@ checkFee(
             mult = request[jss::fee_mult_max].asInt();
             if (mult < 0)
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     RpcInvalidParams,
-                    RPC::expectedFieldMessage(jss::fee_mult_max, "a positive integer"));
+                    rpc::expectedFieldMessage(jss::fee_mult_max, "a positive integer"));
             }
         }
         else
         {
-            return RPC::makeError(
-                RpcHighFee, RPC::expectedFieldMessage(jss::fee_mult_max, "a positive integer"));
+            return rpc::makeError(
+                RpcHighFee, rpc::expectedFieldMessage(jss::fee_mult_max, "a positive integer"));
         }
     }
     if (request.isMember(jss::fee_div_max))
@@ -972,15 +972,15 @@ checkFee(
             div = request[jss::fee_div_max].asInt();
             if (div <= 0)
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     RpcInvalidParams,
-                    RPC::expectedFieldMessage(jss::fee_div_max, "a positive integer"));
+                    rpc::expectedFieldMessage(jss::fee_div_max, "a positive integer"));
             }
         }
         else
         {
-            return RPC::makeError(
-                RpcHighFee, RPC::expectedFieldMessage(jss::fee_div_max, "a positive integer"));
+            return rpc::makeError(
+                RpcHighFee, rpc::expectedFieldMessage(jss::fee_div_max, "a positive integer"));
         }
     }
 
@@ -1071,7 +1071,7 @@ transactionSubmit(
     }
     catch (std::exception&)
     {
-        return RPC::makeError(RpcInternal, "Exception occurred during transaction submission.");
+        return rpc::makeError(RpcInternal, "Exception occurred during transaction submission.");
     }
 
     return transactionFormatResultImpl(txn.second, apiVersion);
@@ -1084,21 +1084,21 @@ static json::Value
 checkMultiSignFields(json::Value const& jvRequest)
 {
     if (!jvRequest.isMember(jss::tx_json))
-        return RPC::missingFieldError(jss::tx_json);
+        return rpc::missingFieldError(jss::tx_json);
 
     json::Value const& txJson(jvRequest[jss::tx_json]);
 
     if (!txJson.isObject())
-        return RPC::invalidFieldMessage(jss::tx_json);
+        return rpc::invalidFieldMessage(jss::tx_json);
 
     // There are a couple of additional fields we need to check before
     // we serialize.  If we serialize first then we generate less useful
     // error messages.
     if (!txJson.isMember(jss::Sequence))
-        return RPC::missingFieldError("tx_json.Sequence");
+        return rpc::missingFieldError("tx_json.Sequence");
 
     if (!txJson.isMember(sfSigningPubKey.getJsonName()))
-        return RPC::missingFieldError("tx_json.SigningPubKey");
+        return rpc::missingFieldError("tx_json.SigningPubKey");
 
     // Multi-signing into a signature_target object field is fine,
     // because it means the signature is not for the transaction
@@ -1106,7 +1106,7 @@ checkMultiSignFields(json::Value const& jvRequest)
     if (!jvRequest.isMember(jss::signature_target) &&
         !txJson[sfSigningPubKey.getJsonName()].asString().empty())
     {
-        return RPC::makeError(
+        return rpc::makeError(
             RpcInvalidParams, "When multi-signing 'tx_json.SigningPubKey' must be empty.");
     }
 
@@ -1120,7 +1120,7 @@ static json::Value
 sortAndValidateSigners(STArray& signers, AccountID const& signingForID)
 {
     if (signers.empty())
-        return RPC::makeParamError("Signers array may not be empty.");
+        return rpc::makeParamError("Signers array may not be empty.");
 
     // Signers must be sorted by Account.
     std::ranges::sort(signers, [](STObject const& a, STObject const& b) {
@@ -1137,7 +1137,7 @@ sortAndValidateSigners(STArray& signers, AccountID const& signingForID)
         std::ostringstream err;
         err << "Duplicate Signers:Signer:Account entries (" << toBase58((*dupIter)[sfAccount])
             << ") are not allowed.";
-        return RPC::makeParamError(err.str());
+        return rpc::makeParamError(err.str());
     }
 
     // An account may not sign for itself.
@@ -1147,7 +1147,7 @@ sortAndValidateSigners(STArray& signers, AccountID const& signingForID)
     {
         std::ostringstream err;
         err << "A Signer may not be the transaction's Account (" << toBase58(signingForID) << ").";
-        return RPC::makeParamError(err.str());
+        return rpc::makeParamError(err.str());
     }
     return {};
 }
@@ -1174,23 +1174,23 @@ transactionSignFor(
     char const accountField[] = "account";
 
     if (!jvRequest.isMember(accountField))
-        return RPC::missingFieldError(accountField);
+        return rpc::missingFieldError(accountField);
 
     // Turn the signer's account into an AccountID for multi-sign.
     auto const signerAccountID = parseBase58(jvRequest[accountField].asString());
     if (!signerAccountID)
     {
-        return RPC::makeError(RpcSrcActMalformed, RPC::invalidFieldMessage(accountField));
+        return rpc::makeError(RpcSrcActMalformed, rpc::invalidFieldMessage(accountField));
     }
 
     if (!jvRequest.isMember(jss::tx_json))
-        return RPC::missingFieldError(jss::tx_json);
+        return rpc::missingFieldError(jss::tx_json);
 
     {
         json::Value& txJson(jvRequest[jss::tx_json]);
 
         if (!txJson.isObject())
-            return RPC::objectFieldError(jss::tx_json);
+            return rpc::objectFieldError(jss::tx_json);
 
         if (auto checkResult =
                 detail::checkNetworkID(txJson, app.getNetworkIDService().getNetworkID());
@@ -1211,7 +1211,7 @@ transactionSignFor(
     using namespace detail;
     {
         json::Value err = checkMultiSignFields(jvRequest);
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -1225,7 +1225,7 @@ transactionSignFor(
         return preprocResult.first;
 
     XRPL_ASSERT(
-        signForParams.validMultiSign(), "xrpl::RPC::transactionSignFor : valid multi-signature");
+        signForParams.validMultiSign(), "xrpl::rpc::transactionSignFor : valid multi-signature");
 
     {
         SLE::const_pointer const accountState = ledger->read(keylet::account(*signerAccountID));
@@ -1263,7 +1263,7 @@ transactionSignFor(
         // For delegated transactions, the delegate account is
         // the one forbidden from appearing in its own Signers array.
         auto err = sortAndValidateSigners(signers, sttx->getInitiator());
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -1299,7 +1299,7 @@ transactionSubmitMultiSigned(
     using namespace detail;
     {
         json::Value err = checkMultiSignFields(jvRequest);
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -1314,7 +1314,7 @@ transactionSubmitMultiSigned(
         app.getFeeTrack(),
         getAPIVersionNumber(jvRequest, app.config().betaRpcApi));
 
-    if (RPC::containsError(txJsonResult))
+    if (rpc::containsError(txJsonResult))
         return std::move(txJsonResult);
 
     SLE::const_pointer const sle = ledger->read(keylet::account(srcAddressID));
@@ -1332,12 +1332,12 @@ transactionSubmitMultiSigned(
         json::Value err =
             checkFee(jvRequest, role, false, app.config(), app.getFeeTrack(), app.getTxQ(), app);
 
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
 
         err = checkPayment(jvRequest, txJson, srcAddressID, role, app, false);
 
-        if (RPC::containsError(err))
+        if (rpc::containsError(err))
             return err;
     }
 
@@ -1359,17 +1359,17 @@ transactionSubmitMultiSigned(
         }
         catch (STObject::FieldErr const& err)
         {
-            return RPC::makeError(RpcInvalidParams, err.what());
+            return rpc::makeError(RpcInvalidParams, err.what());
         }
         catch (std::exception& ex)
         {
             std::string const reason(ex.what());
-            return RPC::makeError(
+            return rpc::makeError(
                 RpcInternal, "Exception while serializing transaction: " + reason);
         }
         std::string reason;
         if (!passesLocalChecks(*stTx, reason))
-            return RPC::makeError(RpcInvalidParams, reason);
+            return rpc::makeError(RpcInvalidParams, reason);
     }
 
     // Validate the fields in the serialized transaction.
@@ -1383,7 +1383,7 @@ transactionSubmitMultiSigned(
             std::ostringstream err;
             err << "Invalid  " << sfSigningPubKey.fieldName
                 << " field.  Field must be empty when multi-signing.";
-            return RPC::makeError(RpcInvalidParams, err.str());
+            return rpc::makeError(RpcInvalidParams, err.str());
         }
 
         // There may not be a TxnSignature field.
@@ -1397,26 +1397,26 @@ transactionSubmitMultiSigned(
         {
             std::ostringstream err;
             err << "Invalid " << sfFee.fieldName << " field.  Fees must be specified in XRP.";
-            return RPC::makeError(RpcInvalidParams, err.str());
+            return rpc::makeError(RpcInvalidParams, err.str());
         }
         if (fee <= STAmount{0})
         {
             std::ostringstream err;
             err << "Invalid " << sfFee.fieldName << " field.  Fees must be greater than zero.";
-            return RPC::makeError(RpcInvalidParams, err.str());
+            return rpc::makeError(RpcInvalidParams, err.str());
         }
     }
 
     // Verify that the Signers field is present.
     if (!stTx->isFieldPresent(sfSigners))
-        return RPC::missingFieldError("tx_json.Signers");
+        return rpc::missingFieldError("tx_json.Signers");
 
     // If the Signers field is present the SField guarantees it to be an array.
     // Get a reference to the Signers array so we can verify and sort it.
     auto& signers = stTx->peekFieldArray(sfSigners);
 
     if (signers.empty())
-        return RPC::makeParamError("tx_json.Signers array may not be empty.");
+        return rpc::makeParamError("tx_json.Signers array may not be empty.");
 
     // The Signers array may only contain Signer objects.
     if (std::ranges::find_if_not(signers, [](STObject const& obj) {
@@ -1427,14 +1427,14 @@ transactionSubmitMultiSigned(
                 obj.isFieldPresent(sfTxnSignature) && obj.getCount() == 3);
         }) != signers.end())
     {
-        return RPC::makeParamError("Signers array may only contain Signer entries.");
+        return rpc::makeParamError("Signers array may only contain Signer entries.");
     }
 
     // The array must be sorted and validated.
     // For delegated transactions, getInitiator() returns sfDelegate,
     // that account is the one forbidden from appearing in its own Signers array.
     auto err = sortAndValidateSigners(signers, stTx->getInitiator());
-    if (RPC::containsError(err))
+    if (rpc::containsError(err))
         return err;
 
     // Make sure the SerializedTransaction makes a legitimate Transaction.
@@ -1452,10 +1452,10 @@ transactionSubmitMultiSigned(
     }
     catch (std::exception&)
     {
-        return RPC::makeError(RpcInternal, "Exception occurred during transaction submission.");
+        return rpc::makeError(RpcInternal, "Exception occurred during transaction submission.");
     }
 
     return transactionFormatResultImpl(txn.second, apiVersion);
 }
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/detail/TransactionSign.h b/src/xrpld/rpc/detail/TransactionSign.h
index dcb417dd16..9b07cbde20 100644
--- a/src/xrpld/rpc/detail/TransactionSign.h
+++ b/src/xrpld/rpc/detail/TransactionSign.h
@@ -20,7 +20,7 @@ class LoadFeeTrack;
 class Transaction;
 class TxQ;
 
-namespace RPC {
+namespace rpc {
 
 json::Value
 getCurrentNetworkFee(
@@ -30,8 +30,8 @@ getCurrentNetworkFee(
     TxQ const& txQ,
     Application const& app,
     json::Value const& tx,
-    int mult = Tuning::kDefaultAutoFillFeeMultiplier,
-    int div = Tuning::kDefaultAutoFillFeeDivisor);
+    int mult = tuning::kDefaultAutoFillFeeMultiplier,
+    int div = tuning::kDefaultAutoFillFeeDivisor);
 
 /**
  * Fill in the fee on behalf of the client.
@@ -140,5 +140,5 @@ transactionSubmitMultiSigned(
     Application& app,
     ProcessTransactionFn const& processTransaction);
 
-}  // namespace RPC
+}  // namespace rpc
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/detail/Tuning.h b/src/xrpld/rpc/detail/Tuning.h
index b904822698..5c47ab4365 100644
--- a/src/xrpld/rpc/detail/Tuning.h
+++ b/src/xrpld/rpc/detail/Tuning.h
@@ -6,7 +6,7 @@
  * Tuned constants.
  */
 /** @{ */
-namespace xrpl::RPC::Tuning {
+namespace xrpl::rpc::tuning {
 
 /**
  * Represents RPC limit parameter values that have a min, default and max.
@@ -98,5 +98,5 @@ static constexpr int kMaxSrcCur = 18;
  */
 static constexpr int kMaxAutoSrcCur = 88;
 
-}  // namespace xrpl::RPC::Tuning
+}  // namespace xrpl::rpc::tuning
 /** @} */
diff --git a/src/xrpld/rpc/handlers/ChannelVerify.cpp b/src/xrpld/rpc/handlers/ChannelVerify.cpp
index 64f616e829..50230a34a1 100644
--- a/src/xrpld/rpc/handlers/ChannelVerify.cpp
+++ b/src/xrpld/rpc/handlers/ChannelVerify.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -27,15 +28,17 @@ namespace xrpl {
 //   signature: signature to verify
 // }
 json::Value
-doChannelVerify(RPC::JsonContext& context)
+doChannelVerify(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     for (auto const& p : {jss::public_key, jss::channel_id, jss::amount, jss::signature})
     {
         if (!params.isMember(p))
-            return RPC::missingFieldError(p);
+            return rpc::missingFieldError(p);
     }
 
+    context.loadType = resource::kFeeHeavyBurdenRpc;
+
     std::optional pk;
     {
         std::string const strPk = params[jss::public_key].asString();
diff --git a/src/xrpld/rpc/handlers/Handlers.h b/src/xrpld/rpc/handlers/Handlers.h
index 7b347b2ecc..d192e8726c 100644
--- a/src/xrpld/rpc/handlers/Handlers.h
+++ b/src/xrpld/rpc/handlers/Handlers.h
@@ -7,147 +7,147 @@
 namespace xrpl {
 
 json::Value
-doAccountCurrencies(RPC::JsonContext&);
+doAccountCurrencies(rpc::JsonContext&);
 json::Value
-doAccountInfo(RPC::JsonContext&);
+doAccountInfo(rpc::JsonContext&);
 json::Value
-doAccountLines(RPC::JsonContext&);
+doAccountLines(rpc::JsonContext&);
 json::Value
-doAccountChannels(RPC::JsonContext&);
+doAccountChannels(rpc::JsonContext&);
 json::Value
-doAccountNFTs(RPC::JsonContext&);
+doAccountNFTs(rpc::JsonContext&);
 json::Value
-doAccountObjects(RPC::JsonContext&);
+doAccountObjects(rpc::JsonContext&);
 json::Value
-doAccountOffers(RPC::JsonContext&);
+doAccountOffers(rpc::JsonContext&);
 json::Value
-doAccountTx(RPC::JsonContext&);
+doAccountTx(rpc::JsonContext&);
 json::Value
-doAMMInfo(RPC::JsonContext&);
+doAMMInfo(rpc::JsonContext&);
 json::Value
-doBookOffers(RPC::JsonContext&);
+doBookOffers(rpc::JsonContext&);
 json::Value
-doBookChanges(RPC::JsonContext&);
+doBookChanges(rpc::JsonContext&);
 json::Value
-doBlackList(RPC::JsonContext&);
+doBlackList(rpc::JsonContext&);
 json::Value
-doCanDelete(RPC::JsonContext&);
+doCanDelete(rpc::JsonContext&);
 json::Value
-doChannelAuthorize(RPC::JsonContext&);
+doChannelAuthorize(rpc::JsonContext&);
 json::Value
-doChannelVerify(RPC::JsonContext&);
+doChannelVerify(rpc::JsonContext&);
 json::Value
-doConnect(RPC::JsonContext&);
+doConnect(rpc::JsonContext&);
 json::Value
-doConsensusInfo(RPC::JsonContext&);
+doConsensusInfo(rpc::JsonContext&);
 json::Value
-doDepositAuthorized(RPC::JsonContext&);
+doDepositAuthorized(rpc::JsonContext&);
 json::Value
-doFeature(RPC::JsonContext&);
+doFeature(rpc::JsonContext&);
 json::Value
-doFee(RPC::JsonContext&);
+doFee(rpc::JsonContext&);
 json::Value
-doFetchInfo(RPC::JsonContext&);
+doFetchInfo(rpc::JsonContext&);
 json::Value
-doGatewayBalances(RPC::JsonContext&);
+doGatewayBalances(rpc::JsonContext&);
 json::Value
-doGetCounts(RPC::JsonContext&);
+doGetCounts(rpc::JsonContext&);
 json::Value
-doGetAggregatePrice(RPC::JsonContext&);
+doGetAggregatePrice(rpc::JsonContext&);
 json::Value
-doLedgerAccept(RPC::JsonContext&);
+doLedgerAccept(rpc::JsonContext&);
 json::Value
-doLedgerCleaner(RPC::JsonContext&);
+doLedgerCleaner(rpc::JsonContext&);
 json::Value
-doLedgerClosed(RPC::JsonContext&);
+doLedgerClosed(rpc::JsonContext&);
 json::Value
-doLedgerCurrent(RPC::JsonContext&);
+doLedgerCurrent(rpc::JsonContext&);
 json::Value
-doLedgerData(RPC::JsonContext&);
+doLedgerData(rpc::JsonContext&);
 json::Value
-doLedgerEntry(RPC::JsonContext&);
+doLedgerEntry(rpc::JsonContext&);
 json::Value
-doLedgerHeader(RPC::JsonContext&);
+doLedgerHeader(rpc::JsonContext&);
 json::Value
-doLedgerRequest(RPC::JsonContext&);
+doLedgerRequest(rpc::JsonContext&);
 json::Value
-doLogLevel(RPC::JsonContext&);
+doLogLevel(rpc::JsonContext&);
 json::Value
-doLogRotate(RPC::JsonContext&);
+doLogRotate(rpc::JsonContext&);
 json::Value
-doManifest(RPC::JsonContext&);
+doManifest(rpc::JsonContext&);
 json::Value
-doNFTBuyOffers(RPC::JsonContext&);
+doNFTBuyOffers(rpc::JsonContext&);
 json::Value
-doNFTSellOffers(RPC::JsonContext&);
+doNFTSellOffers(rpc::JsonContext&);
 json::Value
-doNoRippleCheck(RPC::JsonContext&);
+doNoRippleCheck(rpc::JsonContext&);
 json::Value
-doOwnerInfo(RPC::JsonContext&);
+doOwnerInfo(rpc::JsonContext&);
 json::Value
-doPathFind(RPC::JsonContext&);
+doPathFind(rpc::JsonContext&);
 json::Value
-doPause(RPC::JsonContext&);
+doPause(rpc::JsonContext&);
 json::Value
-doPeers(RPC::JsonContext&);
+doPeers(rpc::JsonContext&);
 json::Value
-doPing(RPC::JsonContext&);
+doPing(rpc::JsonContext&);
 json::Value
-doPrint(RPC::JsonContext&);
+doPrint(rpc::JsonContext&);
 json::Value
-doRandom(RPC::JsonContext&);
+doRandom(rpc::JsonContext&);
 json::Value
-doResume(RPC::JsonContext&);
+doResume(rpc::JsonContext&);
 json::Value
-doPeerReservationsAdd(RPC::JsonContext&);
+doPeerReservationsAdd(rpc::JsonContext&);
 json::Value
-doPeerReservationsDel(RPC::JsonContext&);
+doPeerReservationsDel(rpc::JsonContext&);
 json::Value
-doPeerReservationsList(RPC::JsonContext&);
+doPeerReservationsList(rpc::JsonContext&);
 json::Value
-doRipplePathFind(RPC::JsonContext&);
+doRipplePathFind(rpc::JsonContext&);
 json::Value
-doServerDefinitions(RPC::JsonContext&);
+doServerDefinitions(rpc::JsonContext&);
 json::Value
-doServerInfo(RPC::JsonContext&);  // for humans
+doServerInfo(rpc::JsonContext&);  // for humans
 json::Value
-doServerState(RPC::JsonContext&);  // for machines
+doServerState(rpc::JsonContext&);  // for machines
 json::Value
-doSign(RPC::JsonContext&);
+doSign(rpc::JsonContext&);
 json::Value
-doSignFor(RPC::JsonContext&);
+doSignFor(rpc::JsonContext&);
 json::Value
-doSimulate(RPC::JsonContext&);
+doSimulate(rpc::JsonContext&);
 json::Value
-doStop(RPC::JsonContext&);
+doStop(rpc::JsonContext&);
 json::Value
-doSubmit(RPC::JsonContext&);
+doSubmit(rpc::JsonContext&);
 json::Value
-doSubmitMultiSigned(RPC::JsonContext&);
+doSubmitMultiSigned(rpc::JsonContext&);
 json::Value
-doSubscribe(RPC::JsonContext&);
+doSubscribe(rpc::JsonContext&);
 json::Value
-doTransactionEntry(RPC::JsonContext&);
+doTransactionEntry(rpc::JsonContext&);
 json::Value
-doTxJson(RPC::JsonContext&);
+doTxJson(rpc::JsonContext&);
 json::Value
-doTxHistory(RPC::JsonContext&);
+doTxHistory(rpc::JsonContext&);
 json::Value
-doTxReduceRelay(RPC::JsonContext&);
+doTxReduceRelay(rpc::JsonContext&);
 json::Value
-doUnlList(RPC::JsonContext&);
+doUnlList(rpc::JsonContext&);
 json::Value
-doUnsubscribe(RPC::JsonContext&);
+doUnsubscribe(rpc::JsonContext&);
 json::Value
-doValidationCreate(RPC::JsonContext&);
+doValidationCreate(rpc::JsonContext&);
 json::Value
-doWalletPropose(RPC::JsonContext&);
+doWalletPropose(rpc::JsonContext&);
 json::Value
-doValidators(RPC::JsonContext&);
+doValidators(rpc::JsonContext&);
 json::Value
-doValidatorListSites(RPC::JsonContext&);
+doValidatorListSites(rpc::JsonContext&);
 json::Value
-doValidatorInfo(RPC::JsonContext&);
+doValidatorInfo(rpc::JsonContext&);
 json::Value
-doVaultInfo(RPC::JsonContext&);
+doVaultInfo(rpc::JsonContext&);
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp
index b6d2fe259f..0aa5334bd2 100644
--- a/src/xrpld/rpc/handlers/VaultInfo.cpp
+++ b/src/xrpld/rpc/handlers/VaultInfo.cpp
@@ -8,6 +8,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -25,35 +26,48 @@ parseVault(json::Value const& params, json::Value& jvResult)
     uint256 uNodeIndex = beast::kZero;
     if (hasVaultId && !hasOwner && !hasSeq)
     {
-        if (!uNodeIndex.parseHex(params[jss::vault_id].asString()))
+        // asString() throws on an object or an array, so the type comes first.
+        auto const& vaultId = params[jss::vault_id];
+        if (!vaultId.isString() || !uNodeIndex.parseHex(vaultId.asString()))
         {
-            RPC::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcInvalidParams, rpc::expectedFieldMessage(jss::vault_id, "hex string"), jvResult);
             return std::nullopt;
         }
         // else uNodeIndex holds the value we need
     }
     else if (!hasVaultId && hasOwner && hasSeq)
     {
-        auto const id = parseBase58(params[jss::owner].asString());
+        auto const& owner = params[jss::owner];
+        auto const id = owner.isString() ? parseBase58(owner.asString())
+                                         : std::optional{};
         if (!id)
         {
-            RPC::injectError(RpcActMalformed, jvResult);
-            return std::nullopt;
-        }
-        if (!(params[jss::seq].isInt() || params[jss::seq].isUInt()) ||
-            params[jss::seq].asDouble() <= 0.0 ||
-            params[jss::seq].asDouble() > double(json::Value::kMaxUInt))
-        {
-            RPC::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcActMalformed, rpc::expectedFieldMessage(jss::owner, "AccountID"), jvResult);
             return std::nullopt;
         }
 
-        uNodeIndex = keylet::vault(*id, params[jss::seq].asUInt()).key;
+        // Int and UInt are both 32 bits wide, so the type check is the only upper bound needed.
+        auto const& seqField = params[jss::seq];
+        if (!(seqField.isInt() || seqField.isUInt()) || seqField.asDouble() <= 0.0)
+        {
+            rpc::injectError(
+                RpcInvalidParams,
+                rpc::expectedFieldMessage(jss::seq, "a positive 32-bit integer"),
+                jvResult);
+            return std::nullopt;
+        }
+
+        auto const seq = SeqProxy::rawSequence(seqField.asUInt());
+        uNodeIndex = keylet::vault(*id, seq).key;
     }
     else
     {
-        // Invalid combination of fields vault_id/owner/seq
-        RPC::injectError(RpcInvalidParams, jvResult);
+        rpc::injectError(
+            RpcInvalidParams,
+            "Must specify either 'vault_id' or both 'owner' and 'seq'.",
+            jvResult);
         return std::nullopt;
     }
 
@@ -61,28 +75,33 @@ parseVault(json::Value const& params, json::Value& jvResult)
 }
 
 json::Value
-doVaultInfo(RPC::JsonContext& context)
+doVaultInfo(rpc::JsonContext& context)
 {
     std::shared_ptr lpLedger;
-    auto jvResult = RPC::lookupLedger(lpLedger, context);
+    auto jvResult = rpc::lookupLedger(lpLedger, context);
 
     if (!lpLedger)
         return jvResult;
 
-    auto const uNodeIndex = parseVault(context.params, jvResult).value_or(beast::kZero);
-    if (uNodeIndex == beast::kZero)
+    // No key means the request could not be turned into one, and parseVault has already said why.
+    auto const uNodeIndex = parseVault(context.params, jvResult);
+    if (!uNodeIndex)
+        return jvResult;
+
+    // A zero key names an entry that cannot exist, and the ledger refuses to be asked for one.
+    if (*uNodeIndex == beast::kZero)
     {
-        jvResult[jss::error] = "malformedRequest";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
-    auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex));
+    auto const sleVault = lpLedger->read(keylet::vault(*uNodeIndex));
     auto const sleIssuance = sleVault == nullptr  //
         ? nullptr
         : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
     if (!sleVault || !sleIssuance)
     {
-        jvResult[jss::error] = "entryNotFound";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountChannels.cpp b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
index 8a5c7dc6e3..f2da1e31ee 100644
--- a/src/xrpld/rpc/handlers/account/AccountChannels.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountChannels.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,9 +23,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -69,17 +67,17 @@ addChannel(json::Value& jsonLines, SLE const& line)
 //   marker: opaque                 // optional, resume previous query
 // }
 json::Value
-doAccountChannels(RPC::JsonContext& context)
+doAccountChannels(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
@@ -97,7 +95,7 @@ doAccountChannels(RPC::JsonContext& context)
     if (params.isMember(jss::destination_account))
     {
         if (!params[jss::destination_account].isString())
-            return RPC::invalidFieldError(jss::destination_account);
+            return rpc::invalidFieldError(jss::destination_account);
         strDst = params[jss::destination_account].asString();
     }
 
@@ -108,7 +106,7 @@ doAccountChannels(RPC::JsonContext& context)
         return rpcError(RpcActMalformed);
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kAccountChannels, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kAccountChannels, context))
         return *err;
 
     json::Value jsonChannels{json::ValueType::Array};
@@ -126,10 +124,10 @@ doAccountChannels(RPC::JsonContext& context)
     if (params.isMember(jss::marker))
     {
         if (!params[jss::marker].isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
@@ -141,14 +139,10 @@ doAccountChannels(RPC::JsonContext& context)
         if (!std::getline(marker, value, ','))
             return rpcError(RpcInvalidParams);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
             return rpcError(RpcInvalidParams);
-        }
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
@@ -157,7 +151,7 @@ doAccountChannels(RPC::JsonContext& context)
         if (!sle)
             return rpcError(RpcInvalidParams);
 
-        if (!RPC::isRelatedToAccount(*ledger, sle, accountID))
+        if (!rpc::isRelatedToAccount(*ledger, sle, accountID))
             return rpcError(RpcInvalidParams);
     }
 
@@ -182,7 +176,7 @@ doAccountChannels(RPC::JsonContext& context)
                 if (++count == limit)
                 {
                     marker = sleCur->key();
-                    nextHint = RPC::getStartHint(sleCur, visitData.accountID);
+                    nextHint = rpc::getStartHint(sleCur, visitData.accountID);
                 }
 
                 if (count <= limit && sleCur->getType() == ltPAYCHAN &&
@@ -213,7 +207,7 @@ doAccountChannels(RPC::JsonContext& context)
     for (auto const& item : visitData.items)
         addChannel(jsonChannels, *item);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     result[jss::channels] = std::move(jsonChannels);
     return result;
 }
diff --git a/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp b/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp
index 058c10e224..d9cd41cbbc 100644
--- a/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp
@@ -19,30 +19,30 @@
 namespace xrpl {
 
 json::Value
-doAccountCurrencies(RPC::JsonContext& context)
+doAccountCurrencies(rpc::JsonContext& context)
 {
     auto& params = context.params;
 
     if (!(params.isMember(jss::account) || params.isMember(jss::ident)))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     std::string strIdent;
     if (params.isMember(jss::account))
     {
         if (!params[jss::account].isString())
-            return RPC::invalidFieldError(jss::account);
+            return rpc::invalidFieldError(jss::account);
         strIdent = params[jss::account].asString();
     }
     else if (params.isMember(jss::ident))
     {
         if (!params[jss::ident].isString())
-            return RPC::invalidFieldError(jss::ident);
+            return rpc::invalidFieldError(jss::ident);
         strIdent = params[jss::ident].asString();
     }
 
     // Get the current ledger
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
@@ -50,7 +50,7 @@ doAccountCurrencies(RPC::JsonContext& context)
     auto id = parseBase58(strIdent);
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
index 3a96593452..2276f98a0e 100644
--- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
@@ -5,6 +5,8 @@
 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -17,19 +19,19 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -50,22 +52,16 @@ void
 injectSLE(json::Value& jv, SLE const& sle)
 {
     jv = sle.getJson(JsonOptions::Values::None);
-    if (sle.getType() == ltACCOUNT_ROOT)
+    XRPL_ASSERT(sle.getType() == ltACCOUNT_ROOT, "xrpl::injectSLE : sle is account root");
+    if (sle.isFieldPresent(sfEmailHash))
     {
-        if (sle.isFieldPresent(sfEmailHash))
-        {
-            auto const& hash = sle.getFieldH128(sfEmailHash);
-            Blob const b(hash.begin(), hash.end());
-            std::string md5 = strHex(makeSlice(b));
-            boost::to_lower(md5);
-            // VFALCO TODO Give a name to this constant and move it
-            //             to a more visible location.
-            jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5);
-        }
-    }
-    else
-    {
-        jv[jss::Invalid] = true;
+        auto const& hash = sle.getFieldH128(sfEmailHash);
+        Blob const b(hash.begin(), hash.end());
+        std::string md5 = strHex(makeSlice(b));
+        md5 = toLower(md5);
+        // VFALCO TODO Give a name to this constant and move it
+        //             to a more visible location.
+        jv[jss::urlgravatar] = std::format("https://www.gravatar.com/avatar/{}", md5);
     }
 }
 
@@ -84,7 +80,7 @@ injectSLE(json::Value& jv, SLE const& sle)
 
 // TODO(tom): what is that "default"?
 json::Value
-doAccountInfo(RPC::JsonContext& context)
+doAccountInfo(rpc::JsonContext& context)
 {
     auto& params = context.params;
 
@@ -92,22 +88,22 @@ doAccountInfo(RPC::JsonContext& context)
     if (params.isMember(jss::account))
     {
         if (!params[jss::account].isString())
-            return RPC::invalidFieldError(jss::account);
+            return rpc::invalidFieldError(jss::account);
         strIdent = params[jss::account].asString();
     }
     else if (params.isMember(jss::ident))
     {
         if (!params[jss::ident].isString())
-            return RPC::invalidFieldError(jss::ident);
+            return rpc::invalidFieldError(jss::ident);
         strIdent = params[jss::ident].asString();
     }
     else
     {
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
     }
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
 
     if (!ledger)
         return result;
@@ -116,34 +112,42 @@ doAccountInfo(RPC::JsonContext& context)
     auto id = parseBase58(strIdent);
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
 
-    static constexpr std::array, 9> kLsFlags{
-        {{"defaultRipple", lsfDefaultRipple},
-         {"depositAuth", lsfDepositAuth},
-         {"disableMasterKey", lsfDisableMaster},
-         {"disallowIncomingXRP", lsfDisallowXRP},
-         {"globalFreeze", lsfGlobalFreeze},
-         {"noFreeze", lsfNoFreeze},
-         {"passwordSpent", lsfPasswordSpent},
-         {"requireAuthorization", lsfRequireAuth},
-         {"requireDestinationTag", lsfRequireDestTag}}};
-
-    static constexpr std::array, 4>
-        kDisallowIncomingFlags{
-            {{"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
+    // Flags that are always reported.
+    static constexpr auto kAccountRootFlags =
+        std::to_array>(
+            {{"allowTrustLineClawback", lsfAllowTrustLineClawback},
+             {"defaultRipple", lsfDefaultRipple},
+             {"depositAuth", lsfDepositAuth},
+             {"disableMasterKey", lsfDisableMaster},
              {"disallowIncomingCheck", lsfDisallowIncomingCheck},
+             {"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
              {"disallowIncomingPayChan", lsfDisallowIncomingPayChan},
-             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}}};
+             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline},
+             {"disallowIncomingXRP", lsfDisallowXRP},
+             {"globalFreeze", lsfGlobalFreeze},
+             {"noFreeze", lsfNoFreeze},
+             {"passwordSpent", lsfPasswordSpent},
+             {"requireAuthorization", lsfRequireAuth},
+             {"requireDestinationTag", lsfRequireDestTag}});
 
-    static constexpr std::pair kAllowTrustLineClawbackFlag{
-        "allowTrustLineClawback", lsfAllowTrustLineClawback};
+    // Flags that are only reported when their amendment is enabled. This can't be `constexpr`,
+    // since the amendment IDs are computed at runtime.
+    static auto const kAmendmentGatedFlags =
+        std::to_array>(
+            {{"allowTrustLineLocking", lsfAllowTrustLineLocking, featureTokenEscrow}});
 
-    static constexpr std::pair kAllowTrustLineLockingFlag{
-        "allowTrustLineLocking", lsfAllowTrustLineLocking};
+    // Every `AccountRoot` flag must be reported by `account_info`, so if a new flag is added, it
+    // needs to be added to one of the arrays above. This can't be a `static_assert` because
+    // `getAccountRootFlags()` builds its map at runtime.
+    XRPL_ASSERT_PARTS(
+        kAccountRootFlags.size() + kAmendmentGatedFlags.size() == getAccountRootFlags().size(),
+        "xrpl::doAccountInfo",
+        "number of account flags");
 
     auto const sleAccepted = ledger->read(keylet::account(accountID));
     if (sleAccepted)
@@ -154,7 +158,7 @@ doAccountInfo(RPC::JsonContext& context)
         {
             // It doesn't make sense to request the queue
             // with any closed or validated ledger.
-            RPC::injectError(RpcInvalidParams, result);
+            rpc::injectError(RpcInvalidParams, result);
             return result;
         }
 
@@ -163,19 +167,13 @@ doAccountInfo(RPC::JsonContext& context)
         result[jss::account_data] = jvAccepted;
 
         json::Value acctFlags{json::ValueType::Object};
-        for (auto const& lsf : kLsFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
+        for (auto const& [name, flag] : kAccountRootFlags)
+            acctFlags[name.data()] = sleAccepted->isFlag(flag);
 
-        for (auto const& lsf : kDisallowIncomingFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
-
-        acctFlags[kAllowTrustLineClawbackFlag.first.data()] =
-            sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second);
-
-        if (ledger->rules().enabled(featureTokenEscrow))
+        for (auto const& [name, flag, amendment] : kAmendmentGatedFlags)
         {
-            acctFlags[kAllowTrustLineLockingFlag.first.data()] =
-                sleAccepted->isFlag(kAllowTrustLineLockingFlag.second);
+            if (ledger->rules().enabled(amendment))
+                acctFlags[name.data()] = sleAccepted->isFlag(flag);
         }
 
         result[jss::account_flags] = std::move(acctFlags);
@@ -206,7 +204,7 @@ doAccountInfo(RPC::JsonContext& context)
         if (context.apiVersion > 1u && params.isMember(jss::signer_lists) &&
             !params[jss::signer_lists].isBool())
         {
-            RPC::injectError(RpcInvalidParams, result);
+            rpc::injectError(RpcInvalidParams, result);
             return result;
         }
 
@@ -260,7 +258,7 @@ doAccountInfo(RPC::JsonContext& context)
 
                 // We expect txs to be returned sorted by SeqProxy.  Verify
                 // that with a couple of asserts.
-                SeqProxy prevSeqProxy = SeqProxy::sequence(0);
+                SeqProxy prevSeqProxy = SeqProxy::rawSequence(0);
                 for (auto const& tx : txs)
                 {
                     json::Value jvTx = json::ValueType::Object;
@@ -331,7 +329,7 @@ doAccountInfo(RPC::JsonContext& context)
     else
     {
         result[jss::account] = toBase58(accountID);
-        RPC::injectError(RpcActNotFound, result);
+        rpc::injectError(RpcActNotFound, result);
     }
 
     return result;
diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp
index e69f70ca5a..ac98e271b6 100644
--- a/src/xrpld/rpc/handlers/account/AccountLines.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,9 +23,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -82,24 +80,24 @@ addLine(json::Value& jsonLines, RPCTrustLine const& line)
 //   this account's side)
 // }
 json::Value
-doAccountLines(RPC::JsonContext& context)
+doAccountLines(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
     auto id = parseBase58(params[jss::account].asString());
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
@@ -109,19 +107,24 @@ doAccountLines(RPC::JsonContext& context)
 
     std::string strPeer;
     if (params.isMember(jss::peer))
+    {
+        if (!params[jss::peer].isString())
+            return rpc::invalidFieldError(jss::peer);
+
         strPeer = params[jss::peer].asString();
+    }
 
     auto const raPeerAccount = [&]() -> std::optional {
         return strPeer.empty() ? std::nullopt : parseBase58(strPeer);
     }();
     if (!strPeer.empty() && !raPeerAccount)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kAccountLines, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kAccountLines, context))
         return *err;
 
     // this flag allows the requester to ask incoming trustlines in default
@@ -150,10 +153,10 @@ doAccountLines(RPC::JsonContext& context)
     if (params.isMember(jss::marker))
     {
         if (!params[jss::marker].isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
@@ -165,14 +168,10 @@ doAccountLines(RPC::JsonContext& context)
         if (!std::getline(marker, value, ','))
             return rpcError(RpcInvalidParams);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
             return rpcError(RpcInvalidParams);
-        }
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
@@ -181,7 +180,7 @@ doAccountLines(RPC::JsonContext& context)
         if (!sle)
             return rpcError(RpcInvalidParams);
 
-        if (!RPC::isRelatedToAccount(*ledger, sle, accountID))
+        if (!rpc::isRelatedToAccount(*ledger, sle, accountID))
             return rpcError(RpcInvalidParams);
     }
 
@@ -207,7 +206,7 @@ doAccountLines(RPC::JsonContext& context)
                     if (++count == limit)
                     {
                         marker = sleCur->key();
-                        nextHint = RPC::getStartHint(sleCur, visitData.accountID);
+                        nextHint = rpc::getStartHint(sleCur, visitData.accountID);
                     }
 
                     if (sleCur->getType() != ltRIPPLE_STATE)
@@ -259,7 +258,7 @@ doAccountLines(RPC::JsonContext& context)
     for (auto const& item : visitData.items)
         addLine(jsonLines, item);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     return result;
 }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountNFTs.cpp b/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
index ea9bec0f45..580b93caa3 100644
--- a/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountNFTs.cpp
@@ -35,14 +35,14 @@ namespace xrpl {
  * }
  */
 json::Value
-doAccountNFTs(RPC::JsonContext& context)
+doAccountNFTs(rpc::JsonContext& context)
 {
     auto const& params = context.params;
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     auto id = parseBase58(params[jss::account].asString());
     if (!id)
@@ -51,7 +51,7 @@ doAccountNFTs(RPC::JsonContext& context)
     }
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (ledger == nullptr)
         return result;
     auto const accountID{id.value()};
@@ -60,7 +60,7 @@ doAccountNFTs(RPC::JsonContext& context)
         return rpcError(RpcActNotFound);
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kAccountNfTokens, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kAccountNfTokens, context))
         return *err;
 
     uint256 marker;
@@ -70,10 +70,10 @@ doAccountNFTs(RPC::JsonContext& context)
     {
         auto const& m = params[jss::marker];
         if (!m.isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         if (!marker.parseHex(m.asString()))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
     }
 
     auto const first = keylet::nftokenPage(keylet::nftokenPageMin(accountID), marker);
@@ -125,7 +125,7 @@ doAccountNFTs(RPC::JsonContext& context)
             }
 
             if (markerSet && !markerFound)
-                return RPC::invalidFieldError(jss::marker);
+                return rpc::invalidFieldError(jss::marker);
 
             pastMarker = true;
 
@@ -160,10 +160,10 @@ doAccountNFTs(RPC::JsonContext& context)
     }
 
     if (markerSet && !markerFound)
-        return RPC::invalidFieldError(jss::marker);
+        return rpc::invalidFieldError(jss::marker);
 
     result[jss::account] = toBase58(accountID);
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     return result;
 }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountObjects.cpp b/src/xrpld/rpc/handlers/account/AccountObjects.cpp
index 4a34ff02fc..e855ed65e6 100644
--- a/src/xrpld/rpc/handlers/account/AccountObjects.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountObjects.cpp
@@ -5,6 +5,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -191,6 +192,13 @@ getAccountObjects(
         for (; entryIter != dirEntries.end(); ++entryIter)
         {
             auto const sleNode = ledger.read(keylet::child(*entryIter));
+            if (!sleNode)
+            {
+                // LCOV_EXCL_START
+                UNREACHABLE("xrpl::doAccountObjects : null SLE");
+                continue;
+                // LCOV_EXCL_STOP
+            }
 
             bool canAppend = true;
 
@@ -257,24 +265,24 @@ getAccountObjects(
 }
 
 json::Value
-doAccountObjects(RPC::JsonContext& context)
+doAccountObjects(rpc::JsonContext& context)
 {
     auto const& params = context.params;
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (ledger == nullptr)
         return result;
 
     auto const id = parseBase58(params[jss::account].asString());
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
@@ -323,10 +331,10 @@ doAccountObjects(RPC::JsonContext& context)
     }
     else
     {
-        auto [rpcStatus, type] = RPC::chooseLedgerEntryType(params);
+        auto [rpcStatus, type] = rpc::chooseLedgerEntryType(params);
 
-        if (!RPC::isAccountObjectsValidType(type))
-            return RPC::invalidFieldError(jss::type);
+        if (!rpc::isAccountObjectsValidType(type))
+            return rpc::invalidFieldError(jss::type);
 
         if (rpcStatus)
         {
@@ -341,7 +349,7 @@ doAccountObjects(RPC::JsonContext& context)
     }
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kAccountObjects, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kAccountObjects, context))
         return *err;
 
     uint256 dirIndex;
@@ -350,18 +358,18 @@ doAccountObjects(RPC::JsonContext& context)
     {
         auto const& marker = params[jss::marker];
         if (!marker.isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         auto const& markerStr = marker.asString();
         auto const& idx = markerStr.find(',');
         if (idx == std::string::npos)
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
 
         if (!dirIndex.parseHex(markerStr.substr(0, idx)))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
 
         if (!entryIndex.parseHex(markerStr.substr(idx + 1)))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
     }
 
     std::optional sponsoredFilter;
@@ -369,17 +377,17 @@ doAccountObjects(RPC::JsonContext& context)
     {
         auto const& sponsoredJv = params[jss::sponsored];
         if (!sponsoredJv.isBool())
-            return RPC::expectedFieldError(jss::sponsored, "boolean");
+            return rpc::expectedFieldError(jss::sponsored, "boolean");
 
         sponsoredFilter = sponsoredJv.asBool();
     }
 
     if (!getAccountObjects(
             *ledger, accountID, typeFilter, dirIndex, entryIndex, limit, sponsoredFilter, result))
-        return RPC::invalidFieldError(jss::marker);
+        return rpc::invalidFieldError(jss::marker);
 
     result[jss::account] = toBase58(accountID);
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     return result;
 }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountOffers.cpp b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
index 4829ff56b1..a7933f65a7 100644
--- a/src/xrpld/rpc/handlers/account/AccountOffers.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountOffers.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -20,9 +21,6 @@
 #include 
 #include 
 
-#include 
-#include 
-
 #include 
 #include 
 #include 
@@ -54,24 +52,24 @@ appendOfferJson(SLE::const_ref offer, json::Value& offers)
 //   marker: opaque                 // optional, resume previous query
 // }
 json::Value
-doAccountOffers(RPC::JsonContext& context)
+doAccountOffers(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
     auto id = parseBase58(params[jss::account].asString());
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
@@ -83,7 +81,7 @@ doAccountOffers(RPC::JsonContext& context)
         return rpcError(RpcActNotFound);
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kAccountOffers, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kAccountOffers, context))
         return *err;
 
     json::Value& jsonOffers(result[jss::offers] = json::ValueType::Array);
@@ -94,29 +92,25 @@ doAccountOffers(RPC::JsonContext& context)
     if (params.isMember(jss::marker))
     {
         if (!params[jss::marker].isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         // Marker is composed of a comma separated index and start hint. The
-        // former will be read as hex, and the latter using boost lexical cast.
+        // former will be read as hex, and the latter as a decimal integer.
         std::stringstream marker(params[jss::marker].asString());
         std::string value;
         if (!std::getline(marker, value, ','))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
 
         if (!startAfter.parseHex(value))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
 
         if (!std::getline(marker, value, ','))
-            return RPC::invalidFieldError(jss::marker);
+            return rpc::invalidFieldError(jss::marker);
 
-        try
-        {
-            startHint = boost::lexical_cast(value);
-        }
-        catch (boost::bad_lexical_cast&)
-        {
-            return RPC::invalidFieldError(jss::marker);
-        }
+        auto const hint = toUInt64(value);
+        if (!hint.has_value())
+            return rpc::invalidFieldError(jss::marker);
+        startHint = *hint;
 
         // We then must check if the object pointed to by the marker is actually
         // owned by the account in the request.
@@ -125,7 +119,7 @@ doAccountOffers(RPC::JsonContext& context)
         if (!sle)
             return rpcError(RpcInvalidParams);
 
-        if (!RPC::isRelatedToAccount(*ledger, sle, accountID))
+        if (!rpc::isRelatedToAccount(*ledger, sle, accountID))
             return rpcError(RpcInvalidParams);
     }
 
@@ -150,7 +144,7 @@ doAccountOffers(RPC::JsonContext& context)
                 if (++count == limit)
                 {
                     marker = sle->key();
-                    nextHint = RPC::getStartHint(sle, accountID);
+                    nextHint = rpc::getStartHint(sle, accountID);
                 }
 
                 if (count <= limit && sle->getType() == ltOFFER)
@@ -176,7 +170,7 @@ doAccountOffers(RPC::JsonContext& context)
     for (auto const& offer : offers)
         appendOfferJson(offer, jsonOffers);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     return result;
 }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountTx.cpp b/src/xrpld/rpc/handlers/account/AccountTx.cpp
index c43f560861..7b0c34e048 100644
--- a/src/xrpld/rpc/handlers/account/AccountTx.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountTx.cpp
@@ -43,11 +43,11 @@ static std::expected
 parseDelegateFilter(json::Value const& delegateNode)
 {
     if (!delegateNode.isObject())
-        return std::unexpected(RPC::invalidFieldError(jss::delegate));
+        return std::unexpected(rpc::invalidFieldError(jss::delegate));
 
     if (!delegateNode.isMember(jss::delegate_filter) ||
         !delegateNode[jss::delegate_filter].isString())
-        return std::unexpected(RPC::invalidFieldError(jss::delegate_filter));
+        return std::unexpected(rpc::invalidFieldError(jss::delegate_filter));
 
     auto const& delegateFilterStr = delegateNode[jss::delegate_filter].asString();
 
@@ -58,7 +58,7 @@ parseDelegateFilter(json::Value const& delegateNode)
         if (delegateFilterStr == "authorizer")
             return DelegateType::Authorizer;
 
-        return std::unexpected(RPC::invalidFieldError(jss::delegate_filter));
+        return std::unexpected(rpc::invalidFieldError(jss::delegate_filter));
     }();
 
     if (!typeResult)
@@ -70,7 +70,7 @@ parseDelegateFilter(json::Value const& delegateNode)
     if (delegateNode.isMember(jss::counter_party))
     {
         if (!delegateNode[jss::counter_party].isString())
-            return std::unexpected(RPC::invalidFieldError(jss::counter_party));
+            return std::unexpected(rpc::invalidFieldError(jss::counter_party));
 
         counterparty = parseBase58(delegateNode[jss::counter_party].asString());
 
@@ -90,7 +90,7 @@ using LedgerSpecifier = RelationalDatabase::LedgerSpecifier;
 
 // parses args into a ledger specifier, or returns a Json object on error
 std::variant, json::Value>
-parseLedgerArgs(RPC::Context& context, json::Value const& params)
+parseLedgerArgs(rpc::Context& context, json::Value const& params)
 {
     json::Value response;
     // if ledger_index_min or max is specified, then ledger_hash or ledger_index
@@ -100,7 +100,7 @@ parseLedgerArgs(RPC::Context& context, json::Value const& params)
         if ((params.isMember(jss::ledger_index_min) || params.isMember(jss::ledger_index_max)) &&
             (params.isMember(jss::ledger_hash) || params.isMember(jss::ledger_index)))
         {
-            RPC::Status const status{RpcInvalidParams, "invalidParams"};
+            rpc::Status const status{RpcInvalidParams, "invalidParams"};
             status.inject(response);
             return response;
         }
@@ -123,7 +123,7 @@ parseLedgerArgs(RPC::Context& context, json::Value const& params)
         auto& hashValue = params[jss::ledger_hash];
         if (!hashValue.isString())
         {
-            RPC::Status const status{RpcInvalidParams, "ledgerHashNotString"};
+            rpc::Status const status{RpcInvalidParams, "ledgerHashNotString"};
             status.inject(response);
             return response;
         }
@@ -131,7 +131,7 @@ parseLedgerArgs(RPC::Context& context, json::Value const& params)
         LedgerHash hash;
         if (!hash.parseHex(hashValue.asString()))
         {
-            RPC::Status const status{RpcInvalidParams, "ledgerHashMalformed"};
+            rpc::Status const status{RpcInvalidParams, "ledgerHashMalformed"};
             status.inject(response);
             return response;
         }
@@ -162,7 +162,7 @@ parseLedgerArgs(RPC::Context& context, json::Value const& params)
             }
             else
             {
-                RPC::Status const status{RpcInvalidParams, "ledger_index string malformed"};
+                rpc::Status const status{RpcInvalidParams, "ledger_index string malformed"};
                 status.inject(response);
                 return response;
             }
@@ -172,8 +172,8 @@ parseLedgerArgs(RPC::Context& context, json::Value const& params)
     return std::optional{};
 }
 
-std::variant
-getLedgerRange(RPC::Context& context, std::optional const& ledgerSpecifier)
+std::variant
+getLedgerRange(rpc::Context& context, std::optional const& ledgerSpecifier)
 {
     std::uint32_t uValidatedMin = 0;
     std::uint32_t uValidatedMax = 0;
@@ -193,7 +193,7 @@ getLedgerRange(RPC::Context& context, std::optional const& ledg
     if (ledgerSpecifier)
     {
         auto status = std::visit(
-            [&](auto const& ls) -> RPC::Status {
+            [&](auto const& ls) -> rpc::Status {
                 using T = std::decay_t;
                 if constexpr (std::is_same_v)
                 {
@@ -241,7 +241,7 @@ getLedgerRange(RPC::Context& context, std::optional const& ledg
                     }
                     uLedgerMin = uLedgerMax = ledgerView->header().seq;
                 }
-                return RPC::Status::kOK;
+                return rpc::Status::kOK;
             },
             *ledgerSpecifier);
 
@@ -251,15 +251,15 @@ getLedgerRange(RPC::Context& context, std::optional const& ledg
     return LedgerRange{.min = uLedgerMin, .max = uLedgerMax};
 }
 
-std::pair
-doAccountTxHelp(RPC::Context& context, AccountTxArgs const& args)
+std::pair
+doAccountTxHelp(rpc::Context& context, AccountTxArgs const& args)
 {
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
 
     AccountTxResult result;
 
     auto lgrRange = getLedgerRange(context, args.ledger);
-    if (auto stat = std::get_if(&lgrRange))
+    if (auto stat = std::get_if(&lgrRange))
     {
         // An error occurred getting the requested ledger range
         return {result, *stat};
@@ -318,12 +318,12 @@ doAccountTxHelp(RPC::Context& context, AccountTxArgs const& args)
 
 json::Value
 populateJsonResponse(
-    std::pair const& res,
+    std::pair const& res,
     AccountTxArgs const& args,
-    RPC::JsonContext const& context)
+    rpc::JsonContext const& context)
 {
     json::Value response;
-    RPC::Status const& error = res.second;
+    rpc::Status const& error = res.second;
     if (error.toErrorCode() != RpcSuccess)
     {
         error.inject(response);
@@ -374,13 +374,13 @@ populateJsonResponse(
                     }
 
                     auto const& sttx = txn->getSTransaction();
-                    RPC::insertDeliverMax(jvObj[jsonTx], sttx->getTxnType(), context.apiVersion);
+                    rpc::insertDeliverMax(jvObj[jsonTx], sttx->getTxnType(), context.apiVersion);
                     if (txnMeta)
                     {
                         jvObj[jss::meta] = txnMeta->getJson(JsonOptions::Values::IncludeDate);
                         insertDeliveredAmount(jvObj[jss::meta], context, txn, *txnMeta);
-                        RPC::insertNFTSyntheticInJson(jvObj, sttx, *txnMeta);
-                        RPC::insertMPTokenIssuanceID(jvObj[jss::meta], sttx, *txnMeta);
+                        rpc::insertNFTSyntheticInJson(jvObj, sttx, *txnMeta);
+                        rpc::insertMPTokenIssuanceID(jvObj[jss::meta], sttx, *txnMeta);
                     }
                     else
                     {
@@ -445,7 +445,7 @@ populateJsonResponse(
 // delegate-filtered query is only valid for a follow-up request that repeats
 // the same `delegate` object
 json::Value
-doAccountTx(RPC::JsonContext& context)
+doAccountTx(rpc::JsonContext& context)
 {
     if (!context.app.config().useTxTables())
         return rpcError(RpcNotEnabled);
@@ -460,24 +460,24 @@ doAccountTx(RPC::JsonContext& context)
     // onwards only
     if (context.apiVersion > 1u && params.isMember(jss::binary) && !params[jss::binary].isBool())
     {
-        return RPC::invalidFieldError(jss::binary);
+        return rpc::invalidFieldError(jss::binary);
     }
     if (context.apiVersion > 1u && params.isMember(jss::forward) && !params[jss::forward].isBool())
     {
-        return RPC::invalidFieldError(jss::forward);
+        return rpc::invalidFieldError(jss::forward);
     }
 
-    if (auto const err = RPC::readLimitField(args.limit, RPC::Tuning::kAccountTx, context))
+    if (auto const err = rpc::readLimitField(args.limit, rpc::tuning::kAccountTx, context))
         return *err;
 
     args.binary = params.isMember(jss::binary) && params[jss::binary].asBool();
     args.forward = params.isMember(jss::forward) && params[jss::forward].asBool();
 
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     auto const account = parseBase58(params[jss::account].asString());
     if (!account)
@@ -500,7 +500,7 @@ doAccountTx(RPC::JsonContext& context)
             !token[jss::ledger].isConvertibleTo(json::ValueType::UInt) ||
             !token[jss::seq].isConvertibleTo(json::ValueType::UInt))
         {
-            RPC::Status const status{
+            rpc::Status const status{
                 RpcInvalidParams,
                 "invalid marker. Provide ledger index via ledger field, and "
                 "transaction sequence number via seq field"};
@@ -534,7 +534,7 @@ doAccountTx(RPC::JsonContext& context)
             params[jss::marker][jss::delegate].asBool();
         if (markerFromDelegate != args.delegate.has_value())
         {
-            RPC::Status const status{
+            rpc::Status const status{
                 RpcInvalidParams,
                 "Do not mix delegate and non-delegate pagination markers in account_tx; "
                 "repeat the same `delegate` object when using a delegate marker."};
diff --git a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
index a6730c8e2b..041e878a3f 100644
--- a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
+++ b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp
@@ -49,19 +49,25 @@ namespace xrpl {
 // gateway_balances []  [ [ ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
 
     if (!ledger)
         return result;
 
     if (!(params.isMember(jss::account) || params.isMember(jss::ident)))
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
+
+    if (params.isMember(jss::account) && !params[jss::account].isString())
+        return rpc::invalidFieldError(jss::account);
+
+    if (params.isMember(jss::ident) && !params[jss::ident].isString())
+        return rpc::invalidFieldError(jss::ident);
 
     std::string const strIdent(
         params.isMember(jss::account) ? params[jss::account].asString()
@@ -72,13 +78,13 @@ doGatewayBalances(RPC::JsonContext& context)
     if (!id)
         return rpcError(RpcActMalformed);
     auto const accountID{id.value()};
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
+    context.loadType = resource::kFeeHeavyBurdenRpc;
 
     result[jss::account] = toBase58(accountID);
 
     if (context.apiVersion > 1u && !ledger->exists(keylet::account(accountID)))
     {
-        RPC::injectError(RpcActNotFound, result);
+        rpc::injectError(RpcActNotFound, result);
         return result;
     }
 
@@ -126,11 +132,11 @@ doGatewayBalances(RPC::JsonContext& context)
             // not have currency issued by the account from the request.
             if (context.apiVersion < 2u)
             {
-                RPC::injectError(RpcInvalidHotwallet, result);
+                rpc::injectError(RpcInvalidHotwallet, result);
             }
             else
             {
-                RPC::injectError(RpcInvalidParams, result);
+                rpc::injectError(RpcInvalidParams, result);
             }
             return result;
         }
diff --git a/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp b/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
index d8bb65aba9..4be6e6f1af 100644
--- a/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
+++ b/src/xrpld/rpc/handlers/account/NoRippleCheck.cpp
@@ -26,7 +26,7 @@ namespace xrpl {
 
 static void
 fillTransaction(
-    RPC::JsonContext& context,
+    rpc::JsonContext& context,
     json::Value& txArray,
     AccountID const& accountID,
     std::uint32_t& sequence,
@@ -49,17 +49,17 @@ fillTransaction(
 //   transactions: true             // optional, recommend transactions
 // }
 json::Value
-doNoRippleCheck(RPC::JsonContext& context)
+doNoRippleCheck(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     if (!params.isMember(jss::account))
-        return RPC::missingFieldError("account");
+        return rpc::missingFieldError("account");
 
     if (!params.isMember("role"))
-        return RPC::missingFieldError("role");
+        return rpc::missingFieldError("role");
 
     if (!params[jss::account].isString())
-        return RPC::invalidFieldError(jss::account);
+        return rpc::invalidFieldError(jss::account);
 
     bool roleGateway = false;
     {
@@ -70,12 +70,12 @@ doNoRippleCheck(RPC::JsonContext& context)
         }
         else if (role != "user")
         {
-            return RPC::invalidFieldError("role");
+            return rpc::invalidFieldError("role");
         }
     }
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kNoRippleCheck, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kNoRippleCheck, context))
         return *err;
 
     bool transactions = false;
@@ -89,11 +89,11 @@ doNoRippleCheck(RPC::JsonContext& context)
     if (context.apiVersion > 1u && params.isMember(jss::transactions) &&
         !params[jss::transactions].isBool())
     {
-        return RPC::invalidFieldError(jss::transactions);
+        return rpc::invalidFieldError(jss::transactions);
     }
 
     std::shared_ptr ledger;
-    auto result = RPC::lookupLedger(ledger, context);
+    auto result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
@@ -104,7 +104,7 @@ doNoRippleCheck(RPC::JsonContext& context)
     auto id = parseBase58(params[jss::account].asString());
     if (!id)
     {
-        RPC::injectError(RpcActMalformed, result);
+        rpc::injectError(RpcActMalformed, result);
         return result;
     }
     auto const accountID{id.value()};
diff --git a/src/xrpld/rpc/handlers/account/OwnerInfo.cpp b/src/xrpld/rpc/handlers/account/OwnerInfo.cpp
index 7cfed3cf53..2f5f76c619 100644
--- a/src/xrpld/rpc/handlers/account/OwnerInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/OwnerInfo.cpp
@@ -17,11 +17,11 @@ namespace xrpl {
 //   'ident' : ,
 // }
 json::Value
-doOwnerInfo(RPC::JsonContext& context)
+doOwnerInfo(rpc::JsonContext& context)
 {
     if (!context.params.isMember(jss::account) && !context.params.isMember(jss::ident))
     {
-        return RPC::missingFieldError(jss::account);
+        return rpc::missingFieldError(jss::account);
     }
 
     std::string const strIdent = context.params.isMember(jss::account)
diff --git a/src/xrpld/rpc/handlers/admin/BlackList.cpp b/src/xrpld/rpc/handlers/admin/BlackList.cpp
index 5065a41ec4..7a72651373 100644
--- a/src/xrpld/rpc/handlers/admin/BlackList.cpp
+++ b/src/xrpld/rpc/handlers/admin/BlackList.cpp
@@ -8,7 +8,7 @@
 namespace xrpl {
 
 json::Value
-doBlackList(RPC::JsonContext& context)
+doBlackList(rpc::JsonContext& context)
 {
     auto& rm = context.app.getResourceManager();
     if (context.params.isMember(jss::threshold))
diff --git a/src/xrpld/rpc/handlers/admin/UnlList.cpp b/src/xrpld/rpc/handlers/admin/UnlList.cpp
index c3835c7ae0..61b5e4c640 100644
--- a/src/xrpld/rpc/handlers/admin/UnlList.cpp
+++ b/src/xrpld/rpc/handlers/admin/UnlList.cpp
@@ -12,7 +12,7 @@
 namespace xrpl {
 
 json::Value
-doUnlList(RPC::JsonContext& context)
+doUnlList(rpc::JsonContext& context)
 {
     json::Value obj(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
index 9ec1157e66..5c96bfb215 100644
--- a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
+++ b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp
@@ -3,14 +3,13 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 
-#include 
-
 #include 
 #include 
 #include 
@@ -19,10 +18,10 @@ namespace xrpl {
 
 // can_delete [||now|always|never]
 json::Value
-doCanDelete(RPC::JsonContext& context)
+doCanDelete(rpc::JsonContext& context)
 {
     if (!context.app.getSHAMapStore().advisoryDelete())
-        return RPC::makeError(RpcNotEnabled);
+        return rpc::makeError(RpcNotEnabled);
 
     json::Value ret(json::ValueType::Object);
 
@@ -38,7 +37,7 @@ doCanDelete(RPC::JsonContext& context)
         else
         {
             std::string canDeleteStr = canDelete.asString();
-            boost::to_lower(canDeleteStr);
+            canDeleteStr = toLower(canDeleteStr);
 
             if (canDeleteStr.find_first_not_of("0123456789") == std::string::npos)
             {
@@ -56,20 +55,20 @@ doCanDelete(RPC::JsonContext& context)
             {
                 canDeleteSeq = context.app.getSHAMapStore().getLastRotated();
                 if (canDeleteSeq == 0u)
-                    return RPC::makeError(RpcNotReady);
+                    return rpc::makeError(RpcNotReady);
             }
             else if (uint256 lh; lh.parseHex(canDeleteStr))
             {
                 auto ledger = context.ledgerMaster.getLedgerByHash(lh);
 
                 if (!ledger)
-                    return RPC::makeError(RpcLgrNotFound, "ledgerNotFound");
+                    return rpc::makeError(RpcLgrNotFound, "ledgerNotFound");
 
                 canDeleteSeq = ledger->header().seq;
             }
             else
             {
-                return RPC::makeError(RpcInvalidParams);
+                return rpc::makeError(RpcInvalidParams);
             }
         }
 
diff --git a/src/xrpld/rpc/handlers/admin/data/LedgerCleaner.cpp b/src/xrpld/rpc/handlers/admin/data/LedgerCleaner.cpp
index a62ca44cca..78a2fec410 100644
--- a/src/xrpld/rpc/handlers/admin/data/LedgerCleaner.cpp
+++ b/src/xrpld/rpc/handlers/admin/data/LedgerCleaner.cpp
@@ -9,10 +9,10 @@
 namespace xrpl {
 
 json::Value
-doLedgerCleaner(RPC::JsonContext& context)
+doLedgerCleaner(rpc::JsonContext& context)
 {
     context.app.getLedgerCleaner().clean(context.params);
-    return RPC::makeObjectValue("Cleaner configured");
+    return rpc::makeObjectValue("Cleaner configured");
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/admin/data/LedgerRequest.cpp b/src/xrpld/rpc/handlers/admin/data/LedgerRequest.cpp
index de1e4439cf..80a49aea3f 100644
--- a/src/xrpld/rpc/handlers/admin/data/LedgerRequest.cpp
+++ b/src/xrpld/rpc/handlers/admin/data/LedgerRequest.cpp
@@ -13,10 +13,10 @@ namespace xrpl {
 //   ledger_index : 
 // }
 json::Value
-doLedgerRequest(RPC::JsonContext& context)
+doLedgerRequest(rpc::JsonContext& context)
 {
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
-    auto res = RPC::getOrAcquireLedger(context);
+    context.loadType = resource::kFeeHeavyBurdenRpc;
+    auto res = rpc::getOrAcquireLedger(context);
 
     if (!res.has_value())
         return res.error();
diff --git a/src/xrpld/rpc/handlers/admin/keygen/ValidationCreate.cpp b/src/xrpld/rpc/handlers/admin/keygen/ValidationCreate.cpp
index 0849bad944..5ff3e6727e 100644
--- a/src/xrpld/rpc/handlers/admin/keygen/ValidationCreate.cpp
+++ b/src/xrpld/rpc/handlers/admin/keygen/ValidationCreate.cpp
@@ -30,7 +30,7 @@ validationSeed(json::Value const& params)
 // This command requires Role::ADMIN access because it makes
 // no sense to ask an untrusted server for this.
 json::Value
-doValidationCreate(RPC::JsonContext& context)
+doValidationCreate(rpc::JsonContext& context)
 {
     json::Value obj(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp b/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
index 4b5f1821e3..62def76f9a 100644
--- a/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
+++ b/src/xrpld/rpc/handlers/admin/keygen/WalletPropose.cpp
@@ -52,7 +52,7 @@ estimateEntropy(std::string const& input)
 //  passphrase: 
 // }
 json::Value
-doWalletPropose(RPC::JsonContext& context)
+doWalletPropose(rpc::JsonContext& context)
 {
     return walletPropose(context.params);
 }
@@ -68,7 +68,7 @@ walletPropose(json::Value const& params)
     {
         if (!params[jss::key_type].isString())
         {
-            return RPC::expectedFieldError(jss::key_type, "string");
+            return rpc::expectedFieldError(jss::key_type, "string");
         }
 
         keyType = keyTypeFromString(params[jss::key_type].asString());
@@ -83,11 +83,11 @@ walletPropose(json::Value const& params)
     {
         if (params.isMember(jss::passphrase))
         {
-            seed = RPC::parseXrplLibSeed(params[jss::passphrase]);
+            seed = rpc::parseXrplLibSeed(params[jss::passphrase]);
         }
         else if (params.isMember(jss::seed))
         {
-            seed = RPC::parseXrplLibSeed(params[jss::seed]);
+            seed = rpc::parseXrplLibSeed(params[jss::seed]);
         }
 
         if (seed)
@@ -110,7 +110,7 @@ walletPropose(json::Value const& params)
         {
             json::Value err;
 
-            seed = RPC::getSeedFromRPC(params, err);
+            seed = rpc::getSeedFromRPC(params, err);
 
             if (!seed)
                 return err;
diff --git a/src/xrpld/rpc/handlers/admin/log/LogLevel.cpp b/src/xrpld/rpc/handlers/admin/log/LogLevel.cpp
index 1ff5aa1a27..4aae350810 100644
--- a/src/xrpld/rpc/handlers/admin/log/LogLevel.cpp
+++ b/src/xrpld/rpc/handlers/admin/log/LogLevel.cpp
@@ -16,7 +16,7 @@
 namespace xrpl {
 
 json::Value
-doLogLevel(RPC::JsonContext& context)
+doLogLevel(rpc::JsonContext& context)
 {
     // log_level
     if (not context.params.isMember(jss::severity))
diff --git a/src/xrpld/rpc/handlers/admin/log/LogRotate.cpp b/src/xrpld/rpc/handlers/admin/log/LogRotate.cpp
index 5f5c3e64df..ca935540dc 100644
--- a/src/xrpld/rpc/handlers/admin/log/LogRotate.cpp
+++ b/src/xrpld/rpc/handlers/admin/log/LogRotate.cpp
@@ -9,10 +9,10 @@
 namespace xrpl {
 
 json::Value
-doLogRotate(RPC::JsonContext& context)
+doLogRotate(rpc::JsonContext& context)
 {
     context.app.getPerfLog().rotate();
-    return RPC::makeObjectValue(context.app.getLogs().rotate());
+    return rpc::makeObjectValue(context.app.getLogs().rotate());
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/admin/peer/Connect.cpp b/src/xrpld/rpc/handlers/admin/peer/Connect.cpp
index 568dcdaa26..b318af06f9 100644
--- a/src/xrpld/rpc/handlers/admin/peer/Connect.cpp
+++ b/src/xrpld/rpc/handlers/admin/peer/Connect.cpp
@@ -21,15 +21,15 @@ namespace xrpl {
 // }
 // XXX Might allow domain for manual connections.
 json::Value
-doConnect(RPC::JsonContext& context)
+doConnect(rpc::JsonContext& context)
 {
     if (context.app.config().standalone())
     {
-        return RPC::makeError(RpcNotSynced);
+        return rpc::makeError(RpcNotSynced);
     }
 
     if (!context.params.isMember(jss::ip))
-        return RPC::missingFieldError(jss::ip);
+        return rpc::missingFieldError(jss::ip);
 
     if (context.params.isMember(jss::port) &&
         !context.params[jss::port].isConvertibleTo(json::ValueType::Int))
@@ -49,12 +49,12 @@ doConnect(RPC::JsonContext& context)
     }
 
     auto const ipStr = context.params[jss::ip].asString();
-    auto ip = beast::IP::Endpoint::fromString(ipStr);
+    auto ip = beast::ip::Endpoint::fromString(ipStr);
 
     if (!isUnspecified(ip))
         context.app.getOverlay().connect(ip.atPort(iPort));
 
-    return RPC::makeObjectValue(
+    return rpc::makeObjectValue(
         "attempting connection to IP:" + ipStr + " port: " + std::to_string(iPort));
 }
 
diff --git a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsAdd.cpp b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsAdd.cpp
index 23b0d094b4..579ad85f41 100644
--- a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsAdd.cpp
+++ b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsAdd.cpp
@@ -15,12 +15,12 @@
 namespace xrpl {
 
 json::Value
-doPeerReservationsAdd(RPC::JsonContext& context)
+doPeerReservationsAdd(rpc::JsonContext& context)
 {
     auto const& params = context.params;
 
     if (!params.isMember(jss::public_key))
-        return RPC::missingFieldError(jss::public_key);
+        return rpc::missingFieldError(jss::public_key);
 
     // Returning JSON from every function ruins any attempt to encapsulate
     // the pattern of "get field F as type T, and diagnose an error if it is
@@ -36,7 +36,7 @@ doPeerReservationsAdd(RPC::JsonContext& context)
     // essentially an optional (the "maybe monad" in Haskell) with a non-unit
     // type for the failure case to capture more information.
     if (!params[jss::public_key].isString())
-        return RPC::expectedFieldError(jss::public_key, "a string");
+        return rpc::expectedFieldError(jss::public_key, "a string");
 
     // Same for the pattern of "if field F is present, make sure it has type T
     // and get it".
@@ -44,7 +44,7 @@ doPeerReservationsAdd(RPC::JsonContext& context)
     if (params.isMember(jss::description))
     {
         if (!params[jss::description].isString())
-            return RPC::expectedFieldError(jss::description, "a string");
+            return rpc::expectedFieldError(jss::description, "a string");
         desc = params[jss::description].asString();
     }
 
diff --git a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsDel.cpp b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsDel.cpp
index c2a8319876..e5912a5eca 100644
--- a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsDel.cpp
+++ b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsDel.cpp
@@ -14,15 +14,15 @@
 namespace xrpl {
 
 json::Value
-doPeerReservationsDel(RPC::JsonContext& context)
+doPeerReservationsDel(rpc::JsonContext& context)
 {
     auto const& params = context.params;
 
     // We repeat much of the parameter parsing from `doPeerReservationsAdd`.
     if (!params.isMember(jss::public_key))
-        return RPC::missingFieldError(jss::public_key);
+        return rpc::missingFieldError(jss::public_key);
     if (!params[jss::public_key].isString())
-        return RPC::expectedFieldError(jss::public_key, "a string");
+        return rpc::expectedFieldError(jss::public_key, "a string");
 
     std::optional optPk =
         parseBase58(TokenType::NodePublic, params[jss::public_key].asString());
diff --git a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsList.cpp b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsList.cpp
index e0204159fd..30e19a5c0b 100644
--- a/src/xrpld/rpc/handlers/admin/peer/PeerReservationsList.cpp
+++ b/src/xrpld/rpc/handlers/admin/peer/PeerReservationsList.cpp
@@ -8,7 +8,7 @@
 namespace xrpl {
 
 json::Value
-doPeerReservationsList(RPC::JsonContext& context)
+doPeerReservationsList(rpc::JsonContext& context)
 {
     auto const& reservations = context.app.getPeerReservations().list();
     // Enumerate the reservations in context.app.getPeerReservations()
diff --git a/src/xrpld/rpc/handlers/admin/peer/Peers.cpp b/src/xrpld/rpc/handlers/admin/peer/Peers.cpp
index ab14325f0e..99f069e27e 100644
--- a/src/xrpld/rpc/handlers/admin/peer/Peers.cpp
+++ b/src/xrpld/rpc/handlers/admin/peer/Peers.cpp
@@ -17,7 +17,7 @@
 namespace xrpl {
 
 json::Value
-doPeers(RPC::JsonContext& context)
+doPeers(rpc::JsonContext& context)
 {
     json::Value jvResult(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/admin/server_control/LedgerAccept.cpp b/src/xrpld/rpc/handlers/admin/server_control/LedgerAccept.cpp
index ce06a6e480..00a259bb52 100644
--- a/src/xrpld/rpc/handlers/admin/server_control/LedgerAccept.cpp
+++ b/src/xrpld/rpc/handlers/admin/server_control/LedgerAccept.cpp
@@ -12,7 +12,7 @@
 namespace xrpl {
 
 json::Value
-doLedgerAccept(RPC::JsonContext& context)
+doLedgerAccept(rpc::JsonContext& context)
 {
     json::Value jvResult;
 
diff --git a/src/xrpld/rpc/handlers/admin/server_control/Stop.cpp b/src/xrpld/rpc/handlers/admin/server_control/Stop.cpp
index 3e86bd4632..949eccf1e1 100644
--- a/src/xrpld/rpc/handlers/admin/server_control/Stop.cpp
+++ b/src/xrpld/rpc/handlers/admin/server_control/Stop.cpp
@@ -6,15 +6,15 @@
 
 namespace xrpl {
 
-namespace RPC {
+namespace rpc {
 struct JsonContext;
-}  // namespace RPC
+}  // namespace rpc
 
 json::Value
-doStop(RPC::JsonContext& context)
+doStop(rpc::JsonContext& context)
 {
     context.app.signalStop("RPC");
-    return RPC::makeObjectValue(systemName() + " server stopping");
+    return rpc::makeObjectValue(systemName() + " server stopping");
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp
index be3ce13d45..eb3ac24378 100644
--- a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp
+++ b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp
@@ -15,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -30,34 +31,36 @@ namespace xrpl {
 //   drops: 64-bit uint (as string)
 // }
 json::Value
-doChannelAuthorize(RPC::JsonContext& context)
+doChannelAuthorize(rpc::JsonContext& context)
 {
     if (context.role != Role::ADMIN && !context.app.config().canSign())
     {
-        return RPC::makeError(RpcNotSupported, "Signing is not supported by this server.");
+        return rpc::makeError(RpcNotSupported, "Signing is not supported by this server.");
     }
 
+    context.loadType = resource::kFeeHeavyBurdenRpc;
+
     auto const& params(context.params);
     for (auto const& p : {jss::channel_id, jss::amount})
     {
         if (!params.isMember(p))
-            return RPC::missingFieldError(p);
+            return rpc::missingFieldError(p);
     }
 
     // Compatibility if a key type isn't specified. If it is, the
     // keypairForSignature code will validate parameters and return
     // the appropriate error.
     if (!params.isMember(jss::key_type) && !params.isMember(jss::secret))
-        return RPC::missingFieldError(jss::secret);
+        return rpc::missingFieldError(jss::secret);
 
     json::Value result;
     std::optional> const keyPair =
-        RPC::keypairForSignature(params, result, context.apiVersion);
+        rpc::keypairForSignature(params, result, context.apiVersion);
 
     XRPL_ASSERT(
-        keyPair || RPC::containsError(result),
+        keyPair || rpc::containsError(result),
         "xrpl::doChannelAuthorize : valid keyPair or an error");
-    if (!keyPair || RPC::containsError(result))
+    if (!keyPair || rpc::containsError(result))
         return result;
 
     PublicKey const& pk = keyPair->first;
@@ -86,7 +89,7 @@ doChannelAuthorize(RPC::JsonContext& context)
     catch (std::exception const& ex)
     {
         // LCOV_EXCL_START
-        result = RPC::makeError(
+        result = rpc::makeError(
             RpcInternal, "Exception occurred during signing: " + std::string(ex.what()));
         // LCOV_EXCL_STOP
     }
diff --git a/src/xrpld/rpc/handlers/admin/signing/Sign.cpp b/src/xrpld/rpc/handlers/admin/signing/Sign.cpp
index 781e160f54..6aac058a56 100644
--- a/src/xrpld/rpc/handlers/admin/signing/Sign.cpp
+++ b/src/xrpld/rpc/handlers/admin/signing/Sign.cpp
@@ -15,18 +15,18 @@ namespace xrpl {
 //   secret: 
 // }
 json::Value
-doSign(RPC::JsonContext& context)
+doSign(rpc::JsonContext& context)
 {
     if (context.role != Role::ADMIN && !context.app.config().canSign())
     {
-        return RPC::makeError(RpcNotSupported, "Signing is not supported by this server.");
+        return rpc::makeError(RpcNotSupported, "Signing is not supported by this server.");
     }
 
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
+    context.loadType = resource::kFeeHeavyBurdenRpc;
     NetworkOPs::FailHard const failType = NetworkOPs::doFailHard(
         context.params.isMember(jss::fail_hard) && context.params[jss::fail_hard].asBool());
 
-    auto ret = RPC::transactionSign(
+    auto ret = rpc::transactionSign(
         context.params,
         context.apiVersion,
         failType,
diff --git a/src/xrpld/rpc/handlers/admin/signing/SignFor.cpp b/src/xrpld/rpc/handlers/admin/signing/SignFor.cpp
index 35274d51f4..2b9c830647 100644
--- a/src/xrpld/rpc/handlers/admin/signing/SignFor.cpp
+++ b/src/xrpld/rpc/handlers/admin/signing/SignFor.cpp
@@ -16,18 +16,18 @@ namespace xrpl {
 //   secret: 
 // }
 json::Value
-doSignFor(RPC::JsonContext& context)
+doSignFor(rpc::JsonContext& context)
 {
     if (context.role != Role::ADMIN && !context.app.config().canSign())
     {
-        return RPC::makeError(RpcNotSupported, "Signing is not supported by this server.");
+        return rpc::makeError(RpcNotSupported, "Signing is not supported by this server.");
     }
 
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
+    context.loadType = resource::kFeeHeavyBurdenRpc;
     auto const failHard = context.params[jss::fail_hard].asBool();
     auto const failType = NetworkOPs::doFailHard(failHard);
 
-    auto ret = RPC::transactionSignFor(
+    auto ret = rpc::transactionSignFor(
         context.params,
         context.apiVersion,
         failType,
diff --git a/src/xrpld/rpc/handlers/admin/status/ConsensusInfo.cpp b/src/xrpld/rpc/handlers/admin/status/ConsensusInfo.cpp
index 341980ba6d..8017e7058b 100644
--- a/src/xrpld/rpc/handlers/admin/status/ConsensusInfo.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/ConsensusInfo.cpp
@@ -7,7 +7,7 @@
 namespace xrpl {
 
 json::Value
-doConsensusInfo(RPC::JsonContext& context)
+doConsensusInfo(rpc::JsonContext& context)
 {
     json::Value ret(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/admin/status/FetchInfo.cpp b/src/xrpld/rpc/handlers/admin/status/FetchInfo.cpp
index 16e4789025..ca9bff31f5 100644
--- a/src/xrpld/rpc/handlers/admin/status/FetchInfo.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/FetchInfo.cpp
@@ -7,7 +7,7 @@
 namespace xrpl {
 
 json::Value
-doFetchInfo(RPC::JsonContext& context)
+doFetchInfo(rpc::JsonContext& context)
 {
     json::Value ret(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp b/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp
index 789c6dcf17..421f23d237 100644
--- a/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp
@@ -111,7 +111,7 @@ getCountsJson(Application& app, int minObjectCount)
 //   min_count:   // optional, defaults to 10
 // }
 json::Value
-doGetCounts(RPC::JsonContext& context)
+doGetCounts(rpc::JsonContext& context)
 {
     int minCount = 10;
 
diff --git a/src/xrpld/rpc/handlers/admin/status/Print.cpp b/src/xrpld/rpc/handlers/admin/status/Print.cpp
index 99fd01c9f4..1e1f7f0662 100644
--- a/src/xrpld/rpc/handlers/admin/status/Print.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/Print.cpp
@@ -8,7 +8,7 @@
 namespace xrpl {
 
 json::Value
-doPrint(RPC::JsonContext& context)
+doPrint(rpc::JsonContext& context)
 {
     JsonPropertyStream stream;
     if (context.params.isObject() && context.params[jss::params].isArray() &&
diff --git a/src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp b/src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp
index efe1529ccb..705e03c1a0 100644
--- a/src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/ValidatorInfo.cpp
@@ -12,12 +12,12 @@
 
 namespace xrpl {
 json::Value
-doValidatorInfo(RPC::JsonContext& context)
+doValidatorInfo(rpc::JsonContext& context)
 {
     // return error if not configured as validator
     auto const validationPK = context.app.getValidationPublicKey();
     if (!validationPK)
-        return RPC::notValidatorError();
+        return rpc::notValidatorError();
 
     json::Value ret;
 
diff --git a/src/xrpld/rpc/handlers/admin/status/ValidatorListSites.cpp b/src/xrpld/rpc/handlers/admin/status/ValidatorListSites.cpp
index 7497105c50..9bc8b6bcda 100644
--- a/src/xrpld/rpc/handlers/admin/status/ValidatorListSites.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/ValidatorListSites.cpp
@@ -7,7 +7,7 @@
 namespace xrpl {
 
 json::Value
-doValidatorListSites(RPC::JsonContext& context)
+doValidatorListSites(rpc::JsonContext& context)
 {
     return context.app.getValidatorSites().getJson();
 }
diff --git a/src/xrpld/rpc/handlers/admin/status/Validators.cpp b/src/xrpld/rpc/handlers/admin/status/Validators.cpp
index 48e4466861..d605a38f1b 100644
--- a/src/xrpld/rpc/handlers/admin/status/Validators.cpp
+++ b/src/xrpld/rpc/handlers/admin/status/Validators.cpp
@@ -7,7 +7,7 @@
 namespace xrpl {
 
 json::Value
-doValidators(RPC::JsonContext& context)
+doValidators(rpc::JsonContext& context)
 {
     return context.app.getValidators().getJson();
 }
diff --git a/src/xrpld/rpc/handlers/ledger/Ledger.cpp b/src/xrpld/rpc/handlers/ledger/Ledger.cpp
index 23a97a5026..51f5bdf348 100644
--- a/src/xrpld/rpc/handlers/ledger/Ledger.cpp
+++ b/src/xrpld/rpc/handlers/ledger/Ledger.cpp
@@ -34,7 +34,7 @@
 #include 
 
 namespace xrpl {
-namespace RPC {
+namespace rpc {
 
 LedgerHandler::LedgerHandler(JsonContext& context) : context_(context)
 {
@@ -107,7 +107,7 @@ LedgerHandler::check()
         {
             return RpcTooBusy;
         }
-        context_.loadType = binary ? Resource::kFeeMediumBurdenRpc : Resource::kFeeHeavyBurdenRpc;
+        context_.loadType = binary ? resource::kFeeMediumBurdenRpc : resource::kFeeHeavyBurdenRpc;
     }
 
     if (*queue)
@@ -162,10 +162,10 @@ LedgerHandler::writeResult(json::Value& value)
         value[jss::warnings] = std::move(warnings);
 }
 
-}  // namespace RPC
+}  // namespace rpc
 
 std::pair
-doLedgerGrpc(RPC::GRPCContext& context)
+doLedgerGrpc(rpc::GRPCContext& context)
 {
     auto begin = std::chrono::system_clock::now();
     org::xrpl::rpc::v1::GetLedgerRequest const& request = context.params;
@@ -173,7 +173,7 @@ doLedgerGrpc(RPC::GRPCContext& context)
     grpc::Status const status = grpc::Status::OK;
 
     std::shared_ptr ledger;
-    if (auto status = RPC::ledgerFromRequest(ledger, context))
+    if (auto status = rpc::ledgerFromRequest(ledger, context))
     {
         grpc::Status errorStatus;
         if (status.toErrorCode() == RpcInvalidParams)
diff --git a/src/xrpld/rpc/handlers/ledger/Ledger.h b/src/xrpld/rpc/handlers/ledger/Ledger.h
index 59b64832f7..07d24b497d 100644
--- a/src/xrpld/rpc/handlers/ledger/Ledger.h
+++ b/src/xrpld/rpc/handlers/ledger/Ledger.h
@@ -18,7 +18,7 @@ namespace json {
 class Object;
 }  // namespace json
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 struct JsonContext;
 
@@ -42,9 +42,9 @@ public:
     // NOLINTBEGIN(readability-identifier-naming)
     static constexpr char name[] = "ledger";
 
-    static constexpr unsigned minApiVer = RPC::kApiMinimumSupportedVersion;
+    static constexpr unsigned minApiVer = rpc::kApiMinimumSupportedVersion;
 
-    static constexpr unsigned maxApiVer = RPC::kApiMaximumValidVersion;
+    static constexpr unsigned maxApiVer = rpc::kApiMaximumValidVersion;
 
     static constexpr Role role = Role::USER;
 
@@ -59,4 +59,4 @@ private:
     int options_ = 0;
 };
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerClosed.cpp b/src/xrpld/rpc/handlers/ledger/LedgerClosed.cpp
index 7ea314292f..def0a73cd3 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerClosed.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerClosed.cpp
@@ -10,7 +10,7 @@
 namespace xrpl {
 
 json::Value
-doLedgerClosed(RPC::JsonContext& context)
+doLedgerClosed(rpc::JsonContext& context)
 {
     auto ledger = context.ledgerMaster.getClosedLedger();
     XRPL_ASSERT(ledger, "xrpl::doLedgerClosed : non-null closed ledger");
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerCurrent.cpp b/src/xrpld/rpc/handlers/ledger/LedgerCurrent.cpp
index 1d05774163..ac04084848 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerCurrent.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerCurrent.cpp
@@ -8,7 +8,7 @@
 namespace xrpl {
 
 json::Value
-doLedgerCurrent(RPC::JsonContext& context)
+doLedgerCurrent(rpc::JsonContext& context)
 {
     json::Value jvResult;
     jvResult[jss::ledger_current_index] = context.ledgerMaster.getCurrentLedgerIndex();
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerData.cpp b/src/xrpld/rpc/handlers/ledger/LedgerData.cpp
index 64ab30374b..697d8e52b8 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerData.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerData.cpp
@@ -36,12 +36,12 @@ namespace xrpl {
 //     state:        array of state nodes
 //     marker:       resume point, if any
 json::Value
-doLedgerData(RPC::JsonContext& context)
+doLedgerData(rpc::JsonContext& context)
 {
     std::shared_ptr lpLedger;
     auto const& params = context.params;
 
-    auto jvResult = RPC::lookupLedger(lpLedger, context);
+    auto jvResult = rpc::lookupLedger(lpLedger, context);
     if (!lpLedger)
         return jvResult;
 
@@ -51,14 +51,14 @@ doLedgerData(RPC::JsonContext& context)
     {
         json::Value const& jMarker = params[jss::marker];
         if (!(jMarker.isString() && key.parseHex(jMarker.asString())))
-            return RPC::expectedFieldError(jss::marker, "valid");
+            return rpc::expectedFieldError(jss::marker, "valid");
     }
 
     bool isBinary = false;
     if (params.isMember(jss::binary))
     {
         if (!params[jss::binary].isBool())
-            return RPC::expectedFieldError(jss::binary, "boolean");
+            return rpc::expectedFieldError(jss::binary, "boolean");
         isBinary = params[jss::binary].asBool();
     }
 
@@ -67,12 +67,12 @@ doLedgerData(RPC::JsonContext& context)
     {
         json::Value const& jLimit = params[jss::limit];
         if (!jLimit.isIntegral())
-            return RPC::expectedFieldError(jss::limit, "integer");
+            return rpc::expectedFieldError(jss::limit, "integer");
 
         limit = jLimit.asInt();
     }
 
-    auto maxLimit = RPC::Tuning::pageLength(isBinary);
+    auto maxLimit = rpc::tuning::pageLength(isBinary);
     if ((limit < 0) || ((limit > maxLimit) && (!isUnlimited(context.role))))
         limit = maxLimit;
 
@@ -86,7 +86,7 @@ doLedgerData(RPC::JsonContext& context)
             *lpLedger, &context, isBinary ? static_cast(LedgerFill::Options::Binary) : 0));
     }
 
-    auto [rpcStatus, type] = RPC::chooseLedgerEntryType(params);
+    auto [rpcStatus, type] = rpc::chooseLedgerEntryType(params);
     if (rpcStatus)
     {
         jvResult.clear();
@@ -131,14 +131,14 @@ doLedgerData(RPC::JsonContext& context)
 }
 
 std::pair
-doLedgerDataGrpc(RPC::GRPCContext& context)
+doLedgerDataGrpc(rpc::GRPCContext& context)
 {
     org::xrpl::rpc::v1::GetLedgerDataRequest const& request = context.params;
     org::xrpl::rpc::v1::GetLedgerDataResponse response;
     grpc::Status const status = grpc::Status::OK;
 
     std::shared_ptr ledger;
-    if (auto status = RPC::ledgerFromRequest(ledger, context))
+    if (auto status = rpc::ledgerFromRequest(ledger, context))
     {
         grpc::Status errorStatus;
         if (status.toErrorCode() == RpcInvalidParams)
@@ -177,7 +177,7 @@ doLedgerDataGrpc(RPC::GRPCContext& con
         e = ledger->sles.upperBound(*key);
     }
 
-    int maxLimit = RPC::Tuning::pageLength(true);
+    int maxLimit = rpc::tuning::pageLength(true);
 
     for (auto i = ledger->sles.upperBound(startKey); i != e; ++i)
     {
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerDiff.cpp b/src/xrpld/rpc/handlers/ledger/LedgerDiff.cpp
index f1a9253de2..5e83bedf08 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerDiff.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerDiff.cpp
@@ -15,7 +15,7 @@
 
 namespace xrpl {
 std::pair
-doLedgerDiffGrpc(RPC::GRPCContext& context)
+doLedgerDiffGrpc(rpc::GRPCContext& context)
 {
     org::xrpl::rpc::v1::GetLedgerDiffRequest const& request = context.params;
     org::xrpl::rpc::v1::GetLedgerDiffResponse response;
@@ -24,13 +24,13 @@ doLedgerDiffGrpc(RPC::GRPCContext& con
     std::shared_ptr baseLedgerRv;
     std::shared_ptr desiredLedgerRv;
 
-    if (RPC::ledgerFromSpecifier(baseLedgerRv, request.base_ledger(), context))
+    if (rpc::ledgerFromSpecifier(baseLedgerRv, request.base_ledger(), context))
     {
         grpc::Status const errorStatus{grpc::StatusCode::NOT_FOUND, "base ledger not found"};
         return {response, errorStatus};
     }
 
-    if (RPC::ledgerFromSpecifier(desiredLedgerRv, request.desired_ledger(), context))
+    if (rpc::ledgerFromSpecifier(desiredLedgerRv, request.desired_ledger(), context))
     {
         grpc::Status const errorStatus{grpc::StatusCode::NOT_FOUND, "desired ledger not found"};
         return {response, errorStatus};
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
index 784be779bb..5271720b34 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp
@@ -18,6 +18,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -66,11 +67,11 @@ parseObjectID(
     json::StaticString const fieldName,
     std::string const& expectedType = "hex string or object")
 {
-    if (auto const uNodeIndex = LedgerEntryHelpers::parse(params))
+    if (auto const uNodeIndex = ledger_entry_helpers::parse(params))
     {
         return *uNodeIndex;
     }
-    return LedgerEntryHelpers::invalidFieldError("malformedRequest", fieldName, expectedType);
+    return ledger_entry_helpers::invalidFieldError("malformedRequest", fieldName, expectedType);
 }
 
 static std::expected
@@ -101,12 +102,12 @@ parseAccountRoot(
     json::StaticString const fieldName,
     [[maybe_unused]] unsigned const apiVersion)
 {
-    if (auto const account = LedgerEntryHelpers::parse(params))
+    if (auto const account = ledger_entry_helpers::parse(params))
     {
         return keylet::account(*account).key;
     }
 
-    return LedgerEntryHelpers::invalidFieldError("malformedAddress", fieldName, "AccountID");
+    return ledger_entry_helpers::invalidFieldError("malformedAddress", fieldName, "AccountID");
 }
 
 auto const parseAmendments = fixed(keylet::amendments());
@@ -122,17 +123,18 @@ parseAMM(
         return parseObjectID(params, fieldName);
     }
 
-    if (auto const value = LedgerEntryHelpers::hasRequired(params, {jss::asset, jss::asset2});
+    if (auto const value = ledger_entry_helpers::hasRequired(params, {jss::asset, jss::asset2});
         !value)
     {
         return std::unexpected(value.error());
     }
 
-    auto const asset = LedgerEntryHelpers::requiredAsset(params, jss::asset, "malformedRequest");
+    auto const asset = ledger_entry_helpers::requiredAsset(params, jss::asset, "malformedRequest");
     if (!asset)
         return std::unexpected(asset.error());
 
-    auto const asset2 = LedgerEntryHelpers::requiredAsset(params, jss::asset2, "malformedRequest");
+    auto const asset2 =
+        ledger_entry_helpers::requiredAsset(params, jss::asset2, "malformedRequest");
     if (!asset2)
         return std::unexpected(asset2.error());
 
@@ -147,7 +149,7 @@ parseBridge(
 {
     if (!params.isMember(jss::bridge))
     {
-        return std::unexpected(LedgerEntryHelpers::missingFieldError(jss::bridge));
+        return std::unexpected(ledger_entry_helpers::missingFieldError(jss::bridge));
     }
 
     if (params[jss::bridge].isString())
@@ -155,11 +157,11 @@ parseBridge(
         return parseObjectID(params, fieldName);
     }
 
-    auto const bridge = LedgerEntryHelpers::parseBridgeFields(params[jss::bridge]);
+    auto const bridge = ledger_entry_helpers::parseBridgeFields(params[jss::bridge]);
     if (!bridge)
         return std::unexpected(bridge.error());
 
-    auto const account = LedgerEntryHelpers::requiredAccountID(
+    auto const account = ledger_entry_helpers::requiredAccountID(
         params, jss::bridge_account, "malformedBridgeAccount");
     if (!account)
         return std::unexpected(account.error());
@@ -167,7 +169,7 @@ parseBridge(
     STXChainBridge::ChainType const chainType =
         STXChainBridge::srcChain(account.value() == bridge->lockingChainDoor());
     if (account.value() != bridge->door(chainType))
-        return LedgerEntryHelpers::malformedError("malformedRequest", "");
+        return ledger_entry_helpers::malformedError("malformedRequest", "");
 
     return keylet::bridge(*bridge, chainType).key;
 }
@@ -193,16 +195,16 @@ parseCredential(
     }
 
     auto const subject =
-        LedgerEntryHelpers::requiredAccountID(cred, jss::subject, "malformedRequest");
+        ledger_entry_helpers::requiredAccountID(cred, jss::subject, "malformedRequest");
     if (!subject)
         return std::unexpected(subject.error());
 
     auto const issuer =
-        LedgerEntryHelpers::requiredAccountID(cred, jss::issuer, "malformedRequest");
+        ledger_entry_helpers::requiredAccountID(cred, jss::issuer, "malformedRequest");
     if (!issuer)
         return std::unexpected(issuer.error());
 
-    auto const credType = LedgerEntryHelpers::requiredHexBlob(
+    auto const credType = ledger_entry_helpers::requiredHexBlob(
         cred, jss::credential_type, kMaxCredentialTypeLength, "malformedRequest");
     if (!credType)
         return std::unexpected(credType.error());
@@ -222,12 +224,12 @@ parseDelegate(
     }
 
     auto const account =
-        LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
+        ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!account)
         return std::unexpected(account.error());
 
     auto const authorize =
-        LedgerEntryHelpers::requiredAccountID(params, jss::authorize, "malformedAddress");
+        ledger_entry_helpers::requiredAccountID(params, jss::authorize, "malformedAddress");
     if (!authorize)
         return std::unexpected(authorize.error());
 
@@ -239,7 +241,7 @@ parseAuthorizeCredentials(json::Value const& jv)
 {
     if (!jv.isArray())
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedAuthorizedCredentials", jss::authorized_credentials, "array");
     }
 
@@ -247,7 +249,7 @@ parseAuthorizeCredentials(json::Value const& jv)
     if (n > kMaxCredentialsArraySize)
     {
         return std::unexpected(
-            LedgerEntryHelpers::malformedError(
+            ledger_entry_helpers::malformedError(
                 "malformedAuthorizedCredentials",
                 "Invalid field '" + std::string(jss::authorized_credentials) +
                     "', array too long."));
@@ -256,7 +258,7 @@ parseAuthorizeCredentials(json::Value const& jv)
     if (n == 0)
     {
         return std::unexpected(
-            LedgerEntryHelpers::malformedError(
+            ledger_entry_helpers::malformedError(
                 "malformedAuthorizedCredentials",
                 "Invalid field '" + std::string(jss::authorized_credentials) + "', array empty."));
     }
@@ -266,23 +268,23 @@ parseAuthorizeCredentials(json::Value const& jv)
     {
         if (!jo.isObject())
         {
-            return LedgerEntryHelpers::invalidFieldError(
+            return ledger_entry_helpers::invalidFieldError(
                 "malformedAuthorizedCredentials", jss::authorized_credentials, "array of objects");
         }
 
-        if (auto const value = LedgerEntryHelpers::hasRequired(
+        if (auto const value = ledger_entry_helpers::hasRequired(
                 jo, {jss::issuer, jss::credential_type}, "malformedAuthorizedCredentials");
             !value)
         {
             return std::unexpected(value.error());
         }
 
-        auto const issuer = LedgerEntryHelpers::requiredAccountID(
+        auto const issuer = ledger_entry_helpers::requiredAccountID(
             jo, jss::issuer, "malformedAuthorizedCredentials");
         if (!issuer)
             return std::unexpected(issuer.error());
 
-        auto const credentialType = LedgerEntryHelpers::requiredHexBlob(
+        auto const credentialType = ledger_entry_helpers::requiredHexBlob(
             jo, jss::credential_type, kMaxCredentialTypeLength, "malformedAuthorizedCredentials");
         if (!credentialType)
             return std::unexpected(credentialType.error());
@@ -309,13 +311,13 @@ parseDepositPreauth(
 
     if ((dp.isMember(jss::authorized) == dp.isMember(jss::authorized_credentials)))
     {
-        return LedgerEntryHelpers::malformedError(
+        return ledger_entry_helpers::malformedError(
             "malformedRequest",
             "Must have exactly one of `authorized` and "
             "`authorized_credentials`.");
     }
 
-    auto const owner = LedgerEntryHelpers::requiredAccountID(dp, jss::owner, "malformedOwner");
+    auto const owner = ledger_entry_helpers::requiredAccountID(dp, jss::owner, "malformedOwner");
     if (!owner)
     {
         return std::unexpected(owner.error());
@@ -323,11 +325,11 @@ parseDepositPreauth(
 
     if (dp.isMember(jss::authorized))
     {
-        if (auto const authorized = LedgerEntryHelpers::parse(dp[jss::authorized]))
+        if (auto const authorized = ledger_entry_helpers::parse(dp[jss::authorized]))
         {
             return keylet::depositPreauth(*owner, *authorized).key;
         }
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedAuthorized", jss::authorized, "AccountID");
     }
 
@@ -340,7 +342,7 @@ parseDepositPreauth(
     if (sorted.empty())
     {
         // TODO: this error message is bad/inaccurate
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedAuthorizedCredentials", jss::authorized_credentials, "array");
     }
 
@@ -353,10 +355,10 @@ parseDID(
     json::StaticString const fieldName,
     [[maybe_unused]] unsigned const apiVersion)
 {
-    auto const account = LedgerEntryHelpers::parse(params);
+    auto const account = ledger_entry_helpers::parse(params);
     if (!account)
     {
-        return LedgerEntryHelpers::invalidFieldError("malformedAddress", fieldName, "AccountID");
+        return ledger_entry_helpers::invalidFieldError("malformedAddress", fieldName, "AccountID");
     }
 
     return keylet::did(*account).key;
@@ -377,12 +379,13 @@ parseDirectoryNode(
         (!params[jss::sub_index].isConvertibleTo(json::ValueType::UInt) ||
          params[jss::sub_index].isBool()))
     {
-        return LedgerEntryHelpers::invalidFieldError("malformedRequest", jss::sub_index, "number");
+        return ledger_entry_helpers::invalidFieldError(
+            "malformedRequest", jss::sub_index, "number");
     }
 
     if (params.isMember(jss::owner) == params.isMember(jss::dir_root))
     {
-        return LedgerEntryHelpers::malformedError(
+        return ledger_entry_helpers::malformedError(
             "malformedRequest", "Must have exactly one of `owner` and `dir_root` fields.");
     }
 
@@ -390,27 +393,27 @@ parseDirectoryNode(
 
     if (params.isMember(jss::dir_root))
     {
-        if (auto const uDirRoot = LedgerEntryHelpers::parse(params[jss::dir_root]))
+        if (auto const uDirRoot = ledger_entry_helpers::parse(params[jss::dir_root]))
         {
             return keylet::page(*uDirRoot, uSubIndex).key;
         }
 
-        return LedgerEntryHelpers::invalidFieldError("malformedDirRoot", jss::dir_root, "hash");
+        return ledger_entry_helpers::invalidFieldError("malformedDirRoot", jss::dir_root, "hash");
     }
 
     if (params.isMember(jss::owner))
     {
-        auto const ownerID = LedgerEntryHelpers::parse(params[jss::owner]);
+        auto const ownerID = ledger_entry_helpers::parse(params[jss::owner]);
         if (!ownerID)
         {
-            return LedgerEntryHelpers::invalidFieldError(
+            return ledger_entry_helpers::invalidFieldError(
                 "malformedAddress", jss::owner, "AccountID");
         }
 
         return keylet::page(keylet::ownerDir(*ownerID), uSubIndex).key;
     }
 
-    return LedgerEntryHelpers::malformedError("malformedRequest", "");
+    return ledger_entry_helpers::malformedError("malformedRequest", "");
 }
 
 static std::expected
@@ -424,14 +427,15 @@ parseEscrow(
         return parseObjectID(params, fieldName);
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
+    auto const id = ledger_entry_helpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
         return std::unexpected(id.error());
-    auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq");
+    auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::seq, "malformedSeq");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::escrow(*id, *seq).key;
+    auto const seqProxy = SeqProxy::rawSequence(*seq);
+    return keylet::escrow(*id, seqProxy).key;
 }
 
 auto const parseFeeSettings = fixed(keylet::feeSettings());
@@ -449,7 +453,7 @@ parseFixed(
     }
     if (!params.asBool())
     {
-        return LedgerEntryHelpers::invalidFieldError("invalidParams", fieldName, "true");
+        return ledger_entry_helpers::invalidFieldError("invalidParams", fieldName, "true");
     }
 
     return keylet.key;
@@ -486,14 +490,15 @@ parseLoanBroker(
         return parseObjectID(params, fieldName, "hex string");
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
+    auto const id = ledger_entry_helpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
         return std::unexpected(id.error());
-    auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq");
+    auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::seq, "malformedSeq");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::loanBroker(*id, *seq).key;
+    auto const seqProxy = SeqProxy::rawSequence(*seq);
+    return keylet::loanBroker(*id, seqProxy).key;
 }
 
 static std::expected
@@ -508,14 +513,15 @@ parseLoan(
     }
 
     auto const id =
-        LedgerEntryHelpers::requiredUInt256(params, jss::loan_broker_id, "malformedBroker");
+        ledger_entry_helpers::requiredUInt256(params, jss::loan_broker_id, "malformedBroker");
     if (!id)
         return std::unexpected(id.error());
-    auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::loan_seq, "malformedSeq");
+    auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::loan_seq, "malformedSeq");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::loan(*id, *seq).key;
+    auto const seqProxy = SeqProxy::rawSequence(*seq);
+    return keylet::loan(*id, seqProxy).key;
 }
 
 static std::expected
@@ -529,13 +535,13 @@ parseMPToken(
         return parseObjectID(params, fieldName);
     }
 
-    auto const mptIssuanceID =
-        LedgerEntryHelpers::requiredUInt192(params, jss::mpt_issuance_id, "malformedMPTIssuanceID");
+    auto const mptIssuanceID = ledger_entry_helpers::requiredUInt192(
+        params, jss::mpt_issuance_id, "malformedMPTIssuanceID");
     if (!mptIssuanceID)
         return std::unexpected(mptIssuanceID.error());
 
     auto const account =
-        LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAccount");
+        ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAccount");
     if (!account)
         return std::unexpected(account.error());
 
@@ -548,10 +554,10 @@ parseMPTokenIssuance(
     json::StaticString const fieldName,
     [[maybe_unused]] unsigned const apiVersion)
 {
-    auto const mptIssuanceID = LedgerEntryHelpers::parse(params);
+    auto const mptIssuanceID = ledger_entry_helpers::parse(params);
     if (!mptIssuanceID)
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedMPTokenIssuance", fieldName, "Hash192");
     }
 
@@ -589,15 +595,17 @@ parseOffer(
         return parseObjectID(params, fieldName);
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
+    auto const id =
+        ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!id)
         return std::unexpected(id.error());
 
-    auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
+    auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::seq, "malformedRequest");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::offer(*id, *seq).key;
+    auto const seqProxy = SeqProxy::rawSequence(*seq);
+    return keylet::offer(*id, seqProxy).key;
 }
 
 static std::expected
@@ -611,12 +619,13 @@ parseOracle(
         return parseObjectID(params, fieldName);
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAccount");
+    auto const id =
+        ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAccount");
     if (!id)
         return std::unexpected(id.error());
 
-    auto const seq =
-        LedgerEntryHelpers::requiredUInt32(params, jss::oracle_document_id, "malformedDocumentID");
+    auto const seq = ledger_entry_helpers::requiredUInt32(
+        params, jss::oracle_document_id, "malformedDocumentID");
     if (!seq)
         return std::unexpected(seq.error());
 
@@ -645,20 +654,21 @@ parsePermissionedDomain(
 
     if (!pd.isObject())
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedRequest", fieldName, "hex string or object");
     }
 
     auto const account =
-        LedgerEntryHelpers::requiredAccountID(pd, jss::account, "malformedAddress");
+        ledger_entry_helpers::requiredAccountID(pd, jss::account, "malformedAddress");
     if (!account)
         return std::unexpected(account.error());
 
-    auto const seq = LedgerEntryHelpers::requiredUInt32(pd, jss::seq, "malformedRequest");
+    auto const seq = ledger_entry_helpers::requiredUInt32(pd, jss::seq, "malformedRequest");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::permissionedDomain(*account, pd[jss::seq].asUInt()).key;
+    auto const seqProxy = SeqProxy::rawSequence(pd[jss::seq].asUInt());
+    return keylet::permissionedDomain(*account, seqProxy).key;
 }
 
 static std::expected
@@ -675,7 +685,7 @@ parseRippleState(
     }
 
     if (auto const value =
-            LedgerEntryHelpers::hasRequired(jvRippleState, {jss::currency, jss::accounts});
+            ledger_entry_helpers::hasRequired(jvRippleState, {jss::currency, jss::accounts});
         !value)
     {
         return std::unexpected(value.error());
@@ -683,27 +693,27 @@ parseRippleState(
 
     if (!jvRippleState[jss::accounts].isArray() || jvRippleState[jss::accounts].size() != 2)
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedRequest", jss::accounts, "length-2 array of Accounts");
     }
 
-    auto const id1 = LedgerEntryHelpers::parse(jvRippleState[jss::accounts][0u]);
-    auto const id2 = LedgerEntryHelpers::parse(jvRippleState[jss::accounts][1u]);
+    auto const id1 = ledger_entry_helpers::parse(jvRippleState[jss::accounts][0u]);
+    auto const id2 = ledger_entry_helpers::parse(jvRippleState[jss::accounts][1u]);
     if (!id1 || !id2)
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedAddress", jss::accounts, "array of Accounts");
     }
     if (id1 == id2)
     {
-        return LedgerEntryHelpers::malformedError(
+        return ledger_entry_helpers::malformedError(
             "malformedRequest", "Cannot have a trustline to self.");
     }
 
     if (!jvRippleState[jss::currency].isString() || jvRippleState[jss::currency] == "" ||
         !toCurrency(uCurrency, jvRippleState[jss::currency].asString()))
     {
-        return LedgerEntryHelpers::invalidFieldError(
+        return ledger_entry_helpers::invalidFieldError(
             "malformedCurrency", jss::currency, "Currency");
     }
 
@@ -729,12 +739,12 @@ parseSponsorship(
         return parseObjectID(params, fieldName);
 
     auto const sponsorID =
-        LedgerEntryHelpers::requiredAccountID(params, jss::sponsor, "malformedSponsor");
+        ledger_entry_helpers::requiredAccountID(params, jss::sponsor, "malformedSponsor");
     if (!sponsorID)
         return std::unexpected(sponsorID.error());
 
     auto const sponseeID =
-        LedgerEntryHelpers::requiredAccountID(params, jss::sponsee, "malformedSponsee");
+        ledger_entry_helpers::requiredAccountID(params, jss::sponsee, "malformedSponsee");
     if (!sponseeID)
         return std::unexpected(sponseeID.error());
 
@@ -752,16 +762,18 @@ parseTicket(
         return parseObjectID(params, fieldName);
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
+    auto const id =
+        ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAddress");
     if (!id)
         return std::unexpected(id.error());
 
     auto const seq =
-        LedgerEntryHelpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest");
+        ledger_entry_helpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return getTicketIndex(*id, *seq);
+    auto const seqProxy = SeqProxy::rawTicket(*seq);
+    return keylet::ticket(*id, seqProxy).key;
 }
 
 static std::expected
@@ -775,15 +787,16 @@ parseVault(
         return parseObjectID(params, fieldName);
     }
 
-    auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
+    auto const id = ledger_entry_helpers::requiredAccountID(params, jss::owner, "malformedOwner");
     if (!id)
         return std::unexpected(id.error());
 
-    auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
+    auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::seq, "malformedRequest");
     if (!seq)
         return std::unexpected(seq.error());
 
-    return keylet::vault(*id, *seq).key;
+    auto const seqProxy = SeqProxy::rawSequence(*seq);
+    return keylet::vault(*id, seqProxy).key;
 }
 
 static std::expected
@@ -797,11 +810,11 @@ parseXChainOwnedClaimID(
         return parseObjectID(claimId, fieldName);
     }
 
-    auto const bridgeSpec = LedgerEntryHelpers::parseBridgeFields(claimId);
+    auto const bridgeSpec = ledger_entry_helpers::parseBridgeFields(claimId);
     if (!bridgeSpec)
         return std::unexpected(bridgeSpec.error());
 
-    auto const seq = LedgerEntryHelpers::requiredUInt32(
+    auto const seq = ledger_entry_helpers::requiredUInt32(
         claimId, jss::xchain_owned_claim_id, "malformedXChainOwnedClaimID");
     if (!seq)
     {
@@ -823,11 +836,11 @@ parseXChainOwnedCreateAccountClaimID(
         return parseObjectID(claimId, fieldName);
     }
 
-    auto const bridgeSpec = LedgerEntryHelpers::parseBridgeFields(claimId);
+    auto const bridgeSpec = ledger_entry_helpers::parseBridgeFields(claimId);
     if (!bridgeSpec)
         return std::unexpected(bridgeSpec.error());
 
-    auto const seq = LedgerEntryHelpers::requiredUInt32(
+    auto const seq = ledger_entry_helpers::requiredUInt32(
         claimId,
         jss::xchain_owned_create_account_claim_id,
         "malformedXChainOwnedCreateAccountClaimID");
@@ -853,7 +866,7 @@ struct LedgerEntry
 //   ...
 // }
 json::Value
-doLedgerEntry(RPC::JsonContext& context)
+doLedgerEntry(rpc::JsonContext& context)
 {
     static auto kLedgerEntryParsers = std::to_array({
 #pragma push_macro("LEDGER_ENTRY")
@@ -892,11 +905,11 @@ doLedgerEntry(RPC::JsonContext& context)
 
     if (hasMoreThanOneMember)
     {
-        return RPC::makeParamError("Too many fields provided.");
+        return rpc::makeParamError("Too many fields provided.");
     }
 
     std::shared_ptr lpLedger;
-    auto jvResult = RPC::lookupLedger(lpLedger, context);
+    auto jvResult = rpc::lookupLedger(lpLedger, context);
 
     if (!lpLedger)
         return jvResult;
@@ -936,7 +949,7 @@ doLedgerEntry(RPC::JsonContext& context)
                 jvResult[jss::error] = "unknownOption";
                 return jvResult;
             }
-            return RPC::makeParamError("No ledger_entry params provided.");
+            return rpc::makeParamError("No ledger_entry params provided.");
         }
     }
     catch (json::Error const& e)
@@ -945,7 +958,7 @@ doLedgerEntry(RPC::JsonContext& context)
         {
             // For apiVersion 2 onwards, any parsing failures that throw
             // this exception return an invalidParam error.
-            return RPC::makeError(RpcInvalidParams);
+            return rpc::makeError(RpcInvalidParams);
         }
 
         throw;
@@ -956,7 +969,7 @@ doLedgerEntry(RPC::JsonContext& context)
 
     if (uNodeIndex.isZero())
     {
-        RPC::injectError(RpcEntryNotFound, jvResult);
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
@@ -969,13 +982,13 @@ doLedgerEntry(RPC::JsonContext& context)
     if (!sleNode)
     {
         // Not found.
-        RPC::injectError(RpcEntryNotFound, jvResult);
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
     if ((expectedType != ltANY) && (expectedType != sleNode->getType()))
     {
-        RPC::injectError(RpcUnexpectedLedgerType, jvResult);
+        rpc::injectError(RpcUnexpectedLedgerType, jvResult);
         return jvResult;
     }
 
@@ -996,14 +1009,14 @@ doLedgerEntry(RPC::JsonContext& context)
 }
 
 std::pair
-doLedgerEntryGrpc(RPC::GRPCContext& context)
+doLedgerEntryGrpc(rpc::GRPCContext& context)
 {
     org::xrpl::rpc::v1::GetLedgerEntryRequest const& request = context.params;
     org::xrpl::rpc::v1::GetLedgerEntryResponse response;
     grpc::Status const status = grpc::Status::OK;
 
     std::shared_ptr ledger;
-    if (auto status = RPC::ledgerFromRequest(ledger, context))
+    if (auto status = rpc::ledgerFromRequest(ledger, context))
     {
         grpc::Status errorStatus;
         if (status.toErrorCode() == RpcInvalidParams)
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h b/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
index 57c4e58242..1b119db04e 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
+++ b/src/xrpld/rpc/handlers/ledger/LedgerEntryHelpers.h
@@ -22,7 +22,7 @@
 #include 
 #include 
 
-namespace xrpl::LedgerEntryHelpers {
+namespace xrpl::ledger_entry_helpers {
 
 inline std::unexpected
 missingFieldError(json::StaticString const field, std::optional err = std::nullopt)
@@ -30,7 +30,7 @@ missingFieldError(json::StaticString const field, std::optional err
     json::Value json = json::ValueType::Object;
     json[jss::error] = err.value_or("malformedRequest");
     json[jss::error_code] = RpcInvalidParams;
-    json[jss::error_message] = RPC::missingFieldMessage(std::string(field.cStr()));
+    json[jss::error_message] = rpc::missingFieldMessage(std::string(field.cStr()));
     return std::unexpected(json);
 }
 
@@ -40,7 +40,7 @@ invalidFieldError(std::string const& err, json::StaticString const field, std::s
     json::Value json = json::ValueType::Object;
     json[jss::error] = err;
     json[jss::error_code] = RpcInvalidParams;
-    json[jss::error_message] = RPC::expectedFieldMessage(field, type);
+    json[jss::error_message] = rpc::expectedFieldMessage(field, type);
     return std::unexpected(json);
 }
 
@@ -291,4 +291,4 @@ parseBridgeFields(json::Value const& params)
         *lockingChainDoor, lockingChainIssue, *issuingChainDoor, issuingChainIssue);
 }
 
-}  // namespace xrpl::LedgerEntryHelpers
+}  // namespace xrpl::ledger_entry_helpers
diff --git a/src/xrpld/rpc/handlers/ledger/LedgerHeader.cpp b/src/xrpld/rpc/handlers/ledger/LedgerHeader.cpp
index e2cb80615b..ec3c9fe602 100644
--- a/src/xrpld/rpc/handlers/ledger/LedgerHeader.cpp
+++ b/src/xrpld/rpc/handlers/ledger/LedgerHeader.cpp
@@ -18,10 +18,10 @@ namespace xrpl {
 //   ledger_index : 
 // }
 json::Value
-doLedgerHeader(RPC::JsonContext& context)
+doLedgerHeader(rpc::JsonContext& context)
 {
     std::shared_ptr lpLedger;
-    auto jvResult = RPC::lookupLedger(lpLedger, context);
+    auto jvResult = rpc::lookupLedger(lpLedger, context);
 
     if (!lpLedger)
         return jvResult;
diff --git a/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp b/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
index 7f54f81423..e95c51c483 100644
--- a/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/AMMInfo.cpp
@@ -61,13 +61,13 @@ toIso8601(NetClock::time_point tp)
 }
 
 json::Value
-doAMMInfo(RPC::JsonContext& context)
+doAMMInfo(rpc::JsonContext& context)
 {
     auto const& params(context.params);
     json::Value result;
 
     std::shared_ptr ledger;
-    result = RPC::lookupLedger(ledger, context);
+    result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;
 
@@ -174,7 +174,7 @@ doAMMInfo(RPC::JsonContext& context)
     auto const r = getValuesFromContextParams();
     if (!r)
     {
-        RPC::injectError(r.error(), result);
+        rpc::injectError(r.error(), result);
         return result;
     }
 
diff --git a/src/xrpld/rpc/handlers/orderbook/BookChanges.cpp b/src/xrpld/rpc/handlers/orderbook/BookChanges.cpp
index abad196246..0f796df3a4 100644
--- a/src/xrpld/rpc/handlers/orderbook/BookChanges.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/BookChanges.cpp
@@ -11,15 +11,15 @@
 namespace xrpl {
 
 json::Value
-doBookChanges(RPC::JsonContext& context)
+doBookChanges(rpc::JsonContext& context)
 {
     std::shared_ptr ledger;
 
-    json::Value result = RPC::lookupLedger(ledger, context);
+    json::Value result = rpc::lookupLedger(ledger, context);
     if (ledger == nullptr)
         return result;
 
-    return RPC::computeBookChanges(ledger);
+    return rpc::computeBookChanges(ledger);
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
index 63dee76f1b..219c29d53a 100644
--- a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 
@@ -32,20 +33,19 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name)
 {
     if (!taker.isMember(jss::currency) && !taker.isMember(jss::mpt_issuance_id))
     {
-        return RPC::missingFieldError((boost::format("%s.currency") % name.cStr()).str());
+        return rpc::missingFieldError(std::format("{}.currency", name.cStr()));
     }
 
     if (taker.isMember(jss::mpt_issuance_id) &&
         (taker.isMember(jss::currency) || taker.isMember(jss::issuer)))
     {
-        return RPC::invalidFieldError(name.cStr());
+        return rpc::invalidFieldError(name.cStr());
     }
 
     if ((taker.isMember(jss::currency) && !taker[jss::currency].isString()) ||
         (taker.isMember(jss::mpt_issuance_id) && !taker[jss::mpt_issuance_id].isString()))
     {
-        return RPC::expectedFieldError(
-            (boost::format("%s.currency") % name.cStr()).str(), "string");
+        return rpc::expectedFieldError(std::format("{}.currency", name.cStr()), "string");
     }
 
     return std::nullopt;
@@ -70,10 +70,9 @@ parseTakerAssetJSON(
 
         if (!toCurrency(issue.currency, taker[jss::currency].asString()))
         {
-            JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr();
-            return RPC::makeError(
-                assetError,
-                (boost::format("Invalid field '%s.currency', bad currency.") % name.cStr()).str());
+            JLOG(j.info()) << std::format("Bad {} currency.", name.cStr());
+            return rpc::makeError(
+                assetError, std::format("Invalid field '{}.currency', bad currency.", name.cStr()));
         }
         asset = issue;
     }
@@ -82,9 +81,8 @@ parseTakerAssetJSON(
         MPTID mptid;
         if (!mptid.parseHex(taker[jss::mpt_issuance_id].asString()))
         {
-            return RPC::makeError(
-                assetError,
-                (boost::format("Invalid field '%s.mpt_issuance_id'") % name.cStr()).str());
+            return rpc::makeError(
+                assetError, std::format("Invalid field '{}.mpt_issuance_id'", name.cStr()));
         }
         asset = mptid;
     }
@@ -113,24 +111,21 @@ parseTakerIssuerJSON(
         {
             if (!taker[jss::issuer].isString())
             {
-                return RPC::expectedFieldError(
-                    (boost::format("%s.issuer") % name.cStr()).str(), "string");
+                return rpc::expectedFieldError(std::format("{}.issuer", name.cStr()), "string");
             }
 
             if (!toIssuer(issue.account, taker[jss::issuer].asString()))
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     issuerError,
-                    (boost::format("Invalid field '%s.issuer', bad issuer.") % name.cStr()).str());
+                    std::format("Invalid field '{}.issuer', bad issuer.", name.cStr()));
             }
 
             if (issue.account == noAccount())
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     issuerError,
-                    (boost::format("Invalid field '%s.issuer', bad issuer account one.") %
-                     name.cStr())
-                        .str());
+                    std::format("Invalid field '{}.issuer', bad issuer account one.", name.cStr()));
             }
         }
         else
@@ -140,21 +135,19 @@ parseTakerIssuerJSON(
 
         if (isXRP(issue.currency) && !isXRP(issue.account))
         {
-            return RPC::makeError(
+            return rpc::makeError(
                 issuerError,
-                (boost::format(
-                     "Unneeded field '%s.issuer' for XRP currency "
-                     "specification.") %
-                 name.cStr())
-                    .str());
+                std::format(
+                    "Unneeded field '{}.issuer' for XRP currency "
+                    "specification.",
+                    name.cStr()));
         }
 
         if (!isXRP(issue.currency) && isXRP(issue.account))
         {
-            return RPC::makeError(
+            return rpc::makeError(
                 issuerError,
-                (boost::format("Invalid field '%s.issuer', expected non-XRP issuer.") % name.cStr())
-                    .str());
+                std::format("Invalid field '{}.issuer', expected non-XRP issuer.", name.cStr()));
         }
     }
 
@@ -162,7 +155,7 @@ parseTakerIssuerJSON(
 }
 
 json::Value
-doBookOffers(RPC::JsonContext& context)
+doBookOffers(rpc::JsonContext& context)
 {
     // VFALCO TODO Here is a terrible place for this kind of business
     //             logic. It needs to be moved elsewhere and documented,
@@ -171,25 +164,25 @@ doBookOffers(RPC::JsonContext& context)
         return rpcError(RpcTooBusy);
 
     std::shared_ptr lpLedger;
-    auto jvResult = RPC::lookupLedger(lpLedger, context);
+    auto jvResult = rpc::lookupLedger(lpLedger, context);
 
     if (!lpLedger)
         return jvResult;
 
     if (!context.params.isMember(jss::taker_pays))
-        return RPC::missingFieldError(jss::taker_pays);
+        return rpc::missingFieldError(jss::taker_pays);
 
     if (!context.params.isMember(jss::taker_gets))
-        return RPC::missingFieldError(jss::taker_gets);
+        return rpc::missingFieldError(jss::taker_gets);
 
     json::Value const& takerPays = context.params[jss::taker_pays];
     json::Value const& takerGets = context.params[jss::taker_gets];
 
     if (!takerPays.isObjectOrNull())
-        return RPC::objectFieldError(jss::taker_pays);
+        return rpc::objectFieldError(jss::taker_pays);
 
     if (!takerGets.isObjectOrNull())
-        return RPC::objectFieldError(jss::taker_gets);
+        return rpc::objectFieldError(jss::taker_gets);
 
     if (auto const err = validateTakerJSON(takerPays, jss::taker_pays))
         return *err;
@@ -215,11 +208,11 @@ doBookOffers(RPC::JsonContext& context)
     if (context.params.isMember(jss::taker))
     {
         if (!context.params[jss::taker].isString())
-            return RPC::expectedFieldError(jss::taker, "string");
+            return rpc::expectedFieldError(jss::taker, "string");
 
         takerID = parseBase58(context.params[jss::taker].asString());
         if (!takerID)
-            return RPC::invalidFieldError(jss::taker);
+            return rpc::invalidFieldError(jss::taker);
     }
 
     std::optional domain;
@@ -229,7 +222,7 @@ doBookOffers(RPC::JsonContext& context)
         if (!context.params[jss::domain].isString() ||
             !num.parseHex(context.params[jss::domain].asString()))
         {
-            return RPC::makeError(RpcDomainMalformed, "Unable to parse domain.");
+            return rpc::makeError(RpcDomainMalformed, "Unable to parse domain.");
         }
 
         domain = num;
@@ -238,11 +231,11 @@ doBookOffers(RPC::JsonContext& context)
     if (book.in == book.out)
     {
         JLOG(context.j.info()) << "taker_gets same as taker_pays.";
-        return RPC::makeError(RpcBadMarket);
+        return rpc::makeError(RpcBadMarket);
     }
 
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kBookOffers, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kBookOffers, context))
         return *err;
 
     bool const bProof(context.params.isMember(jss::proof));
@@ -260,7 +253,7 @@ doBookOffers(RPC::JsonContext& context)
         jvMarker,
         jvResult);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
 
     return jvResult;
 }
diff --git a/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp b/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
index 343d539277..9d109fe16f 100644
--- a/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/DepositAuthorized.cpp
@@ -32,17 +32,17 @@ namespace xrpl {
 // }
 
 json::Value
-doDepositAuthorized(RPC::JsonContext& context)
+doDepositAuthorized(rpc::JsonContext& context)
 {
     json::Value const& params = context.params;
 
     // Validate source_account.
     if (!params.isMember(jss::source_account))
-        return RPC::missingFieldError(jss::source_account);
+        return rpc::missingFieldError(jss::source_account);
     if (!params[jss::source_account].isString())
     {
-        return RPC::makeError(
-            RpcInvalidParams, RPC::expectedFieldMessage(jss::source_account, "a string"));
+        return rpc::makeError(
+            RpcInvalidParams, rpc::expectedFieldMessage(jss::source_account, "a string"));
     }
 
     auto srcID = parseBase58(params[jss::source_account].asString());
@@ -52,11 +52,11 @@ doDepositAuthorized(RPC::JsonContext& context)
 
     // Validate destination_account.
     if (!params.isMember(jss::destination_account))
-        return RPC::missingFieldError(jss::destination_account);
+        return rpc::missingFieldError(jss::destination_account);
     if (!params[jss::destination_account].isString())
     {
-        return RPC::makeError(
-            RpcInvalidParams, RPC::expectedFieldMessage(jss::destination_account, "a string"));
+        return rpc::makeError(
+            RpcInvalidParams, rpc::expectedFieldMessage(jss::destination_account, "a string"));
     }
 
     auto dstID = parseBase58(params[jss::destination_account].asString());
@@ -66,7 +66,7 @@ doDepositAuthorized(RPC::JsonContext& context)
 
     // Validate ledger.
     std::shared_ptr ledger;
-    json::Value result = RPC::lookupLedger(ledger, context);
+    json::Value result = rpc::lookupLedger(ledger, context);
 
     if (!ledger)
         return result;
@@ -74,7 +74,7 @@ doDepositAuthorized(RPC::JsonContext& context)
     // If source account is not in the ledger it can't be authorized.
     if (!ledger->exists(keylet::account(srcAcct)))
     {
-        RPC::injectError(RpcSrcActNotFound, result);
+        rpc::injectError(RpcSrcActNotFound, result);
         return result;
     }
 
@@ -82,7 +82,7 @@ doDepositAuthorized(RPC::JsonContext& context)
     auto const sleDest = ledger->read(keylet::account(dstAcct));
     if (!sleDest)
     {
-        RPC::injectError(RpcDstActNotFound, result);
+        rpc::injectError(RpcDstActNotFound, result);
         return result;
     }
 
@@ -96,15 +96,15 @@ doDepositAuthorized(RPC::JsonContext& context)
         auto const& creds(params[jss::credentials]);
         if (!creds.isArray() || !creds)
         {
-            return RPC::makeError(
+            return rpc::makeError(
                 RpcInvalidParams,
-                RPC::expectedFieldMessage(
+                rpc::expectedFieldMessage(
                     jss::credentials, "is non-empty array of CredentialID(hash256)"));
         }
         if (creds.size() > kMaxCredentialsArraySize)
         {
-            return RPC::makeError(
-                RpcInvalidParams, RPC::expectedFieldMessage(jss::credentials, "array too long"));
+            return rpc::makeError(
+                RpcInvalidParams, rpc::expectedFieldMessage(jss::credentials, "array too long"));
         }
 
         lifeExtender.reserve(creds.size());
@@ -112,9 +112,9 @@ doDepositAuthorized(RPC::JsonContext& context)
         {
             if (!jo.isString())
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     RpcInvalidParams,
-                    RPC::expectedFieldMessage(
+                    rpc::expectedFieldMessage(
                         jss::credentials, "an array of CredentialID(hash256)"));
             }
 
@@ -122,34 +122,34 @@ doDepositAuthorized(RPC::JsonContext& context)
             auto const credS = jo.asString();
             if (!credH.parseHex(credS))
             {
-                return RPC::makeError(
+                return rpc::makeError(
                     RpcInvalidParams,
-                    RPC::expectedFieldMessage(
+                    rpc::expectedFieldMessage(
                         jss::credentials, "an array of CredentialID(hash256)"));
             }
 
             SLE::const_pointer sleCred = ledger->read(keylet::credential(credH));
             if (!sleCred)
             {
-                RPC::injectError(RpcBadCredentials, "credentials don't exist", result);
+                rpc::injectError(RpcBadCredentials, "credentials don't exist", result);
                 return result;
             }
 
             if (!sleCred->isFlag(lsfAccepted))
             {
-                RPC::injectError(RpcBadCredentials, "credentials aren't accepted", result);
+                rpc::injectError(RpcBadCredentials, "credentials aren't accepted", result);
                 return result;
             }
 
             if (credentials::checkExpired(*sleCred, ledger->header().parentCloseTime))
             {
-                RPC::injectError(RpcBadCredentials, "credentials are expired", result);
+                rpc::injectError(RpcBadCredentials, "credentials are expired", result);
                 return result;
             }
 
             if ((*sleCred)[sfSubject] != srcAcct)
             {
-                RPC::injectError(
+                rpc::injectError(
                     RpcBadCredentials, "credentials doesn't belong to the root account", result);
                 return result;
             }
@@ -157,7 +157,7 @@ doDepositAuthorized(RPC::JsonContext& context)
             auto [it, ins] = sorted.emplace((*sleCred)[sfIssuer], (*sleCred)[sfCredentialType]);
             if (!ins)
             {
-                RPC::injectError(RpcBadCredentials, "duplicates in credentials", result);
+                rpc::injectError(RpcBadCredentials, "duplicates in credentials", result);
                 return result;
             }
             lifeExtender.push_back(std::move(sleCred));
diff --git a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
index 632456a3fa..33eaa9ce1e 100644
--- a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp
@@ -33,7 +33,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -48,7 +50,7 @@ using Prices = bimap>, multiset_of const& f)
 {
@@ -149,26 +151,26 @@ getStats(Prices::right_const_iterator const& begin, Prices::right_const_iterator
  *   range - {most recent, most recent - time_threshold} [optional]
  */
 json::Value
-doGetAggregatePrice(RPC::JsonContext& context)
+doGetAggregatePrice(rpc::JsonContext& context)
 {
     json::Value result;
     auto const& params(context.params);
 
     static constexpr std::uint16_t kMaxOracles = 200;
     if (!params.isMember(jss::oracles))
-        return RPC::missingFieldError(jss::oracles);
+        return rpc::missingFieldError(jss::oracles);
     if (!params[jss::oracles].isArray() || params[jss::oracles].size() == 0 ||
         params[jss::oracles].size() > kMaxOracles)
     {
-        RPC::injectError(RpcOracleMalformed, result);
+        rpc::injectError(RpcOracleMalformed, result);
         return result;
     }
 
     if (!params.isMember(jss::base_asset))
-        return RPC::missingFieldError(jss::base_asset);
+        return rpc::missingFieldError(jss::base_asset);
 
     if (!params.isMember(jss::quote_asset))
-        return RPC::missingFieldError(jss::quote_asset);
+        return rpc::missingFieldError(jss::quote_asset);
 
     // Lambda to validate uint type
     // support positive int, uint, and a number represented as a string
@@ -213,49 +215,51 @@ doGetAggregatePrice(RPC::JsonContext& context)
     auto const trim = getField(jss::trim);
     if (std::holds_alternative(trim))
     {
-        RPC::injectError(std::get(trim), result);
+        rpc::injectError(std::get(trim), result);
         return result;
     }
     if (params.isMember(jss::trim) &&
         (std::get(trim) == 0 || std::get(trim) > kMaxTrim))
     {
-        RPC::injectError(RpcInvalidParams, result);
+        rpc::injectError(RpcInvalidParams, result);
         return result;
     }
 
     auto const timeThreshold = getField(jss::time_threshold, 0);
     if (std::holds_alternative(timeThreshold))
     {
-        RPC::injectError(std::get(timeThreshold), result);
+        rpc::injectError(std::get(timeThreshold), result);
         return result;
     }
 
     auto const baseAsset = getCurrency(sfBaseAsset, jss::base_asset);
     if (std::holds_alternative(baseAsset))
     {
-        RPC::injectError(std::get(baseAsset), result);
+        rpc::injectError(std::get(baseAsset), result);
         return result;
     }
     auto const quoteAsset = getCurrency(sfQuoteAsset, jss::quote_asset);
     if (std::holds_alternative(quoteAsset))
     {
-        RPC::injectError(std::get(quoteAsset), result);
+        rpc::injectError(std::get(quoteAsset), result);
         return result;
     }
 
     std::shared_ptr ledger;
-    result = RPC::lookupLedger(ledger, context);
+    result = rpc::lookupLedger(ledger, context);
     if (!ledger)
         return result;  // LCOV_EXCL_LINE
 
     // Collect the dataset into bimap keyed by lastUpdateTime and
     // STAmount (Number is int64 and price is uint64)
     Prices prices;
+    // Track seen {account, documentID} pairs to skip duplicates
+    std::set> seen;
     for (auto const& oracle : params[jss::oracles])
     {
         if (!oracle.isMember(jss::oracle_document_id) || !oracle.isMember(jss::account))
         {
-            RPC::injectError(RpcOracleMalformed, result);
+            rpc::injectError(RpcOracleMalformed, result);
             return result;
         }
         auto const documentID = validUInt(oracle, jss::oracle_document_id)
@@ -264,10 +268,14 @@ doGetAggregatePrice(RPC::JsonContext& context)
         auto const account = parseBase58(oracle[jss::account].asString());
         if (!account || account->isZero() || !documentID)
         {
-            RPC::injectError(RpcInvalidParams, result);
+            rpc::injectError(RpcInvalidParams, result);
             return result;
         }
 
+        // Skip duplicate oracle entries
+        if (!seen.emplace(*account, *documentID).second)
+            continue;
+
         auto const sle = ledger->read(keylet::oracle(*account, *documentID));
         iteratePriceData(context, sle, [&](STObject const& node) {
             auto const& series = node.getFieldArray(sfPriceDataSeries);
@@ -298,7 +306,7 @@ doGetAggregatePrice(RPC::JsonContext& context)
 
     if (prices.empty())
     {
-        RPC::injectError(RpcObjectNotFound, result);
+        rpc::injectError(RpcObjectNotFound, result);
         return result;
     }
 
@@ -321,7 +329,7 @@ doGetAggregatePrice(RPC::JsonContext& context)
         if (prices.empty())
         {
             // LCOV_EXCL_START
-            RPC::injectError(RpcInternal, result);
+            rpc::injectError(RpcInternal, result);
             return result;
             // LCOV_EXCL_STOP
         }
diff --git a/src/xrpld/rpc/handlers/orderbook/NFTBuyOffers.cpp b/src/xrpld/rpc/handlers/orderbook/NFTBuyOffers.cpp
index 88e4392dad..60b48ac5a6 100644
--- a/src/xrpld/rpc/handlers/orderbook/NFTBuyOffers.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/NFTBuyOffers.cpp
@@ -10,15 +10,15 @@
 namespace xrpl {
 
 json::Value
-doNFTBuyOffers(RPC::JsonContext& context)
+doNFTBuyOffers(rpc::JsonContext& context)
 {
     if (!context.params.isMember(jss::nft_id))
-        return RPC::missingFieldError(jss::nft_id);
+        return rpc::missingFieldError(jss::nft_id);
 
     uint256 nftId;
 
     if (!nftId.parseHex(context.params[jss::nft_id].asString()))
-        return RPC::invalidFieldError(jss::nft_id);
+        return rpc::invalidFieldError(jss::nft_id);
 
     return enumerateNFTOffers(context, nftId, keylet::nftBuys(nftId));
 }
diff --git a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
index 70bc258d77..21bf3f8be8 100644
--- a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
+++ b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h
@@ -52,15 +52,15 @@ appendNftOfferJson(Application const& app, SLE::const_ref offer, json::Value& of
 //   marker: opaque                 // optional, resume previous query
 // }
 inline json::Value
-enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const& directory)
+enumerateNFTOffers(rpc::JsonContext& context, uint256 const& nftId, Keylet const& directory)
 {
     unsigned int limit = 0;
-    if (auto err = readLimitField(limit, RPC::Tuning::kNftOffers, context))
+    if (auto err = readLimitField(limit, rpc::tuning::kNftOffers, context))
         return *err;
 
     std::shared_ptr ledger;
 
-    if (auto result = RPC::lookupLedger(ledger, context); !ledger)
+    if (auto result = rpc::lookupLedger(ledger, context); !ledger)
         return result;
 
     if (!ledger->exists(directory))
@@ -83,7 +83,7 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
         json::Value const& marker(context.params[jss::marker]);
 
         if (!marker.isString())
-            return RPC::expectedFieldError(jss::marker, "string");
+            return rpc::expectedFieldError(jss::marker, "string");
 
         if (!startAfter.parseHex(marker.asString()))
             return rpcError(RpcInvalidParams);
@@ -93,6 +93,17 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
         if (!sle || nftId != sle->getFieldH256(sfNFTokenID))
             return rpcError(RpcInvalidParams);
 
+        // Reject a marker that references an offer on the opposite side
+        // (buy vs. sell) of the directory being enumerated.  Without this
+        // check the marker's node hint points into the other directory, so
+        // forEachItemAfter never finds `startAfter` and instead scans every
+        // page of `directory` before returning invalidParams -- turning an
+        // O(1) rejection into an O(directory size) walk.
+        auto const offerDir =
+            sle->isFlag(lsfSellNFToken) ? keylet::nftSells(nftId) : keylet::nftBuys(nftId);
+        if (directory.key != offerDir.key)
+            return rpcError(RpcInvalidParams);
+
         startHint = sle->getFieldU64(sfNFTokenOfferNode);
         appendNftOfferJson(context.app, sle, jsonOffers);
         offers.reserve(reserve);
@@ -127,7 +138,7 @@ enumerateNFTOffers(RPC::JsonContext& context, uint256 const& nftId, Keylet const
     for (auto const& offer : offers)
         appendNftOfferJson(context.app, offer, jsonOffers);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
     return result;
 }
 
diff --git a/src/xrpld/rpc/handlers/orderbook/NFTSellOffers.cpp b/src/xrpld/rpc/handlers/orderbook/NFTSellOffers.cpp
index 309df93605..8b09b42a34 100644
--- a/src/xrpld/rpc/handlers/orderbook/NFTSellOffers.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/NFTSellOffers.cpp
@@ -10,15 +10,15 @@
 namespace xrpl {
 
 json::Value
-doNFTSellOffers(RPC::JsonContext& context)
+doNFTSellOffers(rpc::JsonContext& context)
 {
     if (!context.params.isMember(jss::nft_id))
-        return RPC::missingFieldError(jss::nft_id);
+        return rpc::missingFieldError(jss::nft_id);
 
     uint256 nftId;
 
     if (!nftId.parseHex(context.params[jss::nft_id].asString()))
-        return RPC::invalidFieldError(jss::nft_id);
+        return rpc::invalidFieldError(jss::nft_id);
 
     return enumerateNFTOffers(context, nftId, keylet::nftSells(nftId));
 }
diff --git a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
index 3a6b52ee98..c46238a5a5 100644
--- a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp
@@ -13,7 +13,7 @@
 namespace xrpl {
 
 json::Value
-doPathFind(RPC::JsonContext& context)
+doPathFind(rpc::JsonContext& context)
 {
     if (context.app.config().pathSearchMax == 0)
         return rpcError(RpcNotSupported);
@@ -34,7 +34,7 @@ doPathFind(RPC::JsonContext& context)
 
     if (sSubCommand == "create")
     {
-        context.loadType = Resource::kFeeHeavyBurdenRpc;
+        context.loadType = resource::kFeeHeavyBurdenRpc;
         context.infoSub->clearRequest();
         return context.app.getPathRequestManager().makePathRequest(
             context.infoSub, lpLedger, context.params);
diff --git a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
index b7edfb6dbe..923cb0f7f5 100644
--- a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
+++ b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp
@@ -21,12 +21,12 @@ namespace xrpl {
 
 // This interface is deprecated.
 json::Value
-doRipplePathFind(RPC::JsonContext& context)
+doRipplePathFind(rpc::JsonContext& context)
 {
     if (context.app.config().pathSearchMax == 0)
         return rpcError(RpcNotSupported);
 
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
+    context.loadType = resource::kFeeHeavyBurdenRpc;
 
     std::shared_ptr lpLedger;
     json::Value jvResult;
@@ -37,7 +37,7 @@ doRipplePathFind(RPC::JsonContext& context)
         // No ledger specified, use pathfinding defaults
         // and dispatch to pathfinding engine
         if (context.app.getLedgerMaster().getValidatedLedgerAge() >
-            RPC::Tuning::kMaxValidatedLedgerAge)
+            rpc::tuning::kMaxValidatedLedgerAge)
         {
             if (context.apiVersion == 1)
                 return rpcError(RpcNoNetwork);
@@ -146,11 +146,11 @@ doRipplePathFind(RPC::JsonContext& context)
     }
 
     // The caller specified a ledger
-    jvResult = RPC::lookupLedger(lpLedger, context);
+    jvResult = rpc::lookupLedger(lpLedger, context);
     if (!lpLedger)
         return jvResult;
 
-    RPC::LegacyPathFind const lpf(isUnlimited(context.role), context.app);
+    rpc::LegacyPathFind const lpf(isUnlimited(context.role), context.app);
     if (!lpf.isOk())
         return rpcError(RpcTooBusy);
 
diff --git a/src/xrpld/rpc/handlers/server_info/Feature.cpp b/src/xrpld/rpc/handlers/server_info/Feature.cpp
index bd7198b61c..1906658106 100644
--- a/src/xrpld/rpc/handlers/server_info/Feature.cpp
+++ b/src/xrpld/rpc/handlers/server_info/Feature.cpp
@@ -18,7 +18,7 @@ namespace xrpl {
 //   vetoed : true/false
 // }
 json::Value
-doFeature(RPC::JsonContext& context)
+doFeature(rpc::JsonContext& context)
 {
     if (context.params.isMember(jss::feature))
     {
diff --git a/src/xrpld/rpc/handlers/server_info/Fee.cpp b/src/xrpld/rpc/handlers/server_info/Fee.cpp
index 1fe5476d50..2e4147fc1e 100644
--- a/src/xrpld/rpc/handlers/server_info/Fee.cpp
+++ b/src/xrpld/rpc/handlers/server_info/Fee.cpp
@@ -8,7 +8,7 @@
 
 namespace xrpl {
 json::Value
-doFee(RPC::JsonContext& context)
+doFee(rpc::JsonContext& context)
 {
     auto result = context.app.getTxQ().doRPC(context.app);
     if (result.type() == json::ValueType::Object)
@@ -16,7 +16,7 @@ doFee(RPC::JsonContext& context)
 
     // LCOV_EXCL_START
     UNREACHABLE("xrpl::doFee : invalid result type");
-    RPC::injectError(RpcInternal, context.params);
+    rpc::injectError(RpcInternal, context.params);
     return context.params;
     // LCOV_EXCL_STOP
 }
diff --git a/src/xrpld/rpc/handlers/server_info/Manifest.cpp b/src/xrpld/rpc/handlers/server_info/Manifest.cpp
index cb1771750b..c0b29d8275 100644
--- a/src/xrpld/rpc/handlers/server_info/Manifest.cpp
+++ b/src/xrpld/rpc/handlers/server_info/Manifest.cpp
@@ -12,12 +12,12 @@
 
 namespace xrpl {
 json::Value
-doManifest(RPC::JsonContext& context)
+doManifest(rpc::JsonContext& context)
 {
     auto& params = context.params;
 
     if (!params.isMember(jss::public_key))
-        return RPC::missingFieldError(jss::public_key);
+        return rpc::missingFieldError(jss::public_key);
 
     auto const requested = params[jss::public_key].asString();
 
@@ -27,7 +27,7 @@ doManifest(RPC::JsonContext& context)
     auto const pk = parseBase58(TokenType::NodePublic, requested);
     if (!pk)
     {
-        RPC::injectError(RpcInvalidParams, ret);
+        rpc::injectError(RpcInvalidParams, ret);
         return ret;
     }
 
diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
index cce1b3e07f..c297c2482d 100644
--- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
+++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp
@@ -2,6 +2,7 @@
 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -14,7 +15,6 @@
 #include 
 #include 
 
-#include 
 #include 
 
 #include 
@@ -64,7 +64,6 @@ ServerDefinitions::translate(std::string const& inp)
         return out;
     };
 
-    // TODO: use string::contains with C++23
     auto contains = [&](std::string_view s) -> bool { return inp.contains(s); };
 
     if (contains("UINT"))
@@ -107,7 +106,7 @@ ServerDefinitions::translate(std::string const& inp)
         std::string token = inpToProcess.substr(0, pos);
         if (token.size() > 1)
         {
-            boost::algorithm::to_lower(token);
+            token = toLower(token);
             token[0] -= ('a' - 'A');
             out += token;
         }
@@ -382,7 +381,7 @@ getServerDefinitionsJson()
 }
 
 json::Value
-doServerDefinitions(RPC::JsonContext& context)
+doServerDefinitions(rpc::JsonContext& context)
 {
     auto& params = context.params;
 
@@ -390,7 +389,7 @@ doServerDefinitions(RPC::JsonContext& context)
     if (params.isMember(jss::hash))
     {
         if (!params[jss::hash].isString() || !hash.parseHex(params[jss::hash].asString()))
-            return RPC::invalidFieldError(jss::hash);
+            return rpc::invalidFieldError(jss::hash);
     }
 
     auto const& defs = detail::getDefinitions();
diff --git a/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp b/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp
index aaad9d2b02..fd6e2f717f 100644
--- a/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp
+++ b/src/xrpld/rpc/handlers/server_info/ServerInfo.cpp
@@ -9,7 +9,7 @@
 namespace xrpl {
 
 json::Value
-doServerInfo(RPC::JsonContext& context)
+doServerInfo(rpc::JsonContext& context)
 {
     json::Value ret(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/server_info/ServerState.cpp b/src/xrpld/rpc/handlers/server_info/ServerState.cpp
index acf4e9eb43..e0d43d4053 100644
--- a/src/xrpld/rpc/handlers/server_info/ServerState.cpp
+++ b/src/xrpld/rpc/handlers/server_info/ServerState.cpp
@@ -8,7 +8,7 @@
 namespace xrpl {
 
 json::Value
-doServerState(RPC::JsonContext& context)
+doServerState(rpc::JsonContext& context)
 {
     json::Value ret(json::ValueType::Object);
 
diff --git a/src/xrpld/rpc/handlers/server_info/Version.h b/src/xrpld/rpc/handlers/server_info/Version.h
index f25d8679ba..40ad4e5e71 100644
--- a/src/xrpld/rpc/handlers/server_info/Version.h
+++ b/src/xrpld/rpc/handlers/server_info/Version.h
@@ -9,7 +9,7 @@
 #include 
 #include 
 
-namespace xrpl::RPC {
+namespace xrpl::rpc {
 
 class VersionHandler
 {
@@ -34,9 +34,9 @@ public:
     // NOLINTBEGIN(readability-identifier-naming)
     static constexpr char const* name = "version";
 
-    static constexpr unsigned minApiVer = RPC::kApiMinimumSupportedVersion;
+    static constexpr unsigned minApiVer = rpc::kApiMinimumSupportedVersion;
 
-    static constexpr unsigned maxApiVer = RPC::kApiMaximumValidVersion;
+    static constexpr unsigned maxApiVer = rpc::kApiMaximumValidVersion;
 
     static constexpr Role role = Role::USER;
 
@@ -48,4 +48,4 @@ private:
     bool betaEnabled_;
 };
 
-}  // namespace xrpl::RPC
+}  // namespace xrpl::rpc
diff --git a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp
index 93840bb6d6..3f6d716f29 100644
--- a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp
+++ b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp
@@ -7,6 +7,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -19,6 +20,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -26,8 +28,26 @@
 
 namespace xrpl {
 
+namespace {
+
+/**
+ * Test whether admitting `additional` subscriptions would exceed the cap.
+ *
+ * @param ispSub     The connection's InfoSub, queried for its current count.
+ * @param additional Number of new items this branch would add.
+ * @param cap        The effective per-connection cap for this request.
+ * @return true if the request must be rejected to stay within the cap.
+ */
+[[nodiscard]] bool
+wouldExceedSubscriptionCap(InfoSub::ref ispSub, std::size_t additional, std::size_t cap)
+{
+    return exceedsSubscriptionCap(ispSub->totalSubscriptionCount(), additional, cap);
+}
+
+}  // namespace
+
 json::Value
-doSubscribe(RPC::JsonContext& context)
+doSubscribe(rpc::JsonContext& context)
 {
     InfoSub::pointer ispSub;
     json::Value jvResult(json::ValueType::Object);
@@ -79,7 +99,7 @@ doSubscribe(RPC::JsonContext& context)
             }
             catch (std::runtime_error const& ex)
             {
-                return RPC::makeParamError(ex.what());
+                return rpc::makeParamError(ex.what());
             }
         }
         else
@@ -105,6 +125,11 @@ doSubscribe(RPC::JsonContext& context)
     }
     ispSub->setApiVersion(context.apiVersion);
 
+    // Effective per-connection subscription cap: a configured override if set,
+    // otherwise the built-in default. Resolved once and reused by every branch.
+    std::size_t const subscriptionCap =
+        context.app.config().maxSubscriptionsPerConnection.value_or(kMaxSubscriptionsPerConnection);
+
     if (context.params.isMember(jss::streams))
     {
         if (!context.params[jss::streams].isArray())
@@ -166,30 +191,59 @@ doSubscribe(RPC::JsonContext& context)
         }
     }
 
+    // Parse the proposed (real-time) and normal account sets first, then check
+    // the cap against their COMBINED net-new total before subscribing either.
+    // This keeps the account pair all-or-nothing: it never subscribes one set
+    // and then rejects on the other. Other fields (streams and account_history)
+    // are still checked and subscribed independently, as they always have been,
+    // so a later field can be rejected after an earlier one subscribed. The cap
+    // counts only NET-NEW accounts (those not already tracked on this
+    // connection), so re-subscribing accounts already held is never wrongly
+    // rejected.
     auto accountsProposed = context.params.isMember(jss::accounts_proposed)
         ? jss::accounts_proposed
         : jss::rt_accounts;  // DEPRECATED
-    if (context.params.isMember(accountsProposed))
+    bool const hasProposed = context.params.isMember(accountsProposed);
+    bool const hasAccounts = context.params.isMember(jss::accounts);
+
+    hash_set proposedIds;
+    hash_set accountIds;
+
+    if (hasProposed)
     {
         if (!context.params[accountsProposed].isArray())
             return rpcError(RpcInvalidParams);
 
-        auto ids = RPC::parseAccountIds(context.params[accountsProposed]);
-        if (ids.empty())
+        proposedIds = rpc::parseAccountIds(context.params[accountsProposed]);
+        if (proposedIds.empty())
             return rpcError(RpcActMalformed);
-        context.netOps.subAccount(ispSub, ids, true);
     }
 
-    if (context.params.isMember(jss::accounts))
+    if (hasAccounts)
     {
         if (!context.params[jss::accounts].isArray())
             return rpcError(RpcInvalidParams);
 
-        auto ids = RPC::parseAccountIds(context.params[jss::accounts]);
-        if (ids.empty())
+        accountIds = rpc::parseAccountIds(context.params[jss::accounts]);
+        if (accountIds.empty())
             return rpcError(RpcActMalformed);
-        context.netOps.subAccount(ispSub, ids, false);
-        JLOG(context.j.debug()) << "doSubscribe: accounts: " << ids.size();
+    }
+
+    if (hasProposed || hasAccounts)
+    {
+        // Atomic check-and-reserve, so two concurrent requests sharing this
+        // InfoSub (admin subscribe-by-url) cannot both pass the cap check.
+        if (!ispSub->tryReserveAccountSubscriptions(proposedIds, accountIds, subscriptionCap))
+            return rpc::makeParamError("Too many subscriptions for this connection.");
+    }
+
+    if (hasProposed)
+        context.netOps.subAccount(ispSub, proposedIds, true);
+
+    if (hasAccounts)
+    {
+        context.netOps.subAccount(ispSub, accountIds, false);
+        JLOG(context.j.debug()) << "doSubscribe: accounts: " << accountIds.size();
     }
 
     if (context.params.isMember(jss::account_history_tx_stream))
@@ -197,7 +251,7 @@ doSubscribe(RPC::JsonContext& context)
         if (!context.app.config().useTxTables())
             return rpcError(RpcNotEnabled);
 
-        context.loadType = Resource::kFeeMediumBurdenRpc;
+        context.loadType = resource::kFeeMediumBurdenRpc;
         auto const& req = context.params[jss::account_history_tx_stream];
         if (!req.isMember(jss::account) || !req[jss::account].isString())
             return rpcError(RpcInvalidParams);
@@ -206,6 +260,13 @@ doSubscribe(RPC::JsonContext& context)
         if (!id)
             return rpcError(RpcInvalidParams);
 
+        // Charge the cap only when net-new, like the account branches. Not
+        // atomic here (subAccountHistory does its own dup-detecting insert), but
+        // a concurrent race adds at most one entry, so the overshoot is trivial.
+        std::size_t const historyCharge = ispSub->hasAccountHistorySubscription(*id) ? 0 : 1;
+        if (wouldExceedSubscriptionCap(ispSub, historyCharge, subscriptionCap))
+            return rpc::makeParamError("Too many subscriptions for this connection.");
+
         if (auto result = context.netOps.subAccountHistory(ispSub, *id); result != RpcSuccess)
         {
             return rpcError(result);
@@ -222,6 +283,10 @@ doSubscribe(RPC::JsonContext& context)
         if (!context.params[jss::books].isArray())
             return rpcError(RpcInvalidParams);
 
+        // Book subscriptions are tracked separately (OrderBookDB) and are not
+        // part of totalSubscriptionCount(), so they are not gated by the
+        // per-connection account cap. Each book entry is validated and
+        // subscribed below.
         for (auto& j : context.params[jss::books])
         {
             if (!j.isObject() || !j.isMember(jss::taker_pays) || !j.isMember(jss::taker_gets) ||
@@ -230,11 +295,11 @@ doSubscribe(RPC::JsonContext& context)
 
             Book book;
 
-            if (auto const err = RPC::parseSubUnsubJson(book.in, j, jss::taker_pays, context.j);
+            if (auto const err = rpc::parseSubUnsubJson(book.in, j, jss::taker_pays, context.j);
                 err != RpcSuccess)
                 return rpcError(err);
 
-            if (auto const err = RPC::parseSubUnsubJson(book.out, j, jss::taker_gets, context.j);
+            if (auto const err = rpc::parseSubUnsubJson(book.out, j, jss::taker_gets, context.j);
                 err != RpcSuccess)
                 return rpcError(err);
 
@@ -285,7 +350,7 @@ doSubscribe(RPC::JsonContext& context)
             if ((j.isMember(jss::snapshot) && j[jss::snapshot].asBool()) ||
                 (j.isMember(jss::state_now) && j[jss::state_now].asBool()))
             {
-                context.loadType = Resource::kFeeMediumBurdenRpc;
+                context.loadType = resource::kFeeMediumBurdenRpc;
                 std::shared_ptr lpLedger =
                     context.app.getLedgerMaster().getPublishedLedger();
                 if (lpLedger)
@@ -299,7 +364,7 @@ doSubscribe(RPC::JsonContext& context)
                             field == jss::asks ? reversed(book) : book,
                             takerID ? *takerID : noAccount(),
                             false,
-                            RPC::Tuning::kBookOffers.rDefault,
+                            rpc::tuning::kBookOffers.rDefault,
                             jvMarker,
                             jvOffers);
 
diff --git a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
index af42af2a55..33c785a9bb 100644
--- a/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
+++ b/src/xrpld/rpc/handlers/subscribe/Unsubscribe.cpp
@@ -18,7 +18,7 @@
 namespace xrpl {
 
 json::Value
-doUnsubscribe(RPC::JsonContext& context)
+doUnsubscribe(rpc::JsonContext& context)
 {
     InfoSub::pointer ispSub;
     json::Value jvResult(json::ValueType::Object);
@@ -106,7 +106,7 @@ doUnsubscribe(RPC::JsonContext& context)
         if (!context.params[accountsProposed].isArray())
             return rpcError(RpcInvalidParams);
 
-        auto ids = RPC::parseAccountIds(context.params[accountsProposed]);
+        auto ids = rpc::parseAccountIds(context.params[accountsProposed]);
         if (ids.empty())
             return rpcError(RpcActMalformed);
         context.netOps.unsubAccount(ispSub, ids, true);
@@ -117,7 +117,7 @@ doUnsubscribe(RPC::JsonContext& context)
         if (!context.params[jss::accounts].isArray())
             return rpcError(RpcInvalidParams);
 
-        auto ids = RPC::parseAccountIds(context.params[jss::accounts]);
+        auto ids = rpc::parseAccountIds(context.params[jss::accounts]);
         if (ids.empty())
             return rpcError(RpcActMalformed);
         context.netOps.unsubAccount(ispSub, ids, false);
@@ -161,11 +161,11 @@ doUnsubscribe(RPC::JsonContext& context)
 
             Book book;
 
-            if (auto const err = RPC::parseSubUnsubJson(book.in, jv, jss::taker_pays, context.j);
+            if (auto const err = rpc::parseSubUnsubJson(book.in, jv, jss::taker_pays, context.j);
                 err != RpcSuccess)
                 return rpcError(err);
 
-            if (auto const err = RPC::parseSubUnsubJson(book.out, jv, jss::taker_gets, context.j);
+            if (auto const err = rpc::parseSubUnsubJson(book.out, jv, jss::taker_gets, context.j);
                 err != RpcSuccess)
                 return rpcError(err);
 
diff --git a/src/xrpld/rpc/handlers/transaction/Simulate.cpp b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
index 0f163c7356..8441add08b 100644
--- a/src/xrpld/rpc/handlers/transaction/Simulate.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Simulate.cpp
@@ -44,7 +44,7 @@
 namespace xrpl {
 
 static std::expected
-getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context)
+getAutofillSequence(json::Value const& txJson, rpc::JsonContext& context)
 {
     // autofill Sequence
     bool const hasTicketSeq = txJson.isMember(sfTicketSequence.jsonName);
@@ -53,14 +53,14 @@ getAutofillSequence(json::Value const& txJson, RPC::JsonContext& context)
     {
         // sanity check, should fail earlier
         // LCOV_EXCL_START
-        return std::unexpected(RPC::invalidFieldError("tx.Account"));
+        return std::unexpected(rpc::invalidFieldError("tx.Account"));
         // LCOV_EXCL_STOP
     }
     auto const srcAddressID = parseBase58(accountStr.asString());
     if (!srcAddressID.has_value())
     {
         return std::unexpected(
-            RPC::makeError(RpcSrcActMalformed, RPC::invalidFieldMessage("tx.Account")));
+            rpc::makeError(RpcSrcActMalformed, rpc::invalidFieldMessage("tx.Account")));
     }
     SLE::const_pointer const sle =
         context.app.getOpenLedger().current()->read(keylet::account(*srcAddressID));
@@ -88,7 +88,7 @@ autofillSignature(json::Value& sigObject, std::string const& fieldPrefix = "tx")
     if (sigObject.isMember(jss::Signers))
     {
         if (!sigObject[jss::Signers].isArray())
-            return RPC::invalidFieldError(fieldPrefix + ".Signers");
+            return rpc::invalidFieldError(fieldPrefix + ".Signers");
         // check multisigned signers
         for (unsigned index = 0; index < sigObject[jss::Signers].size(); index++)
         {
@@ -96,7 +96,7 @@ autofillSignature(json::Value& sigObject, std::string const& fieldPrefix = "tx")
             if (!signer.isObject() || !signer.isMember(jss::Signer) ||
                 !signer[jss::Signer].isObject())
             {
-                return RPC::invalidFieldError(
+                return rpc::invalidFieldError(
                     fieldPrefix + ".Signers[" + std::to_string(index) + "]");
             }
 
@@ -133,7 +133,7 @@ autofillSignature(json::Value& sigObject, std::string const& fieldPrefix = "tx")
 }
 
 static std::optional
-autofillTx(json::Value& txJson, RPC::JsonContext& context)
+autofillTx(json::Value& txJson, rpc::JsonContext& context)
 {
     if (auto error = autofillSignature(txJson))
         return error;
@@ -142,7 +142,7 @@ autofillTx(json::Value& txJson, RPC::JsonContext& context)
     {
         auto& sponsorSignature = txJson[sfSponsorSignature.jsonName];
         if (!sponsorSignature.isObject())
-            return RPC::objectFieldError(sfSponsorSignature.jsonName);
+            return rpc::objectFieldError(sfSponsorSignature.jsonName);
 
         if (auto const error = autofillSignature(sponsorSignature, "tx.SponsorSignature"))
             return error;
@@ -167,7 +167,7 @@ autofillTx(json::Value& txJson, RPC::JsonContext& context)
     {
         // Autofill Fee after normalizing nested signer fields so the fee
         // estimator sees the full transaction shape.
-        auto feeOrError = RPC::getCurrentNetworkFee(
+        auto feeOrError = rpc::getCurrentNetworkFee(
             context.role,
             context.app.config(),
             context.app.getFeeTrack(),
@@ -191,18 +191,18 @@ getTxJsonFromParams(json::Value const& params)
     {
         if (params.isMember(jss::tx_json))
         {
-            return RPC::makeParamError("Can only include one of `tx_blob` and `tx_json`.");
+            return rpc::makeParamError("Can only include one of `tx_blob` and `tx_json`.");
         }
 
         auto const txBlob = params[jss::tx_blob];
         if (!txBlob.isString())
         {
-            return RPC::invalidFieldError(jss::tx_blob);
+            return rpc::invalidFieldError(jss::tx_blob);
         }
 
         auto unHexed = strUnHex(txBlob.asString());
         if (!unHexed || unHexed->empty())
-            return RPC::invalidFieldError(jss::tx_blob);
+            return rpc::invalidFieldError(jss::tx_blob);
 
         try
         {
@@ -211,7 +211,7 @@ getTxJsonFromParams(json::Value const& params)
         }
         catch (std::runtime_error const&)
         {
-            return RPC::invalidFieldError(jss::tx_blob);
+            return rpc::invalidFieldError(jss::tx_blob);
         }
     }
     else if (params.isMember(jss::tx_json))
@@ -219,30 +219,30 @@ getTxJsonFromParams(json::Value const& params)
         txJson = params[jss::tx_json];
         if (!txJson.isObject())
         {
-            return RPC::objectFieldError(jss::tx_json);
+            return rpc::objectFieldError(jss::tx_json);
         }
     }
     else
     {
-        return RPC::makeParamError("Neither `tx_blob` nor `tx_json` included.");
+        return rpc::makeParamError("Neither `tx_blob` nor `tx_json` included.");
     }
 
     // basic sanity checks for transaction shape
     if (!txJson.isMember(jss::TransactionType))
     {
-        return RPC::missingFieldError("tx.TransactionType");
+        return rpc::missingFieldError("tx.TransactionType");
     }
 
     if (!txJson.isMember(jss::Account))
     {
-        return RPC::missingFieldError("tx.Account");
+        return rpc::missingFieldError("tx.Account");
     }
 
     return txJson;
 }
 
 static json::Value
-simulateTxn(RPC::JsonContext& context, std::shared_ptr transaction)
+simulateTxn(rpc::JsonContext& context, std::shared_ptr transaction)
 {
     json::Value jvResult;
     // Process the transaction
@@ -290,11 +290,11 @@ simulateTxn(RPC::JsonContext& context, std::shared_ptr transaction)
         else
         {
             jvResult[jss::meta] = result.metadata->getJson(JsonOptions::Values::None);
-            RPC::insertDeliveredAmount(
+            rpc::insertDeliveredAmount(
                 jvResult[jss::meta], view, transaction->getSTransaction(), *result.metadata);
-            RPC::insertNFTSyntheticInJson(
+            rpc::insertNFTSyntheticInJson(
                 jvResult, transaction->getSTransaction(), *result.metadata);
-            RPC::insertMPTokenIssuanceID(
+            rpc::insertMPTokenIssuanceID(
                 jvResult[jss::meta], transaction->getSTransaction(), *result.metadata);
         }
     }
@@ -317,23 +317,23 @@ simulateTxn(RPC::JsonContext& context, std::shared_ptr transaction)
 //   binary: 
 // }
 json::Value
-doSimulate(RPC::JsonContext& context)
+doSimulate(rpc::JsonContext& context)
 {
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
 
     json::Value txJson;  // the tx as a JSON
 
     // check validity of `binary` param
     if (context.params.isMember(jss::binary) && !context.params[jss::binary].isBool())
     {
-        return RPC::invalidFieldError(jss::binary);
+        return rpc::invalidFieldError(jss::binary);
     }
 
     for (auto const field : {jss::secret, jss::seed, jss::seed_hex, jss::passphrase})
     {
         if (context.params.isMember(field))
         {
-            return RPC::invalidFieldError(field);
+            return rpc::invalidFieldError(field);
         }
     }
 
@@ -365,13 +365,13 @@ doSimulate(RPC::JsonContext& context)
 
     if (stTx->getTxnType() == ttBATCH)
     {
-        return RPC::makeError(RpcNotImpl);
+        return rpc::makeError(RpcNotImpl);
     }
 
     // Reject transactions with the tfInnerBatchTxn flag.
     if (stTx->isFlag(tfInnerBatchTxn))
     {
-        return RPC::makeError(
+        return rpc::makeError(
             RpcInvalidParams, "tfInnerBatchTxn flag is not allowed on top-level transactions.");
     }
 
diff --git a/src/xrpld/rpc/handlers/transaction/Submit.cpp b/src/xrpld/rpc/handlers/transaction/Submit.cpp
index 79f3680684..05a1552221 100644
--- a/src/xrpld/rpc/handlers/transaction/Submit.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Submit.cpp
@@ -27,11 +27,11 @@
 namespace xrpl {
 
 static std::expected
-getFailHard(RPC::JsonContext const& context)
+getFailHard(rpc::JsonContext const& context)
 {
     if (context.params.isMember(jss::fail_hard) && !context.params[jss::fail_hard].isBool())
     {
-        return std::unexpected(RPC::expectedFieldError(jss::fail_hard, "boolean"));
+        return std::unexpected(rpc::expectedFieldError(jss::fail_hard, "boolean"));
     }
     return NetworkOPs::doFailHard(
         context.params.isMember(jss::fail_hard) && context.params[jss::fail_hard].asBool());
@@ -42,9 +42,9 @@ getFailHard(RPC::JsonContext const& context)
 //   secret: 
 // }
 json::Value
-doSubmit(RPC::JsonContext& context)
+doSubmit(rpc::JsonContext& context)
 {
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
 
     if (!context.params.isMember(jss::tx_blob))
     {
@@ -53,16 +53,16 @@ doSubmit(RPC::JsonContext& context)
             return failType.error();
 
         if (context.role != Role::ADMIN && !context.app.config().canSign())
-            return RPC::makeError(RpcNotSupported, "Signing is not supported by this server.");
+            return rpc::makeError(RpcNotSupported, "Signing is not supported by this server.");
 
-        auto ret = RPC::transactionSubmit(
+        auto ret = rpc::transactionSubmit(
             context.params,
             context.apiVersion,
             *failType,
             context.role,
             context.ledgerMaster.getValidatedLedgerAge(),
             context.app,
-            RPC::getProcessTxnFn(context.netOps));
+            rpc::getProcessTxnFn(context.netOps));
 
         ret[jss::deprecated] =
             "Signing support in the 'submit' command has been "
diff --git a/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp b/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp
index cc04ed073e..09301ca8a6 100644
--- a/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp
+++ b/src/xrpld/rpc/handlers/transaction/SubmitMultiSigned.cpp
@@ -13,20 +13,20 @@ namespace xrpl {
 //   tx_json: ,
 // }
 json::Value
-doSubmitMultiSigned(RPC::JsonContext& context)
+doSubmitMultiSigned(rpc::JsonContext& context)
 {
-    context.loadType = Resource::kFeeHeavyBurdenRpc;
+    context.loadType = resource::kFeeHeavyBurdenRpc;
     auto const failHard = context.params[jss::fail_hard].asBool();
     auto const failType = NetworkOPs::doFailHard(failHard);
 
-    return RPC::transactionSubmitMultiSigned(
+    return rpc::transactionSubmitMultiSigned(
         context.params,
         context.apiVersion,
         failType,
         context.role,
         context.ledgerMaster.getValidatedLedgerAge(),
         context.app,
-        RPC::getProcessTxnFn(context.netOps));
+        rpc::getProcessTxnFn(context.netOps));
 }
 
 }  // namespace xrpl
diff --git a/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp b/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp
index bb68226334..2bd97e852f 100644
--- a/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp
+++ b/src/xrpld/rpc/handlers/transaction/TransactionEntry.cpp
@@ -21,10 +21,10 @@ namespace xrpl {
 // XXX In this case, not specify either ledger does not mean ledger current. It
 // means any ledger.
 json::Value
-doTransactionEntry(RPC::JsonContext& context)
+doTransactionEntry(rpc::JsonContext& context)
 {
     std::shared_ptr lpLedger;
-    json::Value jvResult = RPC::lookupLedger(lpLedger, context);
+    json::Value jvResult = rpc::lookupLedger(lpLedger, context);
 
     if (!lpLedger)
         return jvResult;
@@ -84,7 +84,7 @@ doTransactionEntry(RPC::JsonContext& context)
                 jvResult[jss::tx_json] = sttx->getJson(JsonOptions::Values::None);
             }
 
-            RPC::insertDeliverMax(jvResult[jss::tx_json], sttx->getTxnType(), context.apiVersion);
+            rpc::insertDeliverMax(jvResult[jss::tx_json], sttx->getTxnType(), context.apiVersion);
 
             auto const jsonMeta = (context.apiVersion > 1 ? jss::meta : jss::metadata);
             if (stobj)
diff --git a/src/xrpld/rpc/handlers/transaction/Tx.cpp b/src/xrpld/rpc/handlers/transaction/Tx.cpp
index c065bc268e..ee7110bf6b 100644
--- a/src/xrpld/rpc/handlers/transaction/Tx.cpp
+++ b/src/xrpld/rpc/handlers/transaction/Tx.cpp
@@ -68,8 +68,8 @@ struct TxArgs
     std::optional> ledgerRange;
 };
 
-std::pair
-doTxHelp(RPC::Context& context, TxArgs args)
+std::pair
+doTxHelp(rpc::Context& context, TxArgs args)
 {
     TxResult result;
 
@@ -169,7 +169,7 @@ doTxHelp(RPC::Context& context, TxArgs args)
             uint32_t const netID = context.app.getNetworkIDService().getNetworkID();
 
             if (txnIdx <= 0xFFFFU && netID < 0xFFFFU && lgrSeq < 0x0FFF'FFFFUL)
-                result.ctid = RPC::encodeCTID(lgrSeq, txnIdx, netID);
+                result.ctid = rpc::encodeCTID(lgrSeq, txnIdx, netID);
         }
     }
 
@@ -178,12 +178,12 @@ doTxHelp(RPC::Context& context, TxArgs args)
 
 json::Value
 populateJsonResponse(
-    std::pair const& res,
+    std::pair const& res,
     TxArgs const& args,
-    RPC::JsonContext const& context)
+    rpc::JsonContext const& context)
 {
     json::Value response;
-    RPC::Status const& error = res.second;
+    rpc::Status const& error = res.second;
     TxResult const& result = res.first;
     // handle errors
     if (error.toErrorCode() != RpcSuccess)
@@ -215,7 +215,7 @@ populateJsonResponse(
             else
             {
                 response[jss::tx_json] = result.txn->getJson(kOptionsJson);
-                RPC::insertDeliverMax(
+                rpc::insertDeliverMax(
                     response[jss::tx_json], sttx->getTxnType(), context.apiVersion);
             }
 
@@ -236,7 +236,7 @@ populateJsonResponse(
         {
             response = result.txn->getJson(JsonOptions::Values::IncludeDate, args.binary);
             if (!args.binary)
-                RPC::insertDeliverMax(response, sttx->getTxnType(), context.apiVersion);
+                rpc::insertDeliverMax(response, sttx->getTxnType(), context.apiVersion);
         }
 
         // populate binary metadata
@@ -254,8 +254,8 @@ populateJsonResponse(
             {
                 response[jss::meta] = meta->getJson(JsonOptions::Values::None);
                 insertDeliveredAmount(response[jss::meta], context, result.txn, *meta);
-                RPC::insertNFTSyntheticInJson(response, sttx, *meta);
-                RPC::insertMPTokenIssuanceID(response[jss::meta], sttx, *meta);
+                rpc::insertNFTSyntheticInJson(response, sttx, *meta);
+                rpc::insertMPTokenIssuanceID(response[jss::meta], sttx, *meta);
             }
         }
         response[jss::validated] = result.validated;
@@ -267,7 +267,7 @@ populateJsonResponse(
 }
 
 json::Value
-doTxJson(RPC::JsonContext& context)
+doTxJson(rpc::JsonContext& context)
 {
     if (!context.app.config().useTxTables())
         return rpcError(RpcNotEnabled);
@@ -291,7 +291,7 @@ doTxJson(RPC::JsonContext& context)
     }
     else if (context.params.isMember(jss::ctid))
     {
-        auto ctid = RPC::decodeCTID(context.params[jss::ctid].asString());
+        auto ctid = rpc::decodeCTID(context.params[jss::ctid].asString());
         if (!ctid)
             return rpcError(RpcInvalidParams);
 
@@ -302,7 +302,7 @@ doTxJson(RPC::JsonContext& context)
             out << "Wrong network. You should submit this request to a node "
                    "running on NetworkID: "
                 << net_id;
-            return RPC::makeError(RpcWrongNetwork, out.str());
+            return rpc::makeError(RpcWrongNetwork, out.str());
         }
         args.ctid = {lgr_seq, txn_idx};
     }
@@ -327,7 +327,7 @@ doTxJson(RPC::JsonContext& context)
         }
     }
 
-    std::pair const res = doTxHelp(context, args);
+    std::pair const res = doTxHelp(context, args);
     return populateJsonResponse(res, args, context);
 }
 
diff --git a/src/xrpld/rpc/handlers/transaction/TxHistory.cpp b/src/xrpld/rpc/handlers/transaction/TxHistory.cpp
index a45046773c..2d5ad8cbe5 100644
--- a/src/xrpld/rpc/handlers/transaction/TxHistory.cpp
+++ b/src/xrpld/rpc/handlers/transaction/TxHistory.cpp
@@ -16,12 +16,12 @@ namespace xrpl {
 //   start: 
 // }
 json::Value
-doTxHistory(RPC::JsonContext& context)
+doTxHistory(rpc::JsonContext& context)
 {
     if (!context.app.config().useTxTables())
         return rpcError(RpcNotEnabled);
 
-    context.loadType = Resource::kFeeMediumBurdenRpc;
+    context.loadType = resource::kFeeMediumBurdenRpc;
 
     if (!context.params.isMember(jss::start))
         return rpcError(RpcInvalidParams);
@@ -40,7 +40,7 @@ doTxHistory(RPC::JsonContext& context)
     for (auto const& t : trans)
     {
         json::Value txJson = t->getJson(JsonOptions::Values::None);
-        RPC::insertDeliverMax(txJson, t->getSTransaction()->getTxnType(), context.apiVersion);
+        rpc::insertDeliverMax(txJson, t->getSTransaction()->getTxnType(), context.apiVersion);
         txs.append(txJson);
     }
 
diff --git a/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp b/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp
index edc4eff057..8956603012 100644
--- a/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp
+++ b/src/xrpld/rpc/handlers/transaction/TxReduceRelay.cpp
@@ -7,7 +7,7 @@
 namespace xrpl {
 
 json::Value
-doTxReduceRelay(RPC::JsonContext& context)
+doTxReduceRelay(rpc::JsonContext& context)
 {
     return context.app.getOverlay().txMetrics();
 }
diff --git a/src/xrpld/rpc/handlers/utility/Ping.cpp b/src/xrpld/rpc/handlers/utility/Ping.cpp
index 0d34b9e0fc..68fb06456e 100644
--- a/src/xrpld/rpc/handlers/utility/Ping.cpp
+++ b/src/xrpld/rpc/handlers/utility/Ping.cpp
@@ -6,12 +6,12 @@
 
 namespace xrpl {
 
-namespace RPC {
+namespace rpc {
 struct JsonContext;
-}  // namespace RPC
+}  // namespace rpc
 
 json::Value
-doPing(RPC::JsonContext& context)
+doPing(rpc::JsonContext& context)
 {
     json::Value ret(json::ValueType::Object);
     switch (context.role)
diff --git a/src/xrpld/rpc/handlers/utility/Random.cpp b/src/xrpld/rpc/handlers/utility/Random.cpp
index 56df442cf1..d3428ee6e9 100644
--- a/src/xrpld/rpc/handlers/utility/Random.cpp
+++ b/src/xrpld/rpc/handlers/utility/Random.cpp
@@ -10,16 +10,16 @@
 
 namespace xrpl {
 
-namespace RPC {
+namespace rpc {
 struct JsonContext;
-}  // namespace RPC
+}  // namespace rpc
 
 // Result:
 // {
 //   random: 
 // }
 json::Value
-doRandom(RPC::JsonContext& context)
+doRandom(rpc::JsonContext& context)
 {
     // TODO(tom): the try/catch is almost certainly redundant, we catch at the
     // top level too.
diff --git a/src/xrpld/shamap/NodeFamily.h b/src/xrpld/shamap/NodeFamily.h
index d532f13ecc..1307d76886 100644
--- a/src/xrpld/shamap/NodeFamily.h
+++ b/src/xrpld/shamap/NodeFamily.h
@@ -33,13 +33,13 @@ public:
 
     NodeFamily(Application& app, CollectorManager& cm);
 
-    NodeStore::Database&
+    node_store::Database&
     db() override
     {
         return db_;
     }
 
-    [[nodiscard]] NodeStore::Database const&
+    [[nodiscard]] node_store::Database const&
     db() const override
     {
         return db_;
@@ -80,7 +80,7 @@ public:
 
 private:
     Application& app_;
-    NodeStore::Database& db_;
+    node_store::Database& db_;
     beast::Journal const j_;
 
     std::shared_ptr fbCache_;
diff --git a/tests/conan/src/example.cpp b/tests/conan/src/example.cpp
index acfb253a7d..4720af4384 100644
--- a/tests/conan/src/example.cpp
+++ b/tests/conan/src/example.cpp
@@ -5,6 +5,6 @@
 int
 main(int argc, char const** argv)
 {
-    std::printf("%s\n", xrpl::BuildInfo::getVersionString().c_str());
+    std::printf("%s\n", xrpl::build_info::getVersionString().c_str());
     return 0;
 }