diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 09bb9c5e96..eba8e2801e 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -377,6 +377,7 @@ words: - venv - vfalco - vinnie + - vkeylet - wasmi - wextra - wptr diff --git a/.envrc b/.envrc index ec38b75f5c..a3f6be96ea 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,8 @@ watch_file nix/*.nix +# Pinned Rust toolchain, read by nix/packages.nix via fromRustupToolchainFile. +watch_file rust-toolchain.toml + # The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any # change in there has to invalidate direnv's cached environment. watch_dir conan 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/release-info/action.yml b/.github/actions/release-info/action.yml index 7f1061df93..e03170b2c8 100644 --- a/.github/actions/release-info/action.yml +++ b/.github/actions/release-info/action.yml @@ -7,10 +7,10 @@ outputs: value: ${{ steps.version.outputs.version }} channel: description: "The release channel this build belongs to." - value: ${{ steps.channel.outputs.channel }} + value: ${{ steps.release_info.outputs.channel }} pkg_release: - description: "The package release number: 1 for a tag, the run number otherwise." - value: ${{ steps.pkg_release.outputs.pkg_release }} + description: "The package release number: 1 for a tag, .git otherwise." + value: ${{ steps.release_info.outputs.pkg_release }} runs: using: composite @@ -39,52 +39,6 @@ runs: echo "version=${version}" | tee -a "${GITHUB_OUTPUT}" - # Only a tag says how mature a build is: a push is a develop build whatever - # its version, and a non-public codebase keeps its packages to itself. - - name: Determine release channel - id: channel - shell: bash - env: - IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} - REF_NAME: ${{ github.ref_name }} - VISIBILITY: ${{ github.event.repository.visibility }} - run: | - pre_release="" - if [[ "${REF_NAME}" == *-* ]]; then - pre_release="${REF_NAME#*-}" - fi - - if [[ "${VISIBILITY}" != "public" ]]; then - channel=private - elif [[ "${IS_TAG}" != "true" ]]; then - channel=develop - elif [[ -z "${pre_release}" ]]; then - channel=stable - elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then - channel=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}" + - name: Determine release channel and package release + id: release_info + uses: XRPLF/actions/release-info@7cc0e4a8d9d0b838f92c48d312856b190341bbba diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da37f79007..7361a3db63 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,7 @@ updates: directories: - / - .github/actions/build-deps/ + - .github/actions/cargo-cache/ - .github/actions/release-info/ - .github/actions/set-compiler-env/ - .github/actions/setup-conan/ diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7fef6643ff..65671dbd11 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -23,6 +23,19 @@ _SANITIZER_SUFFIX: dict[str, str] = { } +def config_name( + distro: str, + compiler: str, + build_type: str, + arch: str, + suffix: str = "", + sanitizer: str = "", +) -> str: + """Name a config. Its artifacts are named after it, so packaging reuses this.""" + parts = [s for s in [suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s] + return "-".join([f"{distro}-{compiler}-{build_type.lower()}-{arch}", *parts]) + + def get_cmake_args(build_type: str, extra_args: str) -> str: """Get the full list of CMake arguments for a config.""" args = _BASE_CMAKE_ARGS.copy() @@ -37,17 +50,27 @@ def get_cmake_args(build_type: str, extra_args: str) -> str: # Every config must declare 'minimal'. Minimal configs form the reduced matrix -# built for pull requests by default; the full matrix adds the rest. Packaging -# configs declare it too, but packaging is gated in the workflow, not by it. +# built for pull requests by default; the full matrix adds the rest. # -# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that -# the flag applies to every entry a config expands into, so only set it on -# configs that expand to a single combination. +# Configs may also opt into 'benchmark' to smoke-run the benchmarks, or carry a +# 'package' map to be packaged as well. Note that either applies to every entry +# a config expands into, so only set them on configs that expand to a single +# combination. + + +@dataclasses.dataclass +class PackageConfig: + """The 'package' map of a config whose binaries are also packaged.""" + + type: str # "deb" or "rpm"; has to match what the image provides + # The packaging container image: a vanilla distro image, not the nix image + # the config itself builds in. + image: str @dataclasses.dataclass class LinuxConfig: - """One entry in linux.json's 'configs' or 'package_configs' arrays.""" + """One entry in a linux.json 'configs' array.""" compiler: list[str] build_type: list[str] @@ -57,7 +80,11 @@ class LinuxConfig: sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" - image: str = "" # only used by package_configs entries + package: PackageConfig | None = None # set to also package this config + + def __post_init__(self) -> None: + if isinstance(self.package, dict): + self.package = PackageConfig(**self.package) @dataclasses.dataclass @@ -66,22 +93,16 @@ class LinuxFile: image_tag: str configs: dict[str, list[LinuxConfig]] # distro → configs - package_configs: dict[str, list[LinuxConfig]] # distro → packaging configs @classmethod def load(cls, path: Path) -> "LinuxFile": data = json.loads(path.read_text()) - - def parse(section: dict) -> dict[str, list[LinuxConfig]]: - return { - distro: [LinuxConfig(**c) for c in cfgs] - for distro, cfgs in section.items() - } - return cls( image_tag=data["image_tag"], - configs=parse(data["configs"]), - package_configs=parse(data.get("package_configs", {})), + configs={ + distro: [LinuxConfig(**c) for c in cfgs] + for distro, cfgs in data["configs"].items() + }, ) @@ -156,7 +177,7 @@ class PackagingEntry: xrpld_artifact_name: str validator_keys_artifact_name: str image: str - distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps + package_type: str # "deb" or "rpm"; drives the format-specific steps # --------------------------------------------------------------------------- @@ -197,13 +218,9 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: effective_sanitizers, effective_archs.items(), ): - name = f"{distro}-{compiler}-{build_type.lower()}-{arch}" - suffix_parts = [ - s for s in [cfg.suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s - ] - if suffix_parts: - name += "-" + "-".join(suffix_parts) - + name = config_name( + distro, compiler, build_type, arch, cfg.suffix, sanitizer + ) entries.append( MatrixEntry( config_name=name, @@ -223,27 +240,33 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: - """Generate the packaging matrix from a LinuxFile's package_configs section. + """Generate the packaging matrix from the configs that carry a 'package' map. - Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of - the nix-based build images, because deb/rpm tooling (debhelper, rpm-build) - is taken from the distro's archive rather than from nixpkgs. Each config - entry carries its own 'image'. + Packaging consumes the binaries that config's build job uploaded, so the + artifact names come from the same config name, and a packaged config is one + that passes -Dvalidator_keys=ON. - The artifact names must match what the build job uploads: one artifact per - binary, each named after the build config. + Packaging itself runs in vanilla distro images (debian:trixie, almalinux:10) + instead of the nix-based build images, because deb/rpm tooling (debhelper, + rpm-build) is taken from the distro's archive rather than from nixpkgs. """ entries = [] - for distro, configs in linux.package_configs.items(): + for distro, configs in linux.configs.items(): for cfg in configs: - for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): - config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64" + if cfg.package is None: + continue + for compiler, build_type, arch in itertools.product( + cfg.compiler, cfg.build_type, cfg.arch + ): + # The packaging workflow hardcodes an amd64 runner. + assert arch == "amd64", f"cannot package {distro} on {arch}" + name = config_name(distro, compiler, build_type, arch, cfg.suffix) entries.append( PackagingEntry( - xrpld_artifact_name=f"xrpld-{config_name}", - validator_keys_artifact_name=f"validator-keys-{config_name}", - image=cfg.image, - distro=distro, + xrpld_artifact_name=f"xrpld-{name}", + validator_keys_artifact_name=f"validator-keys-{name}", + image=cfg.package.image, + package_type=cfg.package.type, ) ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index e739a42d5a..d8cdbdfa52 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-a0074f8", + "image_tag": "sha-473fe44", "configs": { "ubuntu": [ { @@ -71,7 +71,11 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "extra_cmake_args": "-Dvalidator_keys=ON" + "extra_cmake_args": "-Dvalidator_keys=ON", + "package": { + "type": "deb", + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-b6a8995" + } } ], @@ -81,28 +85,11 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "extra_cmake_args": "-Dvalidator_keys=ON" - } - ] - }, - "package_configs": { - "debian": [ - { - "compiler": ["gcc"], - "build_type": ["Release"], - "arch": ["amd64"], - "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" - } - ], - - "rhel": [ - { - "compiler": ["gcc"], - "build_type": ["Release"], - "arch": ["amd64"], - "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" + "extra_cmake_args": "-Dvalidator_keys=ON", + "package": { + "type": "rpm", + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-b6a8995" + } } ] } diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml index fe2f43fdcc..a528786dd4 100644 --- a/.github/workflows/build-nix-images.yml +++ b/.github/workflows/build-nix-images.yml @@ -12,6 +12,7 @@ on: - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" + - "!nix/check-tools/*.txt" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" @@ -24,6 +25,7 @@ on: - "nix/**" - "!nix/docker/README.md" - "!nix/devshell.nix" + - "!nix/check-tools/*.txt" - "bin/check-tools.sh" - "bin/default-loader-path.sh" - "bin/install-sanitizer-libs.sh" @@ -58,7 +60,7 @@ jobs: base_image: debian:bookworm - name: rhel base_image: registry.access.redhat.com/ubi9/ubi:latest - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/nix-${{ matrix.distro.name }} dockerfile: nix/docker/Dockerfile diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index fbabc25ac3..e099decc12 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -6,13 +6,13 @@ on: - develop paths: - ".github/workflows/build-packaging-images.yml" - - "package/Dockerfile" - - "package/install-packaging-tools.sh" + - "bin/install-packaging-tools.sh" + - "package/docker/**" pull_request: paths: - ".github/workflows/build-packaging-images.yml" - - "package/Dockerfile" - - "package/install-packaging-tools.sh" + - "bin/install-packaging-tools.sh" + - "package/docker/**" workflow_dispatch: concurrency: @@ -33,15 +33,17 @@ jobs: strategy: fail-fast: false matrix: + # Newest of each distro: these images only wrap pre-built binaries, so + # they set no floor for consumers. build_pkg.py pins the RPM dist tag. distro: - name: debian - base_image: debian:bookworm - # AlmaLinux rather than UBI9, which does not ship rpm-sign. + base_image: debian:trixie + # AlmaLinux rather than UBI, which does not ship rpm-sign. - name: rhel - base_image: almalinux:9 - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + base_image: almalinux:10 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} - dockerfile: package/Dockerfile + dockerfile: package/docker/Dockerfile base_image: ${{ matrix.distro.base_image }} push: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/build-pre-commit-image.yml b/.github/workflows/build-pre-commit-image.yml index d0eba6b495..71f083b686 100644 --- a/.github/workflows/build-pre-commit-image.yml +++ b/.github/workflows/build-pre-commit-image.yml @@ -30,7 +30,7 @@ jobs: permissions: contents: read packages: write - uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32 + uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/pre-commit dockerfile: bin/pre-commit/Dockerfile diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index d167e52e61..6ddc6cdac9 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -34,7 +34,7 @@ permissions: jobs: audit: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 permissions: contents: read # Needed to open an issue on scheduled failures. diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index c7a00e8b49..1169140481 100644 --- a/.github/workflows/check-tools.yml +++ b/.github/workflows/check-tools.yml @@ -79,7 +79,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index ac5fe46722..1f6b69f087 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -14,7 +14,7 @@ on: jobs: # Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks. run-hooks: - uses: XRPLF/actions/.github/workflows/pre-commit.yml@3ba08d6ddf114092891d48491fc2e26c3ba15552 + uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131 with: runs_on: ubuntu-latest - container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }' + container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-473fe44" }' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 3b863f2b33..8c5d10929c 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-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 89bfc7463b..2846c3fb85 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -129,7 +129,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: ${{ inputs.ccache_enabled }} @@ -163,11 +163,10 @@ jobs: compiler: ${{ inputs.compiler }} - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion key: ${{ inputs.config_name }} - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} # two workspaces here because build artifacts are located in 2 places: # - crates/target when cargo is called directly # - build/cargo when cargo is called by cmake diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 8dd1af9d99..045d384181 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -27,14 +27,14 @@ 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-a0074f8" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-473fe44" permissions: contents: read issues: write @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false @@ -60,10 +60,9 @@ jobs: compiler: ${{ env.COMPILER }} - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo - name: Setup Conan diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index cfae706ee1..2a5e6a8c04 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,9 +1,9 @@ # Build Linux packages from the pre-built xrpld and validator-keys artifacts: # -# - one job per distro, taken from "package_configs" in linux.json -# - each job runs in that distro's container, which is what decides DEB or RPM -# - with 'publish: true' a job also uploads what it built -# (see package/publish_pkg.sh) +# - one job per config that carries a "package" map in linux.json +# - that map names the container image and the format it builds there +# - every job ends with the image's publish_pkg.py, uploading what it built +# with 'publish: true' and doing a --dry-run otherwise # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -76,6 +76,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + - name: Download pre-built xrpld binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -97,17 +102,23 @@ jobs: - name: Build package env: + PACKAGE_TYPE: ${{ matrix.package_type }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} - PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} - run: ./package/build_pkg.sh + CHANNEL: ${{ steps.release_info.outputs.channel }} + run: | + ./package/build_pkg.py \ + --package-type "${PACKAGE_TYPE}" \ + --build-dir "${BUILD_DIR}" \ + --pkg-release "${PKG_RELEASE}" \ + --channel "${CHANNEL}" # Before the upload, so the artifact and the published package are the # same bytes. DEBs are not signed, so the key is never set on that job. - name: Sign RPM - if: ${{ inputs.publish && matrix.distro == 'rhel' }} + if: ${{ inputs.publish && matrix.package_type == 'rpm' }} env: PKG_SIGNING_KEY: ${{ secrets.signing_key }} - run: ./package/sign_rpm.sh "${BUILD_DIR}" + run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -120,10 +131,15 @@ jobs: if-no-files-found: error - name: Publish package - if: ${{ inputs.publish }} env: CHANNEL: ${{ steps.release_info.outputs.channel }} + DRY_RUN_OPTION: ${{ !inputs.publish && '--dry-run' || '' }} NEXUS_URL: ${{ inputs.nexus_url }} - NEXUS_USERNAME: ${{ secrets.remote_username }} - NEXUS_PASSWORD: ${{ secrets.remote_password }} - run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" + NEXUS_USERNAME: ${{ inputs.publish && secrets.remote_username || '' }} + NEXUS_PASSWORD: ${{ inputs.publish && secrets.remote_password || '' }} + run: | + publish_pkg.py \ + --channel "${CHANNEL}" \ + --package-dir "${BUILD_DIR}" \ + --nexus-url "${NEXUS_URL}" \ + ${DRY_RUN_OPTION} diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml index e9d281c692..a0199f0129 100644 --- a/.github/workflows/reusable-rust.yml +++ b/.github/workflows/reusable-rust.yml @@ -27,30 +27,26 @@ permissions: jobs: clippy: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Run clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings coverage: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Generate coverage report run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info @@ -70,15 +66,13 @@ jobs: doc: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use cargo artifacts cache - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: crates + uses: ./.github/actions/cargo-cache - name: Build documentation env: diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 680d95fb97..6fa289665a 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,7 +40,7 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} @@ -49,6 +49,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Prepare runner + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 + with: + enable_ccache: false + - name: Determine release info id: release_info uses: ./.github/actions/release-info diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 65a3f9c5b6..184f13cc5e 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner - uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f + uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13 with: enable_ccache: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5e69759fd..f223ab1684 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,11 +82,27 @@ repos: - id: prettier args: [--end-of-line=auto] + # Scoped to package/: the rest of the repo's Python has pre-existing findings, + # so widening these is its own change. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 7c55798a78262d14b2074abf623d8a992ebb70d4 # frozen: v0.16.2 + hooks: + - id: ruff-check + args: [--fix] + files: ^package/.*\.py$ + - repo: https://github.com/psf/black-pre-commit-mirror rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1 hooks: - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0 + hooks: + - id: mypy + args: [--strict] + files: ^package/.*\.py$ + - repo: https://github.com/scop/pre-commit-shfmt rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1 hooks: diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index c853cfb07c..d521f9c024 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,9 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `vault_info`: Errors now identify what the request got wrong instead of reporting every failure as the unregistered token `malformedRequest`, and the `error`, `error_code` and `error_message` fields now agree with each other. An invalid `vault_id` or `seq` returns `invalidParams`, an invalid `owner` returns `actMalformed`, and a request that mixes `vault_id` with `owner`/`seq` or supplies neither returns `invalidParams` with a message naming the accepted combinations. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: A well-formed all-zero `vault_id` now returns `entryNotFound` instead of being rejected as malformed, and `entryNotFound` responses now include `error_code` and `error_message`. Clients that request `ripplerpc` 3.0 or above therefore receive HTTP 400 with that error rather than HTTP 200. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: `vault_id` and `owner` must now be strings, matching how `ledger_entry` reads the same fields. An object or an array in either field previously produced an internal error, and a number was silently converted to its decimal text; `vault_id` now returns `invalidParams` and `owner` returns `actMalformed`. [#8015](https://github.com/XRPLF/rippled/pull/8015) - `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) - `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) diff --git a/package/install-packaging-tools.sh b/bin/install-packaging-tools.sh similarity index 83% rename from package/install-packaging-tools.sh rename to bin/install-packaging-tools.sh index 2326d8f2ac..36557364ae 100755 --- a/package/install-packaging-tools.sh +++ b/bin/install-packaging-tools.sh @@ -28,31 +28,31 @@ esac # - debhelper and dpkg-dev build the DEB # - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config # supplying the systemd and find-debuginfo macros the spec uses -# - rpm-sign signs the built RPM -# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from; -# without one the timestamp falls back to the wall clock -# - curl uploads the finished packages in publish_pkg.sh -# - ca-certificates lets curl and git verify TLS +# - rpm-sign and gnupg2 sign the built RPM +# - python3 runs the packaging scripts +# - git gives build_pkg.py the commit timestamp it stamps files with +# - ca-certificates lets git and the packaging scripts verify TLS function install() { case "${ID}" in debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ ca-certificates \ - curl \ debhelper \ debhelper-compat \ dpkg-dev \ - git + git \ + python3 ;; rhel | centos | rocky | almalinux) dnf install -y --setopt=install_weak_deps=False \ - curl-minimal \ git \ + gnupg2 \ + python3 \ + redhat-rpm-config \ rpm-build \ rpm-sign \ - redhat-rpm-config \ systemd-rpm-macros ;; esac diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 0e552e20b7..76e2469810 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -1094,8 +1094,8 @@ # Default is 100. # # back_off_milliseconds -# Number of milliseconds to wait between -# online_delete batches to allow other functions +# Number of milliseconds to wait between online_delete +# SQL deletion batches to allow other functions # to catch up. # Default is 100. # @@ -1109,10 +1109,22 @@ # The online delete process checks periodically # that xrpld is still in sync with the network, # and that the validated ledger is less than -# 'age_threshold_seconds' old. If not, then continue +# 'age_threshold_seconds' old, and that all +# recent ledgers are available. If not, then continue # sleeping for this number of seconds and # checking until healthy. -# Default is 5. +# Default is 2. +# +# max_waiting_ledgers +# The maximum number of ledgers that may be validated +# while online deletion is waiting for the node to get +# fully synced with the rest of the network. If more than +# this number of ledgers are validated while waiting, then +# online deletion gives up on the current ledger and tries +# again later. Note this only affects situations that cause +# rotation to wait, such as going out of sync, or missing +# ledgers. Forward progress is not penalized. Minimum is 64. +# Default is the online_delete value. # # Notes: # The 'node_db' entry configures the primary, persistent storage. diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 2b46739d97..29c1dfe478 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -120,7 +120,10 @@ if(MSVC) _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS $<$,$>:_CRTDBG_MAP_ALLOC> ) - target_link_libraries(common INTERFACE -errorreport:none -machine:X64) + target_link_libraries( + common + INTERFACE -errorreport:none -machine:X64 -ignore:4099 + ) else() target_compile_options( common diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index bee7b15791..e2f7029ad2 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -1,7 +1,7 @@ #[===================================================================[ Linux packaging support: 'package' target. - The packaging script (package/build_pkg.sh) installs to FHS-standard + The packaging script (package/build_pkg.py) installs to FHS-standard paths (/usr/bin, /etc/xrpld, etc.) regardless of CMAKE_INSTALL_PREFIX, so no prefix guard is needed here. #]===================================================================] @@ -38,19 +38,20 @@ if(NOT TARGET validator-keys) return() endif() -set(package_env - SRC_DIR=${CMAKE_SOURCE_DIR} - BUILD_DIR=${CMAKE_BINARY_DIR} - PKG_RELEASE=${pkg_release} -) +if(DPKG_BUILDPACKAGE_EXECUTABLE) + set(pkg_type deb) +else() + set(pkg_type rpm) +endif() add_custom_target( package COMMAND - ${CMAKE_COMMAND} -E env ${package_env} - ${CMAKE_SOURCE_DIR}/package/build_pkg.sh + ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type=${pkg_type} + --build-dir=${CMAKE_BINARY_DIR} --pkg-release=${pkg_release} + --channel=UNRELEASED WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys - COMMENT "Building Linux package (deb/rpm inferred from host tooling)" + COMMENT "Building Linux ${pkg_type} package" VERBATIM ) diff --git a/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/crates/Cargo.lock b/crates/Cargo.lock index bc38558c16..70247f8e19 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -57,9 +57,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974" dependencies = [ "cc", "cxx-build", @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036" dependencies = [ "cc", "codespan-reporting", @@ -87,9 +87,9 @@ dependencies = [ [[package]] name = "cxxbridge-cmd" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e" dependencies = [ "clap", "codespan-reporting", @@ -101,15 +101,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" +checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef" [[package]] name = "cxxbridge-macro" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc" dependencies = [ "indexmap", "proc-macro2", diff --git a/docs/install.md b/docs/install.md index 9699150fdb..4c52b587b6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,9 +13,9 @@ To build from source instead, see [BUILD.md](../BUILD.md). Packages are published to four channels: -- `stable` - the latest production release -- `unstable` - release candidates -- `experimental` - beta builds +- `stable` - production releases +- `rc` - release candidates +- `beta` - beta builds - `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop) See [Publishing packages](../package/README.md#publishing-packages) for how channels are produced. @@ -65,7 +65,7 @@ wherever it appears in the repository configuration. 4. Add the repository, using the channel you picked in [Release channels](#release-channels): ```bash - echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable focal main" | \ + echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable any main" | \ sudo tee /etc/apt/sources.list.d/xrplf.list ``` @@ -92,19 +92,19 @@ wherever it appears in the repository configuration. 2. Add the repository, using the channel you picked in [Release channels](#release-channels): ```bash - cat << REPOFILE | sudo tee /etc/yum.repos.d/xrplf.repo + cat << 'REPOFILE' | sudo tee /etc/yum.repos.d/xrplf.repo [xrplf-stable] name=XRP Ledger Packages enabled=1 - baseurl=https://packages.xrplf.org/repository/rpm-stable/ + baseurl=https://packages.xrplf.org/repository/rpm-stable/$basearch/ gpgcheck=1 - repo_gpgcheck=0 + repo_gpgcheck=1 gpgkey=https://packages.xrplf.org/xrplf.asc REPOFILE ``` `gpgcheck=1` verifies each package against the key above. - `repo_gpgcheck` is off because the repository metadata is generated by the server and is not signed. + `repo_gpgcheck=1` verifies the repository metadata, which the server signs with the same key. 3. Install the `xrpld` package: diff --git a/include/xrpl/beast/net/IPAddress.h b/include/xrpl/beast/net/IPAddress.h index 7422778ea2..e636a69ce7 100644 --- a/include/xrpl/beast/net/IPAddress.h +++ b/include/xrpl/beast/net/IPAddress.h @@ -103,7 +103,7 @@ namespace boost { template <> struct hash<::beast::ip::Address> { - explicit hash() = default; + hash() = default; std::size_t operator()(::beast::ip::Address const& addr) const diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 85d9e3f147..c78643d6c3 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -125,6 +125,7 @@ struct Keys static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; + static constexpr auto kMaxWaitingLedgers = "max_waiting_ledgers"; static constexpr auto kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index f7fd5b5a8c..bb0817673c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace xrpl { @@ -198,7 +199,10 @@ dirLink( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -209,7 +213,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. @@ -222,7 +227,10 @@ canWithdraw( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -232,20 +240,25 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. * * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * destination account (sfDestination). Credentials, if any, are taken from the + * transaction's sfCredentialIDs field. * * - Checks that the receiver account exists. * - If the receiver requires a destination tag, check that one exists, even * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials in sfCredentialIDs to already exist in the + * ledger, and returns an internal error otherwise. Validate them + * beforehand with credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ diff --git a/include/xrpl/ledger/helpers/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 350fc6ca85..452d402d14 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -15,7 +15,6 @@ #include #include #include -#include #include namespace xrpl { @@ -353,14 +352,14 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); * * The list is constructed during initialization and is const after that. * Pseudo-account designator fields MUST be maintained by including the - * SField::sMD_PseudoAccount flag in the SField definition. + * SField::kSmdPseudoAccount flag in the SField definition. */ [[nodiscard]] std::vector const& getPseudoAccountFields(); /** - * Returns true if and only if sleAcct is a pseudo-account or specific - * pseudo-accounts in pseudoFieldFilter. + * Returns true if and only if sleAcct is a pseudo-account of any kind + * (i.e. carries at least one field flagged with SField::kSmdPseudoAccount). * * Returns false if sleAcct is: * - NOT a pseudo-account OR @@ -368,18 +367,15 @@ getPseudoAccountFields(); * - null pointer */ [[nodiscard]] bool -isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter = {}); +isPseudoAccount(SLE::const_pointer sleAcct); /** * Convenience overload that reads the account from the view. */ [[nodiscard]] inline bool -isPseudoAccount( - ReadView const& view, - AccountID const& accountId, - std::set const& pseudoFieldFilter = {}) +isPseudoAccount(ReadView const& view, AccountID const& accountId) { - return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter); + return isPseudoAccount(view.read(keylet::account(accountId))); } /** diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 8b1c819bf4..6d235b4316 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,32 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed); [[nodiscard]] TER deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); +/** + * @brief Remove credentials pinned to a pseudo-account's owner directory. + * + * Cleans up credentials that were linked to a pseudo-account (Vault, LoanBroker, + * AMM), which such an account can neither accept nor delete. Only credentials + * are removed; every other object is left in place. The walk visits at most + * @p maxNodesToDelete directory entries and charges the ones it leaves alone + * against that budget too, so a directory holding other objects yields fewer + * than @p maxNodesToDelete deletions. On reaching the bound the result is + * `tecINCOMPLETE` and the caller must propagate it so a later transaction + * resumes. + * + * @param view Mutable ledger view. + * @param pseudoAcct The pseudo-account whose directory is cleaned. + * @param maxNodesToDelete Upper bound on directory entries processed in one call. + * @param j Journal for diagnostics. + * @return tesSUCCESS once no credentials remain, tecINCOMPLETE if the bound was + * reached, or a deletion error. + */ +[[nodiscard]] TER +deletePseudoAccountCredentials( + ApplyView& view, + AccountID const& pseudoAcct, + std::uint16_t maxNodesToDelete, + beast::Journal j); + // Amendment and parameters checks for sfCredentialIDs field NotTEC checkFields(STTx const& tx, Rules const& rules, beast::Journal j); diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 4aa89ea672..f3fc82eacb 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -324,6 +324,12 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +// Returns true if the loan's next payment is late per protocol rules. The +// boundary is amendment-gated: with fixCleanup3_4_0 the due date must be +// strictly in the past, otherwise the exact due-date instant counts as late. +[[nodiscard]] bool +isPaymentLate(ReadView const& view, SLE::const_ref loanSle); + // Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single // accounting touch point (origination, payment, impair/unimpair/default). struct AccountingDeltas diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 501101136a..5153b43cb2 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -294,6 +294,14 @@ accountFunds( AuthHandling authHandling, beast::Journal j); +/** + * Returns the transfer fee as Rate based on the type of token + * @param view The ledger view + * @param asset The asset being transferred + */ +[[nodiscard]] Rate +transferRate(ReadView const& view, Asset const& asset); + /** * Returns the transfer fee as Rate based on the type of token * @param view The ledger view diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index c898e9e148..b42f349b95 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -7,8 +7,10 @@ #include #include #include +#include #include +#include #include namespace xrpl { @@ -43,6 +45,32 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co [[nodiscard]] std::optional sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); +/** + * Adjusts a requested asset change (`delta`) to match the decimal scale of the + * updated total vault assets. This ensures `sfAssetsTotal`, `sfAssetsAvailable`, + * and the actual asset transfer change by the exact same representable amount. + * + * Rounding strategy: + * - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent + * paying out more than requested. + * - Credits (deposits): Floors the resulting total asset balance and returns the + * difference from the current total. This prevents crediting the vault with + * more assets than the user deposited. + * + * Key rules: + * - The returned magnitude never exceeds `|delta|`. + * - Returns `tecPRECISION_LOSS` if the change is smaller than 1 ULP of the target scale + * (prevents share operations when totals cannot change). + * - For integer assets (XRP, MPT), rounding is a no-op. + * + * @param vault The vault ledger entry. + * @param delta The requested signed change to sfAssetsTotal. + * @return The rounded, positive magnitude, or `tecPRECISION_LOSS` if the + * change is below representable precision. + */ +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta); + /** * Controls whether to truncate shares instead of rounding. */ @@ -58,33 +86,30 @@ enum class TruncateShares : bool { No = false, Yes = true }; enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; /** - * Returns the effective total of assets backing outstanding shares for the - * purposes of a withdrawal, i.e. sfAssetsTotal, discounted by sfLossUnrealized - * unless waived. This is the numerator used by both withdraw conversion - * helpers (assetsToSharesWithdraw and sharesToAssetsWithdraw) to compute the - * share/asset exchange rate. + * Returns the assets backing outstanding shares for a withdrawal: + * sfAssetsTotal minus sfLossUnrealized, or sfAssetsTotal alone when the + * unrealized loss is waived. Used by assetsToSharesWithdraw and + * sharesToAssetsWithdraw as the numerator of the share/asset exchange rate. * * @param vault The vault SLE. - * @param waive Whether to waive (i.e. not subtract) the vault's unrealized - * loss. + * @param waive Whether to skip subtracting the unrealized loss. */ [[nodiscard]] Number assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive); /** - * Returns whether debiting `amount` from `total` — the current value of a - * vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back - * to the exact same STAmount value it started at. This happens when a - * genuinely non-zero debit is dust relative to a `total` large enough to - * exceed STAmount's significant-digit precision: the shares still move, but - * the stored total doesn't change, which otherwise trips the ValidVault - * invariant after the fact instead of failing cleanly upfront. + * Returns true if debiting `amount` from `total` (the current value of a + * vault's sfAssetsTotal or sfAssetsAvailable) would canonicalize to the + * same STAmount value. This happens when `amount` is non-zero but too small + * to change the stored total at STAmount's precision. Shares would still + * move, so the ValidVault invariant would fail after apply; callers use + * this to reject the transaction upfront instead. * - * @param asset The vault's underlying asset, used to canonicalize both sides - * the same way the ledger will when the field is stored. + * @param asset The vault's underlying asset, used to canonicalize both + * sides the same way the ledger will when the field is stored. * @param total The field's current value. - * @param amount The amount to debit. A value of zero always returns false; - * that case is rejected separately and unconditionally. + * @param amount The amount to debit. Zero always returns false; that case + * is rejected separately. */ [[nodiscard]] bool debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount); @@ -238,4 +263,40 @@ getVaultPhase( std::optional subscriptionDate, std::optional redemptionDate); +/** + * Controls whether checkVaultDomain reports an expired credential as an + * error. A caller that deletes expired credentials later, in doApply, passes + * Yes and treats the subject as authorized; a caller with no such cleanup + * step must keep the error. + */ +enum class SuppressExpired : bool { No = false, Yes = true }; + +/** + * Checks that subject belongs to the permissioned domain governing a vault's + * shares. + * + * The domain is read from the share issuance rather than from the vault. Vault + * shares are issued by the vault's pseudo-account, which cannot grant an + * authorization explicitly, so domain membership is the only route to being + * authorized: a vault with no domain set has no authorized participants at + * all, and every subject fails with tecNO_AUTH. + * + * Which accounts to check, and whether to check at all, is left to the caller. + * This says nothing about vault privacy or about the roles of the accounts. + * + * @param view The ledger view. + * @param issuance The MPTokenIssuance SLE for the vault's shares. + * @param subject The account whose domain membership is checked. + * @param suppressExpired Whether an expired credential counts as authorized. + * + * @return tesSUCCESS if the subject is a domain member, otherwise the reason + * it is not. + */ +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired); + } // namespace xrpl diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index 1e11f6cd8b..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -91,6 +91,17 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAuctionSlotFeeScaleFactor; } +/** + * Minimum auction slot price: LPTokens * TradingFee / kAuctionSlotMinFeeFraction + * @param lptAMMBalance AMM LP token balance + * @param tradingFee trading fee in {0, 1000} + */ +inline Number +ammAuctionMinSlotPrice(Number const& lptAMMBalance, std::uint16_t tradingFee) +{ + return lptAMMBalance * getFee(tradingFee) / kAuctionSlotMinFeeFraction; +} + /** * Get fee multiplier (1 - tfee) * @tfee trading fee in basis points diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index a83eb41b24..e6ed3729dd 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -133,7 +133,7 @@ private: using id_hash_type = boost::base_from_member, 0>; public: - explicit hash() = default; + hash() = default; using value_type = std::size_t; using argument_type = xrpl::MPTIssue; @@ -160,7 +160,7 @@ private: mptissue_hasher mMptissueHasher_; public: - explicit hash() = default; + hash() = default; value_type operator()(argument_type const& asset) const @@ -227,7 +227,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; @@ -235,7 +235,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; diff --git a/include/xrpl/protocol/MPTAmount.h b/include/xrpl/protocol/MPTAmount.h index 462092f7dd..68a7926256 100644 --- a/include/xrpl/protocol/MPTAmount.h +++ b/include/xrpl/protocol/MPTAmount.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -174,4 +175,17 @@ mulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundU return MPTAmount(r.convert_to()); } +inline std::optional +tryMulRatio(MPTAmount const& amt, std::uint32_t num, std::uint32_t den, bool roundUp) +{ + try + { + return mulRatio(amt, num, den, roundUp); + } + catch (std::overflow_error const&) + { + return std::nullopt; + } +} + } // namespace xrpl diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index 7f473da6a2..49c1fd63dc 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -151,7 +151,7 @@ namespace std { template <> struct hash : xrpl::MPTID::hasher { - explicit hash() = default; + hash() = default; }; } // namespace std diff --git a/include/xrpl/protocol/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 345baef853..e6768efd76 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -396,6 +396,16 @@ using TxID = uint256; */ constexpr std::uint16_t kMaxDeletableAmmTrustLines = 512; +/** + * The maximum number of owner-directory entries to walk when clearing + * credentials pinned to a pseudo-account, in a single transaction. + * + * The walk stops after this many entries whether or not each one turns out to + * be a credential, so a directory that also holds other objects yields fewer + * deletions per transaction. + */ +constexpr std::uint16_t kMaxDeletablePseudoAccountCredentials = 512; + /** * The maximum length of a URI inside an Oracle */ diff --git a/include/xrpl/protocol/STLedgerEntry.h b/include/xrpl/protocol/STLedgerEntry.h index 8731488adb..7bc369ea37 100644 --- a/include/xrpl/protocol/STLedgerEntry.h +++ b/include/xrpl/protocol/STLedgerEntry.h @@ -19,7 +19,7 @@ namespace xrpl { class Rules; namespace test { -class Invariants_test; +class InvariantsMisc_test; } // namespace test class STLedgerEntry final : public STObject, public CountedObject @@ -83,8 +83,8 @@ private: void setSLEType(); - friend test::Invariants_test; // this test wants access to the private - // type_ + friend test::InvariantsMisc_test; // this test wants access to the + // private type_ STBase* copy(std::size_t n, void* buf) const override; diff --git a/include/xrpl/protocol/TxSettings.h b/include/xrpl/protocol/TxSettings.h new file mode 100644 index 0000000000..8ea249856a --- /dev/null +++ b/include/xrpl/protocol/TxSettings.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include + +namespace xrpl { + +enum class Delegation { Delegable, NotDelegable }; + +/** + * Operations a transaction is permitted to perform, as a bitfield. + * + * These are declared per-transaction in transactions.macro (via + * TxSettings::privileges) and enforced in InvariantCheck.cpp. + */ +enum class Privilege : std::uint16_t { + NoPriv = 0x0000, // The transaction can not do any of the enumerated operations + CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. + CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, + // which implies createAcct + MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object + MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT + // object, but does not have to + OverrideFreeze = 0x0010, // The transaction can override some freeze rules + ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT + CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance + DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance + MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT + // object (except by issuer) + MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT + // object (except by issuer) + MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. + MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault + MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. +}; + +// The inner static_cast is not redundant: the underlying type is narrower than +// `int`, so the operands integer-promote and the result has to be narrowed back. +// safeCast rejects that narrowing, but every input bit is a Privilege bit by +// construction, so the result is always representable. +constexpr Privilege +operator|(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) | safeCast(rhs))); +} + +constexpr Privilege +operator&(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) & safeCast(rhs))); +} + +/** + * Per-transaction metadata declared in transactions.macro. + * + * Every member has a default, so a transaction only needs to name the settings + * that differ from the common case. See the documentation at the top of + * transactions.macro for the authoring syntax. + * + * This is deliberately not a constexpr-friendly type: amendment identifiers are + * runtime-initialized `extern uint256 const` globals (see Feature.h), so a + * TxSettings can only be built at runtime. + */ +struct TxSettings +{ + /** + * Whether an account may delegate this transaction to another account. + */ + Delegation delegable{Delegation::NotDelegable}; + + /** + * The amendment gating this transaction, or uint256{} if always available. + */ + // The `{}` looks redundant, because BaseUInt's default constructor already + // zeroes the value. It is not: without a default member initializer here, + // every partial designated initializer in transactions.macro trips the + // missing-designated-field-initializers warning, which the build treats as + // an error. + // NOLINTNEXTLINE(readability-redundant-member-init) + uint256 amendment{}; + + /** + * Operations this transaction is permitted to perform. + */ + Privilege privileges{Privilege::NoPriv}; +}; + +} // namespace xrpl diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index de02fed7d8..ae696a4ea4 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -18,7 +18,7 @@ XRPL_FIX (Cleanup3_4_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) +XRPL_FEATURE(LendingProtocolV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(ConfidentialTransfer, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index f8676d3b63..dbf9b66ac7 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,11 +64,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, @@ -61,11 +77,7 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -79,9 +91,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +109,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +119,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +131,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +144,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +154,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +167,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +177,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +190,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +200,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +213,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +226,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +237,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +245,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +256,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +267,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +283,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +299,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +309,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +321,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +329,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +339,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +349,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +367,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +383,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +399,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +418,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +430,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +444,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +459,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +468,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +478,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +489,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +499,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +520,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +544,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +553,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +565,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +577,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +585,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +600,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +610,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,9 +622,11 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, @@ -713,9 +641,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,9 +655,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -744,9 +672,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +687,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +700,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +711,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +723,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +735,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +746,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +756,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +769,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -872,9 +792,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -887,9 +808,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -900,9 +823,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -913,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. */ @@ -928,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}, @@ -942,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}, @@ -957,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}, @@ -974,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}, })) @@ -985,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}, })) @@ -997,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 @@ -1012,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}, })) @@ -1024,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}, @@ -1051,9 +990,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1062,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}, })) @@ -1076,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}, })) @@ -1088,9 +1032,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::NotDelegable, - featureConfidentialTransfer, - NoPriv, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1107,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}, })) @@ -1119,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}, @@ -1137,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}, @@ -1158,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}, @@ -1173,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}, @@ -1186,9 +1122,7 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1205,9 +1139,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1217,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 @@ -1238,9 +1168,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/transactions/AMMBid.h b/include/xrpl/protocol_autogen/transactions/AMMBid.h index 30a2b6f2ab..94d0672699 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMBid.h +++ b/include/xrpl/protocol_autogen/transactions/AMMBid.h @@ -21,7 +21,7 @@ class AMMBidBuilder; * Type: ttAMM_BID (39) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMBidBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMClawback.h b/include/xrpl/protocol_autogen/transactions/AMMClawback.h index 38aba892c4..c837b5cee6 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMClawback.h +++ b/include/xrpl/protocol_autogen/transactions/AMMClawback.h @@ -21,7 +21,7 @@ class AMMClawbackBuilder; * Type: ttAMM_CLAWBACK (31) * Delegable: Delegation::Delegable * Amendment: featureAMMClawback - * Privileges: MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::OverrideFreeze | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMCreate.h b/include/xrpl/protocol_autogen/transactions/AMMCreate.h index c6ccd4e860..e2e50f87ff 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMCreate.h +++ b/include/xrpl/protocol_autogen/transactions/AMMCreate.h @@ -21,7 +21,7 @@ class AMMCreateBuilder; * Type: ttAMM_CREATE (35) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: CreatePseudoAcct | MayCreateMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDelete.h b/include/xrpl/protocol_autogen/transactions/AMMDelete.h index 05899a46c8..86e91bf52b 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDelete.h @@ -21,7 +21,7 @@ class AMMDeleteBuilder; * Type: ttAMM_DELETE (40) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MustDeleteAcct | MayDeleteMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayDeleteMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h index 5416547dab..fed1bd3195 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h @@ -21,7 +21,7 @@ class AMMDepositBuilder; * Type: ttAMM_DEPOSIT (36) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMVote.h b/include/xrpl/protocol_autogen/transactions/AMMVote.h index 7dce3c252f..3fca42a232 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMVote.h +++ b/include/xrpl/protocol_autogen/transactions/AMMVote.h @@ -21,7 +21,7 @@ class AMMVoteBuilder; * Type: ttAMM_VOTE (38) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMVoteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h index 81258f22d6..e177011801 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h @@ -21,7 +21,7 @@ class AMMWithdrawBuilder; * Type: ttAMM_WITHDRAW (37) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MayDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountDelete.h b/include/xrpl/protocol_autogen/transactions/AccountDelete.h index cf6e97bb63..87ecab0c7b 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AccountDelete.h @@ -21,7 +21,7 @@ class AccountDeleteBuilder; * Type: ttACCOUNT_DELETE (21) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: MustDeleteAcct + * Privileges: Privilege::MustDeleteAcct * * Immutable wrapper around STTx providing type-safe field access. * Use AccountDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountSet.h b/include/xrpl/protocol_autogen/transactions/AccountSet.h index 55c449e78e..9f85603e22 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountSet.h +++ b/include/xrpl/protocol_autogen/transactions/AccountSet.h @@ -21,7 +21,7 @@ class AccountSetBuilder; * Type: ttACCOUNT_SET (3) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AccountSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Batch.h b/include/xrpl/protocol_autogen/transactions/Batch.h index 1a59d2b4c0..f92aaa5348 100644 --- a/include/xrpl/protocol_autogen/transactions/Batch.h +++ b/include/xrpl/protocol_autogen/transactions/Batch.h @@ -21,7 +21,7 @@ class BatchBuilder; * Type: ttBATCH (71) * Delegable: Delegation::NotDelegable * Amendment: featureBatchV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use BatchBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCancel.h b/include/xrpl/protocol_autogen/transactions/CheckCancel.h index b75b717e3f..cf300d3b9b 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCancel.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCancel.h @@ -21,7 +21,7 @@ class CheckCancelBuilder; * Type: ttCHECK_CANCEL (18) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCash.h b/include/xrpl/protocol_autogen/transactions/CheckCash.h index c742a15154..b80429875f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCash.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCash.h @@ -21,7 +21,7 @@ class CheckCashBuilder; * Type: ttCHECK_CASH (17) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCashBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCreate.h b/include/xrpl/protocol_autogen/transactions/CheckCreate.h index 63e55f8604..db51b5eb5f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCreate.h @@ -21,7 +21,7 @@ class CheckCreateBuilder; * Type: ttCHECK_CREATE (16) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Clawback.h b/include/xrpl/protocol_autogen/transactions/Clawback.h index 9a3a7f9feb..ad79f1d1fe 100644 --- a/include/xrpl/protocol_autogen/transactions/Clawback.h +++ b/include/xrpl/protocol_autogen/transactions/Clawback.h @@ -21,7 +21,7 @@ class ClawbackBuilder; * Type: ttCLAWBACK (30) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h index c80fc81dc5..bf204a35cb 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -21,7 +21,7 @@ class ConfidentialMPTClawbackBuilder; * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index 284b7f9e70..d23e6409d9 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT (85) * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h index 53a8e64125..80ec81e6f3 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBackBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h index 848da42a41..e3ec886acf 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -21,7 +21,7 @@ class ConfidentialMPTMergeInboxBuilder; * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index b994e4ec07..3d28a12cee 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h @@ -21,7 +21,7 @@ class EscrowCreateBuilder; * Type: ttESCROW_CREATE (1) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 2476def5c2..1cbc60c738 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h @@ -21,7 +21,7 @@ class EscrowFinishBuilder; * Type: ttESCROW_FINISH (2) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowFinishBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index af86dea0b0..4c02989f09 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -21,7 +21,7 @@ class LedgerStateFixBuilder; * Type: ttLEDGER_STATE_FIX (53) * Delegable: Delegation::Delegable * Amendment: fixNFTokenPageLinks - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LedgerStateFixBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h index 875e0a4c5e..468ce054c2 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h @@ -21,7 +21,7 @@ class LoanBrokerCoverClawbackBuilder; * Type: ttLOAN_BROKER_COVER_CLAWBACK (78) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h index 38cc113844..0fe1bd7b91 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h @@ -21,7 +21,7 @@ class LoanBrokerCoverDepositBuilder; * Type: ttLOAN_BROKER_COVER_DEPOSIT (76) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 56a93acbb4..4992fb8bbd 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -21,7 +21,7 @@ class LoanBrokerCoverWithdrawBuilder; * Type: ttLOAN_BROKER_COVER_WITHDRAW (77) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt + * Privileges: Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverWithdrawBuilder to construct new transactions. @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerCoverWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the LoanBrokerCoverWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h index 29b3a787fd..c449ebaff0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h @@ -21,7 +21,7 @@ class LoanBrokerDeleteBuilder; * Type: ttLOAN_BROKER_DELETE (75) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MustDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h index 41c87c281d..18f14b7a37 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h @@ -21,7 +21,7 @@ class LoanBrokerSetBuilder; * Type: ttLOAN_BROKER_SET (74) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: CreatePseudoAcct | MayAuthorizeMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanDelete.h b/include/xrpl/protocol_autogen/transactions/LoanDelete.h index 8ed537b37a..2696b542da 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanDelete.h @@ -21,7 +21,7 @@ class LoanDeleteBuilder; * Type: ttLOAN_DELETE (81) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanManage.h b/include/xrpl/protocol_autogen/transactions/LoanManage.h index 5eb95d21b1..4a665b372f 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanManage.h +++ b/include/xrpl/protocol_autogen/transactions/LoanManage.h @@ -21,7 +21,7 @@ class LoanManageBuilder; * Type: ttLOAN_MANAGE (82) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayModifyVault + * Privileges: Privilege::MayModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanManageBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanPay.h b/include/xrpl/protocol_autogen/transactions/LoanPay.h index 8e1faeb981..c9224fd697 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanPay.h +++ b/include/xrpl/protocol_autogen/transactions/LoanPay.h @@ -21,7 +21,7 @@ class LoanPayBuilder; * Type: ttLOAN_PAY (84) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanPayBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanSet.h b/include/xrpl/protocol_autogen/transactions/LoanSet.h index 2cadebd02e..eb04a468f0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -21,7 +21,7 @@ class LoanSetBuilder; * Type: ttLOAN_SET (80) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h index 2fb93eaf35..89d026928d 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h @@ -21,7 +21,7 @@ class MPTokenAuthorizeBuilder; * Type: ttMPTOKEN_AUTHORIZE (57) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: MustAuthorizeMpt + * Privileges: Privilege::MustAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenAuthorizeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index 82ffba9996..b83de9d843 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -21,7 +21,7 @@ class MPTokenIssuanceCreateBuilder; * Type: ttMPTOKEN_ISSUANCE_CREATE (54) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: CreateMptIssuance + * Privileges: Privilege::CreateMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h index cbcd206097..6d1c9b1eaa 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h @@ -21,7 +21,7 @@ class MPTokenIssuanceDestroyBuilder; * Type: ttMPTOKEN_ISSUANCE_DESTROY (55) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: DestroyMptIssuance + * Privileges: Privilege::DestroyMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceDestroyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index ed7e1f0f6c..43def05194 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -21,7 +21,7 @@ class MPTokenIssuanceSetBuilder; * Type: ttMPTOKEN_ISSUANCE_SET (56) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h index 325d2d7fbd..6c858be721 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h @@ -21,7 +21,7 @@ class NFTokenAcceptOfferBuilder; * Type: ttNFTOKEN_ACCEPT_OFFER (29) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenAcceptOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h index ec423ea468..ac831bf45e 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h @@ -21,7 +21,7 @@ class NFTokenBurnBuilder; * Type: ttNFTOKEN_BURN (26) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenBurnBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h index 4c4fb1dc65..81f4f3a848 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h @@ -21,7 +21,7 @@ class NFTokenCancelOfferBuilder; * Type: ttNFTOKEN_CANCEL_OFFER (28) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCancelOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h index a535a578e0..683436f4fd 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h @@ -21,7 +21,7 @@ class NFTokenCreateOfferBuilder; * Type: ttNFTOKEN_CREATE_OFFER (27) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCreateOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h index 5af41eb3dd..5a4e3b5b1c 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h @@ -21,7 +21,7 @@ class NFTokenMintBuilder; * Type: ttNFTOKEN_MINT (25) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenMintBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h index 9b9701fed6..84f1e395d4 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h @@ -21,7 +21,7 @@ class NFTokenModifyBuilder; * Type: ttNFTOKEN_MODIFY (61) * Delegable: Delegation::Delegable * Amendment: featureDynamicNFT - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCancel.h b/include/xrpl/protocol_autogen/transactions/OfferCancel.h index 5e6010e0dd..3e52ebf24b 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCancel.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCancel.h @@ -21,7 +21,7 @@ class OfferCancelBuilder; * Type: ttOFFER_CANCEL (8) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index ffc1216297..774921d87a 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -21,7 +21,7 @@ class OfferCreateBuilder; * Type: ttOFFER_CREATE (7) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleDelete.h b/include/xrpl/protocol_autogen/transactions/OracleDelete.h index ebdc8fb7e9..e50b6f6b02 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleDelete.h +++ b/include/xrpl/protocol_autogen/transactions/OracleDelete.h @@ -21,7 +21,7 @@ class OracleDeleteBuilder; * Type: ttORACLE_DELETE (52) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleSet.h b/include/xrpl/protocol_autogen/transactions/OracleSet.h index 0ec6d5cad0..03e4ffc518 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleSet.h +++ b/include/xrpl/protocol_autogen/transactions/OracleSet.h @@ -21,7 +21,7 @@ class OracleSetBuilder; * Type: ttORACLE_SET (51) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Payment.h b/include/xrpl/protocol_autogen/transactions/Payment.h index 389900bf12..cb177a8d08 100644 --- a/include/xrpl/protocol_autogen/transactions/Payment.h +++ b/include/xrpl/protocol_autogen/transactions/Payment.h @@ -21,7 +21,7 @@ class PaymentBuilder; * Type: ttPAYMENT (0) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: CreateAcct | MayCreateMpt + * Privileges: Privilege::CreateAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h index 4c567b13f4..06892955db 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h @@ -21,7 +21,7 @@ class PaymentChannelClaimBuilder; * Type: ttPAYCHAN_CLAIM (15) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h index 0a513d575a..2a3aebca4c 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h @@ -21,7 +21,7 @@ class PaymentChannelCreateBuilder; * Type: ttPAYCHAN_CREATE (13) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h index 51210dd796..9a8c452b0b 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h @@ -21,7 +21,7 @@ class PaymentChannelFundBuilder; * Type: ttPAYCHAN_FUND (14) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelFundBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h index 3db921776c..1b16b13116 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h @@ -21,7 +21,7 @@ class PermissionedDomainDeleteBuilder; * Type: ttPERMISSIONED_DOMAIN_DELETE (63) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h index 3e352cad76..30832aec8c 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h @@ -21,7 +21,7 @@ class PermissionedDomainSetBuilder; * Type: ttPERMISSIONED_DOMAIN_SET (62) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetFee.h b/include/xrpl/protocol_autogen/transactions/SetFee.h index 177f39199b..9513723e94 100644 --- a/include/xrpl/protocol_autogen/transactions/SetFee.h +++ b/include/xrpl/protocol_autogen/transactions/SetFee.h @@ -21,7 +21,7 @@ class SetFeeBuilder; * Type: ttFEE (101) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetFeeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h index a943bb0279..042676251b 100644 --- a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h +++ b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h @@ -21,7 +21,7 @@ class SetRegularKeyBuilder; * Type: ttREGULAR_KEY_SET (5) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetRegularKeyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SignerListSet.h b/include/xrpl/protocol_autogen/transactions/SignerListSet.h index 6e9d0e41ba..253bcccc1a 100644 --- a/include/xrpl/protocol_autogen/transactions/SignerListSet.h +++ b/include/xrpl/protocol_autogen/transactions/SignerListSet.h @@ -21,7 +21,7 @@ class SignerListSetBuilder; * Type: ttSIGNER_LIST_SET (12) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SignerListSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index dfd12a329f..bb3eb2ccf0 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -21,7 +21,7 @@ class SponsorshipSetBuilder; * Type: ttSPONSORSHIP_SET (91) * Delegable: Delegation::Delegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h index ab26e887e3..5bd5bc1319 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h @@ -21,7 +21,7 @@ class SponsorshipTransferBuilder; * Type: ttSPONSORSHIP_TRANSFER (90) * Delegable: Delegation::NotDelegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipTransferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TicketCreate.h b/include/xrpl/protocol_autogen/transactions/TicketCreate.h index 0d8670a76a..4cb109b8f2 100644 --- a/include/xrpl/protocol_autogen/transactions/TicketCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TicketCreate.h @@ -21,7 +21,7 @@ class TicketCreateBuilder; * Type: ttTICKET_CREATE (10) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TicketCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TrustSet.h b/include/xrpl/protocol_autogen/transactions/TrustSet.h index 22891b94ec..9d939eb1d0 100644 --- a/include/xrpl/protocol_autogen/transactions/TrustSet.h +++ b/include/xrpl/protocol_autogen/transactions/TrustSet.h @@ -21,7 +21,7 @@ class TrustSetBuilder; * Type: ttTRUST_SET (20) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TrustSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/UNLModify.h b/include/xrpl/protocol_autogen/transactions/UNLModify.h index 6569e4bf7d..f5c94071d7 100644 --- a/include/xrpl/protocol_autogen/transactions/UNLModify.h +++ b/include/xrpl/protocol_autogen/transactions/UNLModify.h @@ -21,7 +21,7 @@ class UNLModifyBuilder; * Type: ttUNL_MODIFY (102) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use UNLModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultClawback.h b/include/xrpl/protocol_autogen/transactions/VaultClawback.h index 270ccc94bb..d859b4a446 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultClawback.h +++ b/include/xrpl/protocol_autogen/transactions/VaultClawback.h @@ -21,7 +21,7 @@ class VaultClawbackBuilder; * Type: ttVAULT_CLAWBACK (70) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index e206925e02..2925302dec 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -21,7 +21,7 @@ class VaultCreateBuilder; * Type: ttVAULT_CREATE (65) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: CreatePseudoAcct | CreateMptIssuance | MustModifyVault + * Privileges: Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDelete.h b/include/xrpl/protocol_autogen/transactions/VaultDelete.h index 67cc32f543..3cef0ce599 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDelete.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDelete.h @@ -21,7 +21,7 @@ class VaultDeleteBuilder; * Type: ttVAULT_DELETE (67) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustDeleteAcct | DestroyMptIssuance | MustModifyVault + * Privileges: Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h index 5bb5362114..099342aa0c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h @@ -21,7 +21,7 @@ class VaultDepositBuilder; * Type: ttVAULT_DEPOSIT (68) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultSet.h b/include/xrpl/protocol_autogen/transactions/VaultSet.h index 14df70f13b..33dfe8bf21 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultSet.h +++ b/include/xrpl/protocol_autogen/transactions/VaultSet.h @@ -21,7 +21,7 @@ class VaultSetBuilder; * Type: ttVAULT_SET (66) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustModifyVault + * Privileges: Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 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/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/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/macos.txt b/nix/check-tools/macos.txt index 8e99aa28e4..8edfeef311 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (59807616 2026-04-14) - /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f4 2026-07-14) + /nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index a5857c93f1..28b6c38014 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/88abzp43ywyzql1rhf8jh5aj5n5j7xzr-cargo-1.97.1-x86_64-unknown-linux-gnu/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (5980761 2026-04-14) - /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/6f1icmb2za20kxn30pgmbv5jq9fnbf4z-rustfmt-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rustfmt GCC toolchain: ✅ gcc diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index 820c6de086..b3b5885a7f 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/6hch2qrr86n2sa2m90lrpxrfxxwbkayl-cargo-1.97.1-aarch64-unknown-linux-gnu/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (5980761 2026-04-14) - /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f 2026-07-14) + /nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/nd8g81wv1smnvdpy4whpcyv2siwjmaan-rustfmt-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rustfmt GCC toolchain: ✅ gcc diff --git a/package/Dockerfile b/package/Dockerfile deleted file mode 100644 index 978b569bd8..0000000000 --- a/package/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -ARG BASE_IMAGE=debian:bookworm - -FROM ${BASE_IMAGE} - -COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh - -RUN /tmp/install-packaging-tools.sh diff --git a/package/README.md b/package/README.md index 9c40861530..027a374898 100644 --- a/package/README.md +++ b/package/README.md @@ -8,9 +8,11 @@ a build configured with `-Dvalidator_keys=ON`. ``` package/ - build_pkg.sh Staging and build script (called by the CMake `package` target and CI) - sign_rpm.sh Signs the built RPMs (called by CI when publishing) - publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) + build_pkg.py Staging and build script (called by the CMake `package` target and CI) + sign_rpm.py Signs the built RPMs (called by CI when publishing) + docker/ + Dockerfile Packaging image, built by `build-packaging-images.yml`; installs its tooling with `bin/install-packaging-tools.sh` + publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -23,15 +25,16 @@ package/ ## Prerequisites -Packaging targets and their container images are declared in -[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json) -under `package_configs`, one entry per distro. Today only `linux/amd64` is -emitted. Each entry pins its full container image in an `image` field; to move -to a new image, edit that field and both CI and local builds pick it up. The -package format (deb or rpm) is inferred at build time from the container's -package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). +Packaging is declared on the build configs themselves, in +[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json): +a config that is also packaged carries a `package` map, so its binaries and its +packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map +pins the full container image in `image` — edit that field to move to a new +image and both CI and local builds pick it up — and names the format that image +builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two +have to stay in step. -| Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | +| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | | RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13 | @@ -49,19 +52,20 @@ To print the full packaging matrix (artifact names and images) for the current Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from -`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` -binary artifacts and runs in that distro's container, 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 +the configs that carry a `package` map (via `generate.py --packaging`) and fans +out one job per distro. Each job downloads the pre-built `xrpld` and +`validator-keys` binary artifacts and runs in that distro's container, building +the format `package.type` declares. The packaging script derives the package +version from the downloaded binary's `xrpld --version` output; no CMake configure or build step is needed inside the packaging job. -The binaries come from the `debian` and `rhel` build configurations in -`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +The binaries come from the `debian` and `rhel` build configs themselves — the +ones carrying the `package` map — which pass `-Dvalidator_keys=ON` so that the build job produces `validator-keys` next to `xrpld` and uploads it as the -`validator-keys-` artifact. The packaging entry for a distro names -both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a -packaged configuration must keep `-Dvalidator_keys=ON`. +`validator-keys-` artifact. The packaging matrix names both +artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that +same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs +are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix. `validator-keys` is fetched from an exact commit pinned in [`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given @@ -74,11 +78,10 @@ With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash -# From the repo root. Each distro's container image is the `image` field of its -# package_configs entry in linux.json; the package format is inferred from the -# container's package manager. Example for the rpm-producing image (use -# .package_configs.debian[0].image for the deb image): -IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json) +# From the repo root. Each distro's container image is the `package.image` field +# of its config in linux.json. Example for the rpm-producing image (use +# .configs.debian[0].package.image and --package-type deb for the other one): +IMAGE=$(jq -r '.configs.rhel[0].package.image' .github/scripts/strategy-matrix/linux.json) PKG_RELEASE=1 @@ -86,7 +89,10 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "${IMAGE}" \ - ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" + ./package/build_pkg.py \ + --package-type rpm \ + --pkg-release "${PKG_RELEASE}" \ + --channel UNRELEASED # Output: # build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) @@ -113,12 +119,12 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL The `cmake/XrplPackaging.cmake` module defines the `package` target only if at least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target -builds both binaries before packaging. `build_pkg.sh` then infers the package -format from the host's package manager. The packaging script installs to -FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of -`CMAKE_INSTALL_PREFIX`. +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise, and `--channel UNRELEASED`. +The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, +etc.) regardless of `CMAKE_INSTALL_PREFIX`. -The package version is not a CMake input on this path: `build_pkg.sh` derives it +The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. @@ -126,15 +132,15 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`. Packages are published to the XRPLF repositories on Sonatype Nexus at `https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.sh` maps that channel to a repository pair: +the event, and `publish_pkg.py` maps that channel to its repositories: -| Event | Version | Channel | DEB repository | RPM repository | -| ------------------------ | ----------------- | -------------- | ------------------ | ------------------ | -| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable` | -| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable` | -| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental` | -| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop` | -| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private` | +| Event | Version | Channel | DEB repository | RPM upload repository | +| ------------------------ | ----------------- | --------- | -------------- | --------------------- | +| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable-hosted` | +| tag | `X.Y.Z-rcN` | `rc` | `deb-rc` | `rpm-rc-hosted` | +| tag | `X.Y.Z-bN` | `beta` | `deb-beta` | `rpm-beta-hosted` | +| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | +| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | Only a tag names a channel — do not extend that to `develop`, where `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final @@ -143,24 +149,33 @@ Versions sort in row order, so moving to a more mature channel never downgrades. The action decides the package release number on the same split: a tag's version is unique, so its packages are release 1, while develop repeats the same version -and takes `github.run_number` so each push supersedes the last. Both reach the -packaging scripts as arguments, so neither script derives anything itself. +and takes `.git`, e.g. +`857.20260826gitb6a8995` — the leading run number keeps each push superseding +the last, and the date and hash say which commit a package on +`packages.xrplf.org` came from. Both reach the packaging scripts as arguments, +so neither script derives anything itself. Publishing is the last step of each packaging job, uploading from the container -that built the packages. It runs when the caller passes `publish: true`: -`on-trigger.yml` for develop pushes in `XRPLF/rippled`, `on-tag.yml` for tags in -any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the +that built the packages with the `publish_pkg.py` shipped in the image — the +same copy other repositories run. Without `publish: true` the step is a +`--dry-run`, listing the uploads it would make without needing credentials, so +any run that builds packages also exercises the upload routing. `on-trigger.yml` +passes `publish: true` for develop pushes in `XRPLF/rippled` and `on-tag.yml` +for tags in any `XRPLF` repository, both authenticating with the `NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the -Conan remote. +Conan remote; `on-pr.yml` never publishes. Nexus owns the repository metadata; nothing here indexes anything. Worth knowing: -- Each apt-hosted repository needs a distribution and a PGP signing keypair - configured in Nexus, which rejects one created without a keypair. Nexus signs - the apt metadata with it, never the packages. -- Hosted yum repositories cannot be signed by Nexus at all, so `sign_rpm.sh` - signs the RPMs before they are uploaded, and rpm clients verify with - `gpgcheck=1` rather than `repo_gpgcheck=1`. +- Each apt-hosted repository needs a distribution (ours use `any`) and a PGP + signing keypair configured in Nexus, which rejects one created without a + keypair. Nexus signs the apt metadata with it, never the packages. +- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` + repository sits behind a `rpm-` yum group repository whose metadata + Nexus signs. Uploads go to the hosted repository; clients point at the group + and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs + themselves, so `sign_rpm.py` signs them before they are uploaded, and clients + verify them with `gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. - Each job uploads only what it built, and uploads are not transactional, so a @@ -169,20 +184,26 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - The `develop` repositories gain a package per push, so they need a cleanup policy to stay bounded; tagged channels publish each version once. -## How `build_pkg.sh` works +### Publishing from other repositories -`build_pkg.sh` derives the `xrpld` software version from +`publish_pkg.py` knows nothing about `xrpld`, so the packaging image +installs it at `/usr/local/bin/publish_pkg.py` for other XRPLF repositories that +build their packages elsewhere. + +## How `build_pkg.py` works + +`build_pkg.py` derives the `xrpld` software version from `${BUILD_DIR}/xrpld --version` in both package formats. The binary's version is already SemVer-validated by `BuildInfo`. -`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or +`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or `3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before the final release. If that normalized package version still contains `-`, packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as the upstream/revision separator. `pkg_version` is the normalized package metadata version derived inside -`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release +`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. `PKG_RELEASE` is a different value: the package release iteration for that @@ -200,35 +221,41 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the channel passed as `--channel` -(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build -metadata on a final release such as `3.2.0+abc123`, are both rejected. +`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it +from the build host, so the RHEL image can track a newer release without +changing what the packages claim to target. + +The Debian changelog entry carries the channel passed as `--channel`, which +only accepts the channels in the table above plus `UNRELEASED`, the Debian +convention for a build that targets no channel at all — what local and CMake +builds pass, since nothing publishes them. An unsupported pre-release, and +build metadata on a final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. -The package format (`deb` or `rpm`) is inferred from the host's package -manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those -fail early. +The package format is `--package-type`, either `deb` or `rpm`. It is required, +so a job never silently builds the wrong format for the image it runs in; the +matching build tool still has to be on PATH. -Flags are for explicit invocation; environment variables are intended for -CMake/CI integration. The CI workflow and the CMake `package` target both invoke -`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and -`PKG_CHANNEL` via env and lets the script use defaults for the rest. +Every input is a named argument, and every argument but `--build-dir` and +`--pkg-release` is required. The repository root is not an argument +at all: the script reads it from its own location. Only secrets stay in the +environment, so they never reach the process list -- `PKG_SIGNING_KEY` for +`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`. -Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +Signing is not part of this script. `sign_rpm.py` does it in a separate CI step that only runs when publishing, so a published RPM is always signed and a local build never needs a key. -It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls +It resolves the build directory to an absolute path, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, and shared support files into the staging area, and invokes the platform build -tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging -environment; a missing or non-runnable one fails early. That runtime check is -what catches a binary still linked against the Nix store's ELF loader (see +tool. Both binaries must be present in the build directory and must run in the +packaging environment; a missing or non-runnable one fails early. That runtime +check is what catches a binary still linked against the Nix store's ELF loader (see `patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM @@ -274,10 +301,9 @@ lintian -I debbuild/*.deb ## Reproducibility -`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit -time, or the current time outside a git tree, and exports it (override with -`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file -modification times to it via `%build_mtime_policy`. The remaining variables +`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time and +exports it; the RPM spec clamps file modification times to it via +`%build_mtime_policy`. The remaining variables below further improve reproducibility but are _not_ set by the script — export them yourself if needed: diff --git a/package/build_pkg.py b/package/build_pkg.py new file mode 100755 index 0000000000..2518d8c1db --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries. + +The build tool for the chosen format has to be on PATH, so this runs in the +vanilla distro image that matches it. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import textwrap +from datetime import datetime, timezone +from pathlib import Path + +# This script lives in the repository it packages. +SRC_DIR = Path(__file__).resolve().parents[1] + +PRE_RELEASE = re.compile(r"^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$") + +# Files both packaging systems consume, staged under the same names. +STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") +STAGED_FROM_SRC = { + "cfg/xrpld-example.cfg": "xrpld.cfg", + "cfg/validators-example.txt": "validators.txt", + "LICENSE.md": "LICENSE.md", + "README.md": "README.md", +} +STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") + + +def run(*command: object, cwd: Path | None = None) -> None: + """Echo a command and run it.""" + argv = [str(part) for part in command] + print("+ " + " ".join(argv), flush=True) + subprocess.run(argv, check=True, cwd=cwd) + + +def capture(*command: object) -> str: + """Run a command and return its stdout, stripped.""" + argv = [str(part) for part in command] + # stderr is left alone so a failing command explains itself. + return subprocess.run( + argv, stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def package_version(reported: str) -> str: + """Normalise a reported version into one the package formats accept. + + A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before + the final 3.2.0; a no-op for a final release. + """ + base, _, pre_release = reported.partition("-") + version = f"{base}~{pre_release}" if pre_release else base + + # BuildInfo already SemVer-validates the version. Packaging adds one narrower + # constraint: after normalisation the version must not contain '-', because + # RPM forbids it in Version and Debian reads it as the revision separator. + assert "-" not in version, ( + f"unsupported version {reported!r}: {version!r} cannot contain '-'. " + "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." + ) + assert pre_release or "+" not in reported, ( + f"unsupported version {reported!r}: " + "build metadata is only supported on bN/rcN pre-releases." + ) + assert not pre_release or PRE_RELEASE.match(pre_release), ( + f"unsupported pre-release {pre_release!r}: use bN or rcN, " + "e.g. 3.2.0-b1 or 3.2.0-rc2." + ) + return version + + +def read_version(xrpld: Path) -> str: + """Read the version from the binary that is about to be packaged.""" + fields = capture(xrpld, "--version").partition("\n")[0].split() + assert len(fields) >= 3, f"cannot read a version from {xrpld} --version" + return fields[2] + + +def check_binaries(build_dir: Path) -> None: + """Fail unless the binaries and their notices are present and runnable.""" + missing = [ + name + for name in ("xrpld", "validator-keys") + if not os.access(build_dir / name, os.X_OK) + ] + assert not missing, ( + f"missing or not executable in {build_dir}: {' '.join(missing)}. " + "Both binaries come from a single CMake build directory configured with " + "-Dxrpld=ON -Dvalidator_keys=ON." + ) + + # No package goes out without the attribution. + notice = build_dir / "validator-keys-LICENSE" + assert notice.is_file(), ( + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " + "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + ) + + # Catches a binary still pointing at the Nix store's ELF loader, since + # packaging runs in a vanilla distro container. + capture(build_dir / "validator-keys", "--version") + + +def source_date_epoch() -> int: + """The last commit's timestamp.""" + # git refuses to read a checkout owned by another user, which is what a CI + # container or a bind mount hands it. + return int( + capture( + "git", + "-c", + f"safe.directory={SRC_DIR}", + "-C", + SRC_DIR, + "log", + "-1", + "--format=%ct", + ) + ) + + +def stage_common(build_dir: Path, dest: Path) -> None: + """Copy everything both packaging systems consume into dest.""" + dest.mkdir(parents=True, exist_ok=True) + + for name in STAGED_FROM_BUILD: + shutil.copy2(build_dir / name, dest / name) + for source, name in STAGED_FROM_SRC.items(): + shutil.copy2(SRC_DIR / source, dest / name) + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: + """Stage the spec and its sources, then build the binary RPMs.""" + topdir = build_dir / "rpmbuild" + for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (topdir / name).mkdir(parents=True, exist_ok=True) + + spec = topdir / "SPECS" / "xrpld.spec" + shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) + stage_common(build_dir, topdir / "SOURCES") + + run( + "rpmbuild", + "-bb", + "--define", + f"_topdir {topdir}", + "--define", + f"pkg_version {version}", + "--define", + f"pkg_release {pkg_release}", + # The image tracks the newest distro, but the packages target el9. + "--define", + "dist .el9", + spec, + ) + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + + # debhelper picks these up from debian/ automatically. + for name in STAGED_UNITS: + shutil.copy2(staging / name, staging / "debian" / name) + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + xrpld ({version}-{pkg_release}) {channel}; urgency=medium + * Release {reported}. + + -- XRPL Foundation {date} + """) + (staging / "debian" / "changelog").write_text(changelog) + + (staging / "debian" / "rules").chmod(0o755) + + run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package-type", + required=True, + choices=("deb", "rpm"), + help="the package format to build", + ) + parser.add_argument( + "--build-dir", + type=Path, + default=Path("build"), + help="directory holding the xrpld and validator-keys binaries (default: %(default)s)", + ) + parser.add_argument( + "--pkg-release", + default="1", + help="package release iteration (default: %(default)s)", + ) + parser.add_argument( + "--channel", + required=True, + choices=("stable", "rc", "beta", "develop", "private", "UNRELEASED"), + help="release channel, written to debian/changelog", + ) + args = parser.parse_args() + package_type: str = args.package_type + build_dir: Path = args.build_dir.resolve() + pkg_release: str = args.pkg_release + channel: str = args.channel + + assert build_dir.is_dir(), ( + f"build directory not found: {build_dir}. Build the binaries before " + "packaging, or point --build-dir at the directory holding them." + ) + + check_binaries(build_dir) + reported = read_version(build_dir / "xrpld") + version = package_version(reported) + epoch = source_date_epoch() + + # rpmbuild and dpkg-buildpackage both honour this for file timestamps. + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + + # Remove both build trees, because a package left from an earlier build would + # otherwise be picked up and published alongside this one. + for tree in ("debbuild", "rpmbuild"): + shutil.rmtree(build_dir / tree, ignore_errors=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index cca3be7248..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from the pre-built xrpld and validator-keys -# binaries. -# -# Flags override env vars; env vars override defaults. - -usage() { - cat <<'EOF' -Usage: build_pkg.sh [options] - -Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding the - xrpld and validator-keys - binaries [BUILD_DIR; default: ${PWD}/build] - --pkg-release N package release iteration [PKG_RELEASE; default: 1] - --channel NAME release channel, written - to debian/changelog [PKG_CHANNEL; default: unstable] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] - -h, --help show this help and exit -EOF -} - -need_arg() { - if [[ $# -lt 2 || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 2 - fi -} - -# Seed from env. CLI parsing below overrides these directly. -SRC_DIR="${SRC_DIR:-}" -BUILD_DIR="${BUILD_DIR:-}" -PKG_RELEASE="${PKG_RELEASE:-1}" -PKG_CHANNEL="${PKG_CHANNEL:-unstable}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --src-dir) - need_arg "$@" - SRC_DIR="$2" - shift 2 - ;; - --build-dir) - need_arg "$@" - BUILD_DIR="$2" - shift 2 - ;; - --pkg-release) - need_arg "$@" - PKG_RELEASE="$2" - shift 2 - ;; - --channel) - need_arg "$@" - PKG_CHANNEL="$2" - shift 2 - ;; - --source-date-epoch) - need_arg "$@" - SOURCE_DATE_EPOCH="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="${BUILD_DIR:-${PWD}/build}" -if [[ ! -d "${BUILD_DIR}" ]]; then - echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -validator_keys_binary="${BUILD_DIR}/validator-keys" - -# Report both binaries at once: they share a single BUILD_DIR, so telling the -# reader to point it at one of them in isolation is advice they cannot follow. -missing=() -[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) -[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) - -if [[ ${#missing[@]} -gt 0 ]]; then - echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 - echo "Both binaries come from a single CMake build directory configured with" >&2 - echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 - echo "directory." >&2 - exit 1 -fi - -# Shipping validator-keys means shipping its notice, so treat it as required -# rather than letting a package go out without the attribution. -validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" -if [[ ! -f "${validator_keys_license}" ]]; then - echo "build_pkg.sh: missing ${validator_keys_license}." >&2 - echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 - echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 - exit 1 -fi - -# The binary must also *run* here. Packaging happens in a vanilla distro -# container, so this is what catches a binary still pointing at the Nix store's -# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is -# covered implicitly by the version query below. -if ! "${validator_keys_binary}" --version >/dev/null; then - echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 - exit 1 -fi - -xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')" - -if [[ -z "${xrpld_version}" ]]; then - echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2 - exit 1 -fi - -# The version as the package formats consume it: identical to xrpld_version -# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before -# the final 3.2.0; a no-op for a final release. Lowercase = derived internally, -# not an input (cf. pkg_type). -pkg_version="${xrpld_version}" -pre_release="" -if [[ "${xrpld_version}" == *-* ]]; then - pre_release="${xrpld_version#*-}" - pkg_version="${xrpld_version%%-*}~${pre_release}" -fi - -# BuildInfo already SemVer-validates the binary's version. Packaging adds one -# narrower constraint: after pre-release normalization, the package version must -# not contain '-' because RPM forbids it in Version and Debian uses it as the -# upstream/revision separator. -if [[ "${pkg_version}" == *-* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Package version '${pkg_version}' cannot contain '-'." >&2 - echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Build metadata is only supported on bN/rcN pre-releases." >&2 - exit 1 -fi - -if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if command -v apt-get >/dev/null 2>&1; then - pkg_type=deb -elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then - pkg_type=rpm -else - echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2 - exit 1 -fi - -if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then - if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH="$(date +%s)" - fi -fi - -export SOURCE_DATE_EPOCH -CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" - -SHARED="${SRC_DIR}/package/shared" -DEBIAN_DIR="${SRC_DIR}/package/debian" - -# Stage files that both packaging systems consume using the same filenames. -stage_common() { - local dest="$1" - mkdir -p "${dest}" - - cp "${xrpld_binary}" "${dest}/xrpld" - cp "${validator_keys_binary}" "${dest}/validator-keys" - cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" - - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" -} - -build_rpm() { - local topdir="${BUILD_DIR}/rpmbuild" - mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - - cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" - stage_common "${topdir}/SOURCES" - - set -x - rpmbuild -bb \ - --define "_topdir ${topdir}" \ - --define "pkg_version ${pkg_version}" \ - --define "pkg_release ${PKG_RELEASE}" \ - "${topdir}/SPECS/xrpld.spec" -} - -build_deb() { - local staging="${BUILD_DIR}/debbuild/source" - mkdir -p "${staging}" - - stage_common "${staging}" - cp -r "${DEBIAN_DIR}" "${staging}/debian" - - cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service" - cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers" - cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" - cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - - # Debian version is [~
]-.
-    cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
-EOF
-
-    chmod +x "${staging}/debian/rules"
-
-    set -x
-    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
-}
-
-# Remove both build directories, because a package left from an earlier build
-# would otherwise be picked up and published alongside this one.
-rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
-
-"build_${pkg_type}"
diff --git a/package/docker/Dockerfile b/package/docker/Dockerfile
new file mode 100644
index 0000000000..b55c37b02a
--- /dev/null
+++ b/package/docker/Dockerfile
@@ -0,0 +1,10 @@
+ARG BASE_IMAGE=debian:trixie
+
+FROM ${BASE_IMAGE}
+
+COPY bin/install-packaging-tools.sh /tmp/install-packaging-tools.sh
+
+RUN /tmp/install-packaging-tools.sh
+
+# See ../README.md, "Publishing from other repositories".
+COPY package/docker/publish_pkg.py /usr/local/bin/publish_pkg.py
diff --git a/package/docker/publish_pkg.py b/package/docker/publish_pkg.py
new file mode 100755
index 0000000000..c9a6d3db1e
--- /dev/null
+++ b/package/docker/publish_pkg.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Publish built DEB and RPM packages to the XRPLF repositories on Nexus.
+
+Takes packages and a channel, and nothing else, so it publishes whatever built
+them; see package/README.md, "Publishing from other repositories".
+
+RPMs are uploaded to the hosted repository, but yum clients install from the
+'rpm-' group repository in front of it, which serves signed metadata.
+
+NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
+credentials never reach the process list.
+"""
+
+import argparse
+import base64
+import os
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+SUFFIXES = (".deb", ".ddeb", ".rpm")
+
+# No progress for this long ends an attempt. urlopen applies the timeout per
+# socket operation, so a stalled transfer fails while a merely slow one carries
+# on -- the debuginfo package is large enough for that distinction to matter.
+STALL_TIMEOUT = 300
+
+ATTEMPTS = 4
+RETRY_DELAY = 5
+
+
+def build_opener() -> urllib.request.OpenerDirector:
+    """An opener with no redirect handler, so a 3xx raises instead of being followed.
+
+    A redirected upload is silently downgraded to a GET, turning it into a no-op
+    that still answers 200.
+    """
+    opener = urllib.request.OpenerDirector()
+    opener.add_handler(urllib.request.HTTPHandler())
+    opener.add_handler(urllib.request.HTTPSHandler())
+    opener.add_handler(urllib.request.HTTPErrorProcessor())
+    opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+    return opener
+
+
+def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None:
+    """Send one package, retrying only what is worth retrying.
+
+    A 4xx is a deterministic rejection, so it is reported at once rather than
+    re-sending the whole body three more times. Nexus explains what it rejected
+    in the response body, so that body is always surfaced.
+    """
+    opener = build_opener()
+
+    for attempt in range(1, ATTEMPTS + 1):
+        try:
+            with package.open("rb") as body:
+                request = urllib.request.Request(
+                    url,
+                    data=body,
+                    method=method,
+                    headers={**headers, "Content-Length": str(package.stat().st_size)},
+                )
+                opener.open(request, timeout=STALL_TIMEOUT)
+            return
+        except urllib.error.HTTPError as error:
+            detail = error.read().decode(errors="replace").strip()
+            reason = f"HTTP {error.code}: {detail}"
+            retryable = error.code >= 500
+        except (urllib.error.URLError, OSError) as error:
+            reason = str(error)
+            retryable = True
+
+        assert (
+            retryable and attempt < ATTEMPTS
+        ), f"upload of {package.name} failed: {reason}"
+        print(f"    attempt {attempt} failed ({reason}), retrying")
+        time.sleep(RETRY_DELAY)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--channel",
+        required=True,
+        choices=("stable", "rc", "beta", "develop", "private"),
+        help="release channel, selecting the deb- and rpm--hosted repositories",
+    )
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--nexus-url",
+        default="https://packages.xrplf.org",
+        help="the Nexus instance to publish to (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="list the uploads without performing them",
+    )
+    args = parser.parse_args()
+    channel: str = args.channel
+    package_dir: Path = args.package_dir
+    nexus_url: str = args.nexus_url
+    dry_run: bool = args.dry_run
+
+    nexus = nexus_url.rstrip("/")
+    deb_repo = f"deb-{channel}"
+    rpm_repo = f"rpm-{channel}-hosted"
+
+    auth: dict[str, str] = {}
+    if not dry_run:
+        username = os.environ.get("NEXUS_USERNAME")
+        password = os.environ.get("NEXUS_PASSWORD")
+        assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
+        token = base64.b64encode(f"{username}:{password}".encode()).decode()
+        auth = {"Authorization": f"Basic {token}"}
+
+    packages = sorted(
+        path
+        for path in package_dir.rglob("*")
+        if path.is_file() and path.suffix in SUFFIXES
+    )
+    # Uploading nothing would otherwise look like a successful publish.
+    assert packages, f"no packages found in {package_dir}"
+
+    print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
+    for package in packages:
+        if package.suffix == ".rpm":
+            # yum repositories are addressed by path, and the arch comes from
+            # the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
+            destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
+            url = f"{nexus}/repository/{destination}/{package.name}"
+            method, content_type = "PUT", "application/octet-stream"
+        else:
+            # A raw body with a multipart Content-Type, POSTed to the repository
+            # root, is the documented upload for a hosted apt repository:
+            # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+            destination = deb_repo
+            url = f"{nexus}/repository/{destination}/"
+            method, content_type = "POST", "multipart/form-data"
+
+        print(f"  {package.name} -> {destination}")
+        if not dry_run:
+            upload(url, method, {"Content-Type": content_type, **auth}, package)
+
+    verb = "would be published" if dry_run else "published"
+    print(f"{len(packages)} package(s) {verb}.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
deleted file mode 100755
index be36b531de..0000000000
--- a/package/publish_pkg.sh
+++ /dev/null
@@ -1,106 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
-# repositories on Sonatype Nexus.
-#
-# Usage: publish_pkg.sh  [package-dir]
-#
-#   channel      release channel, selecting the 'deb-' and
-#                'rpm-' repository pair
-#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
-#                default)
-#
-# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
-# instance, and DRY_RUN=1 lists the uploads without performing them.
-
-channel="${1:-}"
-pkg_dir="${2:-build}"
-nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
-
-if [[ -z "${channel}" ]]; then
-    echo "usage: publish_pkg.sh  [package-dir]" >&2
-    exit 2
-fi
-
-deb_repo="deb-${channel}"
-rpm_repo="rpm-${channel}"
-
-if [[ -z "${DRY_RUN:-}" ]]; then
-    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
-fi
-
-# Deliberate curl choices:
-#
-#   - no --fail, which would hide the response body where Nexus explains what it
-#     rejected
-#   - no --location, since curl downgrades a redirected POST to GET and turns an
-#     upload into a no-op that still answers 200
-#   - credentials on stdin, to keep them out of the process list
-upload() {
-    local url="$1"
-    shift
-    [[ -z "${DRY_RUN:-}" ]] || return 0
-
-    local body code status=0
-    body="$(mktemp)"
-    code="$(
-        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
-            curl \
-                --config - \
-                --silent \
-                --show-error \
-                --retry 3 \
-                --retry-delay 5 \
-                --retry-all-errors \
-                --output "${body}" \
-                --write-out '%{http_code}' \
-                "$@" \
-                "${url}"
-    )" || status=$?
-
-    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
-        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
-        cat "${body}" >&2
-        echo >&2
-        rm -f "${body}"
-        exit 1
-    fi
-
-    rm -f "${body}"
-}
-
-echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
-
-count=0
-while IFS= read -r -d '' file; do
-    name="${file##*/}"
-    case "${name}" in
-        # A raw body with a multipart Content-Type, POSTed to the repository root,
-        # is the documented upload for a hosted apt repository:
-        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
-        *.deb | *.ddeb)
-            echo "  ${name} -> ${deb_repo}"
-            upload "${nexus_url}/repository/${deb_repo}/" \
-                --header 'Content-Type: multipart/form-data' \
-                --data-binary "@${file}"
-            ;;
-        # yum repositories are addressed by path; the arch comes from the name.
-        *.rpm)
-            arch="${name%.rpm}"
-            arch="${arch##*.}"
-            echo "  ${name} -> ${rpm_repo}/${arch}"
-            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
-                --upload-file "${file}"
-            ;;
-    esac
-    count=$((count + 1))
-done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
-
-# Uploading nothing would otherwise look like a successful publish.
-if [[ ${count} -eq 0 ]]; then
-    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-echo "${count} package(s) ${DRY_RUN:+would be }published."
diff --git a/package/sign_rpm.py b/package/sign_rpm.py
new file mode 100755
index 0000000000..05c719b710
--- /dev/null
+++ b/package/sign_rpm.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Sign the RPMs built by build_pkg.py.
+
+Nexus signs the yum repository metadata (via the 'rpm-' group
+repository), but never the packages themselves, so they carry their own
+signature. Clients verify the packages with gpgcheck=1 and the metadata with
+repo_gpgcheck=1.
+
+The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+but apt does not verify them by default and trusts the repository metadata,
+which Nexus signs, instead.
+
+PKG_SIGNING_KEY is read from the environment, so the key never reaches the
+process list.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
+# so both are queried; checking only the first would reject a signed package.
+SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
+UNSIGNED = "(none)(none)"
+
+
+def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
+    """Run gpg against a throwaway keyring and return its stdout."""
+    return subprocess.run(
+        ["gpg", "--batch", "--quiet", *args],
+        input=stdin,
+        # stderr is left alone so a failing gpg explains itself.
+        stdout=subprocess.PIPE,
+        text=True,
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    ).stdout
+
+
+def import_key(gnupghome: Path, key: str) -> str:
+    """Import the armoured private key and return its fingerprint."""
+    gpg(gnupghome, "--import", stdin=key)
+
+    records = [
+        line.split(":")
+        for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
+    ]
+    # Exactly one, so the fingerprint picked below is not a guess.
+    secrets = [record for record in records if record[0] == "sec"]
+    assert (
+        len(secrets) == 1
+    ), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
+
+    # The first fingerprint belongs to the primary key; subkeys follow.
+    fingerprints = [record[9] for record in records if record[0] == "fpr"]
+    assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
+    return fingerprints[0]
+
+
+def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
+    """Attach a signature to every RPM in one rpmsign invocation."""
+    subprocess.run(
+        [
+            "rpmsign",
+            "--define",
+            f"_gpg_name {fingerprint}",
+            # Loopback pinentry: the key is unattended, so there is no tty to
+            # prompt on.
+            "--define",
+            "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
+            "--addsign",
+            *(str(rpm) for rpm in rpms),
+        ],
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    )
+
+
+def verify(rpms: list[Path]) -> None:
+    """Fail unless every RPM now carries a signature.
+
+    rpmsign can exit 0 having attached nothing, and an unsigned package is only
+    rejected later, on the installing machine.
+    """
+    for rpm in rpms:
+        signature = subprocess.run(
+            ["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
+            stdout=subprocess.PIPE,
+            text=True,
+            check=True,
+        ).stdout.strip()
+        assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help="searched recursively for *.rpm (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    package_dir: Path = args.package_dir
+
+    rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
+    # Signing nothing would otherwise look like a successful signing.
+    assert rpms, f"no RPMs found in {package_dir}"
+
+    key = os.environ.get("PKG_SIGNING_KEY")
+    assert key, "PKG_SIGNING_KEY is required"
+
+    # The keyring holds an unencrypted private key, so it goes even if signing
+    # fails.
+    with tempfile.TemporaryDirectory() as tmp:
+        gnupghome = Path(tmp)
+        fingerprint = import_key(gnupghome, key)
+        print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
+        sign(gnupghome, rpms, fingerprint)
+        verify(rpms)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
deleted file mode 100755
index 7a1d6f00e3..0000000000
--- a/package/sign_rpm.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Sign the RPMs built by build_pkg.sh. Nexus cannot sign hosted yum metadata, so
-# the packages carry the signature themselves and rpm clients verify them with
-# gpgcheck=1.
-#
-# Usage: sign_rpm.sh [package-dir]
-#
-#   package-dir  searched recursively for *.rpm ('build' by default)
-#
-# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
-# the key out of the process list.
-#
-# There is no DEB equivalent: apt trusts the repository metadata, which Nexus
-# signs, rather than the packages themselves.
-
-pkg_dir="${1:-build}"
-
-mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
-
-# Signing nothing would otherwise look like a successful signing.
-if [[ ${#rpms[@]} -eq 0 ]]; then
-    echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-: "${PKG_SIGNING_KEY:?is required}"
-
-# Global, and expanded by the trap when it fires: the keyring holds an
-# unencrypted private key, so it must go even if signing fails.
-signing_home="$(mktemp -d)"
-trap 'rm -rf "${signing_home}"' EXIT
-export GNUPGHOME="${signing_home}"
-
-printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
-
-# Exactly one secret key, so that picking the first below is not a guess between
-# several.
-secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
-if [[ "${secrets}" -ne 1 ]]; then
-    echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
-    exit 1
-fi
-
-key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
-echo "Signing ${#rpms[@]} RPM(s) with ${key}."
-
-# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
-rpmsign \
-    --define "_gpg_name ${key}" \
-    --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
-    --addsign "${rpms[@]}"
-
-# rpmsign can exit 0 having attached nothing, and an unsigned package is only
-# rejected later, on the installing machine. Both header tags are checked
-# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
-# DSAHEADER.
-for pkg in "${rpms[@]}"; do
-    signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
-    if [[ "${signature}" == "(none)(none)" ]]; then
-        echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
-        exit 1
-    fi
-done
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index a82b4734d8..dd5e1fe438 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,4 +1,4 @@
 [toolchain]
-channel = "1.95"
-components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"]
+channel = "1.97.1"
+components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"]
 profile = "minimal"
diff --git a/src/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/ledger/View.cpp b/src/libxrpl/ledger/View.cpp
index e01ae2e492..0cd082ff47 100644
--- a/src/libxrpl/ledger/View.cpp
+++ b/src/libxrpl/ledger/View.cpp
@@ -35,6 +35,7 @@
 #include 
 #include 
 #include 
+#include 
 
 namespace xrpl {
 
@@ -467,7 +468,8 @@ canWithdraw(
     AccountID const& to,
     SLE::const_ref toSle,
     STAmount const& amount,
-    bool hasDestinationTag)
+    bool hasDestinationTag,
+    std::optional> const& credentialIDs)
 {
     if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag))
         return ret;
@@ -478,7 +480,28 @@ canWithdraw(
     if (toSle->isFlag(lsfDepositAuth))
     {
         if (!view.exists(keylet::depositPreauth(to, from)))
-            return tecNO_PERMISSION;
+        {
+            if (credentialIDs.has_value())
+            {
+                STVector256 const credIDs{*credentialIDs};
+
+                // Callers must have validated these in preclaim, so a missing
+                // credential here is an invariant violation.
+                for (auto const& h : credIDs)
+                {
+                    if (!view.exists(keylet::credential(h)))
+                        return tecINTERNAL;  // LCOV_EXCL_LINE
+                }
+
+                if (auto const ret = credentials::authorizedDepositPreauth(view, credIDs, to);
+                    !isTesSuccess(ret))
+                    return ret;
+            }
+            else
+            {
+                return tecNO_PERMISSION;
+            }
+        }
     }
 
     return withdrawToDestExceedsLimit(view, from, to, amount);
@@ -490,11 +513,12 @@ canWithdraw(
     AccountID const& from,
     AccountID const& to,
     STAmount const& amount,
-    bool hasDestinationTag)
+    bool hasDestinationTag,
+    std::optional> const& credentialIDs)
 {
     auto const toSle = view.read(keylet::account(to));
 
-    return canWithdraw(view, from, to, toSle, amount, hasDestinationTag);
+    return canWithdraw(view, from, to, toSle, amount, hasDestinationTag, credentialIDs);
 }
 
 [[nodiscard]] TER
@@ -503,7 +527,8 @@ canWithdraw(ReadView const& view, STTx const& tx)
     auto const from = tx[sfAccount];
     auto const to = tx[~sfDestination].value_or(from);
 
-    return canWithdraw(view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag));
+    return canWithdraw(
+        view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag), tx[~sfCredentialIDs]);
 }
 
 TER
diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
index fcad22d2d5..20a793e4cb 100644
--- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp
@@ -11,6 +11,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -690,6 +691,12 @@ deleteAMMTrustLines(
 
                 return {deleteAMMTrustLine(sb, sleItem, ammAccountID, j), SkipEntry::No};
             }
+            // A credential naming the pseudo-account as subject can't be
+            // accepted or deleted by it and would otherwise permanently pin the
+            // AMM. Clean it up here, inside the same bounded walk, so the
+            // pinned AMM can still be deleted.
+            if (sb.rules().enabled(fixCleanup3_4_0) && nodeType == ltCREDENTIAL)
+                return {credentials::deleteSLE(sb, sleItem, j), SkipEntry::No};
             // LCOV_EXCL_START
             JLOG(j.error()) << "deleteAMMObjects: deleting non-trustline or non-MPT " << nodeType;
             return {tecINTERNAL, SkipEntry::No};
@@ -767,6 +774,8 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo
         // LCOV_EXCL_STOP
     }
 
+    // deleteAMMTrustLines also removes any credentials pinned to the AMM
+    // pseudo-account, within its bounded walk.
     if (auto const ter = deleteAMMTrustLines(sb, ammAccountID, kMaxDeletableAmmTrustLines, j);
         !isTesSuccess(ter))
         return ter;
@@ -908,6 +917,11 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c
                 ++nMPT;
                 continue;
             }
+            // A credential naming the pseudo-account as subject can be pinned
+            // to its owner directory. Ignore it here; deleteAMMTrustLines
+            // removes it when the AMM is deleted.
+            if (view.rules().enabled(fixCleanup3_4_0) && entryType == ltCREDENTIAL)
+                continue;
             if (entryType != ltRIPPLE_STATE)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             auto const lowLimit = sle->getFieldAmount(sfLowLimit);
diff --git a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
index faca4ebfb6..819ebb04d1 100644
--- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp
@@ -28,7 +28,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 
@@ -515,8 +514,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey)
 }
 
 // Pseudo-account designator fields MUST be maintained by including the
-// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to
-// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated,
+// SField::kSmdPseudoAccount flag in the SField definition. (Don't forget to
+// "| SField::kSmdDefault"!) The fields do NOT need to be amendment-gated,
 // since a non-active amendment will not set any field, by definition.
 // Specific properties of a pseudo-account are NOT checked here, that's what
 // InvariantCheck is for.
@@ -547,18 +546,14 @@ getPseudoAccountFields()
 }
 
 [[nodiscard]] bool
-isPseudoAccount(SLE::const_pointer sleAcct, std::set const& pseudoFieldFilter)
+isPseudoAccount(SLE::const_pointer sleAcct)
 {
-    auto const& fields = getPseudoAccountFields();
-
     // Intentionally use defensive coding here because it's cheap and makes the
     // semantics of true return value clean.
     return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT &&
-        std::count_if(
-            fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool {
-                return sleAcct->isFieldPresent(*sf) &&
-                    (pseudoFieldFilter.empty() || pseudoFieldFilter.contains(sf));
-            }) > 0;
+        std::ranges::any_of(getPseudoAccountFields(), [&sleAcct](SField const* sf) {
+               return sleAcct->isFieldPresent(*sf);
+           });
 }
 
 std::expected
diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
index 5ba832957d..9c3ca4ec78 100644
--- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp
@@ -5,8 +5,10 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -127,6 +129,36 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j)
     return tesSUCCESS;
 }
 
+TER
+deletePseudoAccountCredentials(
+    ApplyView& view,
+    AccountID const& pseudoAcct,
+    std::uint16_t maxNodesToDelete,
+    beast::Journal j)
+{
+    XRPL_ASSERT(
+        isPseudoAccount(view.read(keylet::account(pseudoAcct))),
+        "xrpl::credentials::deletePseudoAccountCredentials : is a pseudo-account");
+
+    // Delete the credentials linked into the pseudo-account's owner directory,
+    // visiting at most maxNodesToDelete entries. Any other object is left in
+    // place; the caller's own checks decide whether the remaining directory
+    // blocks deletion. If the bound is reached, cleanupOnAccountDelete returns
+    // tecINCOMPLETE and the caller propagates it so a later transaction resumes.
+    return cleanupOnAccountDelete(
+        view,
+        keylet::ownerDir(pseudoAcct),
+        [&view, &j](LedgerEntryType nodeType, uint256 const&, SLE::pointer& sleItem)
+            -> std::pair {
+            if (nodeType == ltCREDENTIAL)
+                return {deleteSLE(view, sleItem, j), SkipEntry::No};
+
+            return {tesSUCCESS, SkipEntry::Yes};
+        },
+        j,
+        maxNodesToDelete);
+}
+
 NotTEC
 checkFields(STTx const& tx, Rules const& rules, beast::Journal j)
 {
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index cf1bd4915f..10c7e62c6c 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -169,6 +169,16 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
         roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
 }
 
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle)
+{
+    return hasExpired(
+        view,
+        loanSle->at(sfNextPaymentDueDate),
+        view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                              : ExpiryComparison::Inclusive);
+}
+
 namespace accrual {
 
 AccountingDeltas
@@ -514,7 +524,7 @@ loanLatePaymentInterest(
     // If the payment is not late by any amount of time, then there's no late
     // interest
     if (now <= nextPaymentDueDate)
-        return 0;
+        return kNumZero;
 
     // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
     auto const secondsOverdue = now - nextPaymentDueDate;
@@ -1035,7 +1045,7 @@ doOverpayment(
 std::expected
 computeLatePayment(
     Asset const& asset,
-    ApplyView const& view,
+    ReadView const& view,
     SLE::const_ref loan,
     ExtendedPaymentComponents const& periodic,
     STAmount const& amount,
@@ -1046,8 +1056,11 @@ computeLatePayment(
     std::int32_t const loanScale = loan->at(sfLoanScale);
 
     // Check if the due date has passed. If not, reject the payment as
-    // being too soon
-    if (!hasExpired(view, nextDueDate))
+    // being too soon. Uses isPaymentLate() so this agrees with the
+    // regular payment path on whether the loan is actually late at the
+    // exact due date boundary (amendment-gated: Exclusive once
+    // fixCleanup3_4_0 is enabled, Inclusive otherwise).
+    if (!isPaymentLate(view, loan))
         return std::unexpected(tecTOO_SOON);
 
     // Calculate the penalty interest based on how long the payment is overdue.
@@ -1128,7 +1141,7 @@ computeLatePayment(
 std::expected
 computeFullPayment(
     Asset const& asset,
-    ApplyView& view,
+    ReadView const& view,
     SLE::const_ref loan,
     Number const& periodicRate,
     STAmount const& amount,
@@ -2270,7 +2283,7 @@ loanMakePayment(
 
     // -------------------------------------------------------------
     // A late payment not flagged as late overrides all other options.
-    if (paymentType != LoanPaymentType::Late && hasExpired(view, nextDueDateProxy))
+    if (paymentType != LoanPaymentType::Late && isPaymentLate(view, loan))
     {
         // If the payment is late, and the late flag was not set, it's not
         // valid
diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
index 73d5fdb1d5..1b9bb19ad4 100644
--- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp
@@ -384,8 +384,7 @@ requireAuth(
     // They are implicitly authorized for any MPT they hold, including vault shares whose
     // underlying asset would otherwise require auth.
     auto const isPseudoAccountExempt = [&] {
-        return (featureSAVEnabled || featureMPTV2Enabled) &&
-            isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID, &sfAMMID});
+        return (featureSAVEnabled || featureMPTV2Enabled) && isPseudoAccount(view, account);
     };
 
     auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
index 868c9fb26d..706564db6f 100644
--- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
@@ -584,9 +584,15 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account,
     {
         if (trustLine)
         {
-            return trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth)
-                ? tesSUCCESS
-                : TER{tecNO_AUTH};
+            if (trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth))
+                return tesSUCCESS;
+
+            // A pseudo-account cannot submit transactions and only stores assets for the object
+            // that owns it, so it is implicitly authorized.
+            if (view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(view, account))
+                return tesSUCCESS;
+
+            return TER{tecNO_AUTH};
         }
         return TER{tecNO_LINE};
     }
diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
index 7ebfa64bcf..aaf99a3c0a 100644
--- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp
@@ -535,13 +535,19 @@ accountFunds(
 }
 
 Rate
-transferRate(ReadView const& view, STAmount const& amount)
+transferRate(ReadView const& view, Asset const& asset)
 {
-    return amount.asset().visit(
+    return asset.visit(
         [&](Issue const& issue) { return transferRate(view, issue.getIssuer()); },
         [&](MPTIssue const& issue) { return transferRate(view, issue.getMptID()); });
 }
 
+Rate
+transferRate(ReadView const& view, STAmount const& amount)
+{
+    return transferRate(view, amount.asset());
+}
+
 //------------------------------------------------------------------------------
 //
 // Holding operations
diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
index b0d835a423..941b94143d 100644
--- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
@@ -1,9 +1,11 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include   // IWYU pragma: keep
@@ -13,8 +15,10 @@
 #include 
 #include   // IWYU pragma: keep
 #include 
+#include 
 
 #include 
+#include 
 #include 
 #include 
 
@@ -67,6 +71,65 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
     return assets;
 }
 
+[[nodiscard]] std::expected
+clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta)
+{
+    XRPL_ASSERT(
+        delta.asset() == vault->at(sfAsset),
+        "xrpl::clampToAssetsTotalScale : delta and vault asset match");
+
+    Asset const asset = vault->at(sfAsset);
+
+    STAmount magnitude = delta.negative() ? -delta : delta;
+    if (asset.integral())
+    {
+        return magnitude;
+    }
+    Number const assetsTotal = vault->at(sfAssetsTotal);
+
+    // Calculate the scale after applying the delta using ToNearest rounding.
+    // This aligns the delta with scale checks used by vault invariants.
+    int const postScale = [&] {
+        NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
+        return scale(assetsTotal + delta, asset);
+    }();
+
+    STAmount actualDelta;
+    if (delta.negative())
+    {
+        // For withdrawals (debits), floor the magnitude to the target scale
+        // to ensure exact grid alignment without paying out extra assets.
+        actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
+    }
+    else
+    {
+        // For deposits (credits), derive actualDelta from the floored posterior total.
+        // This prevents grid alignment issues from crediting the vault more than deposited.
+        //
+        // Sum using Downward rounding so intermediate precision doesn't round up
+        // and exceed the original requested amount.
+        Number const posterior = [&] {
+            NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
+            return assetsTotal + magnitude;
+        }();
+
+        Number const roundedPosterior =
+            roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
+        actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
+    }
+
+    XRPL_ASSERT(
+        abs(actualDelta) <= abs(delta),
+        "xrpl::clampToAssetsTotalScale : actual delta smaller or equal to calculated delta");
+
+    // Reject changes below scale precision (1 ULP) to prevent share balance changes
+    // without corresponding asset movements.
+    if (actualDelta <= beast::kZero)
+        return std::unexpected(tecPRECISION_LOSS);
+
+    return actualDelta;
+}
+
 [[nodiscard]] Number
 assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
 {
@@ -242,4 +305,26 @@ getVaultPhase(
     return VaultPhase::Redemption;
 }
 
+[[nodiscard]] TER
+checkVaultDomain(
+    ReadView const& view,
+    SLE::const_ref issuance,
+    AccountID const& subject,
+    SuppressExpired suppressExpired)
+{
+    XRPL_ASSERT(
+        issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
+        "xrpl::checkVaultDomain : valid issuance SLE");
+
+    auto const maybeDomainID = issuance->at(~sfDomainID);
+    if (!maybeDomainID)
+        return tecNO_AUTH;
+
+    auto const err = credentials::validDomain(view, *maybeDomainID, subject);
+    if (err == tecEXPIRED && suppressExpired == SuppressExpired::Yes)
+        return tesSUCCESS;
+
+    return err;
+}
+
 }  // namespace xrpl
diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp
index ff4e5aa0ee..f1917eccff 100644
--- a/src/libxrpl/protocol/BuildInfo.cpp
+++ b/src/libxrpl/protocol/BuildInfo.cpp
@@ -23,7 +23,7 @@ namespace {
 //------------------------------------------------------------------------------
 // clang-format off
 // NOLINTNEXTLINE(readability-identifier-naming)
-char const* const versionString = "3.4.0-b0"
+char const* const versionString = "3.4.0-b2"
     // clang-format on
     ;
 
diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp
index 66fdfd453b..91ed5c893f 100644
--- a/src/libxrpl/protocol/Indexes.cpp
+++ b/src/libxrpl/protocol/Indexes.cpp
@@ -123,6 +123,10 @@ getBookBase(Book const& book)
 {
     XRPL_ASSERT(isConsistent(book), "xrpl::getBookBase : input is consistent");
 
+    constexpr std::uint8_t kIssueToMPTTag = 0x01;
+    constexpr std::uint8_t kMPTToIssueTag = 0x02;
+    constexpr std::uint8_t kMPTToMPTTag = 0x03;
+
     auto getIndexHash = [&book](Args... args) {
         if (book.domain)
             return indexHash(std::forward(args)..., *book.domain);
@@ -136,19 +140,36 @@ getBookBase(Book const& book)
                 return getIndexHash(
                     LedgerNameSpace::BookDir, in.currency, out.currency, in.account, out.account);
             }
+            // The three MPT-involving branches are new under MPTokensV2 and
+            // each gets a 1-byte discriminator to prevent preimage collisions
+            // between branches: the (Issue,MPT) and (MPT,Issue) preimages
+            // are both 64 bytes of raw concatenation, so without a
+            // per-branch tag chosen Currency / MPTID / AccountID values can
+            // align byte-for-byte and produce the same BookDir keylet for
+            // two distinct markets. (Issue,Issue) is left untagged to
+            // preserve existing mainnet order-book keylets.
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.currency, out.getMptID(), in.account);
+                    LedgerNameSpace::BookDir,
+                    kIssueToMPTTag,
+                    in.currency,
+                    out.getMptID(),
+                    in.account);
             }
             else if constexpr (std::is_same_v && std::is_same_v)
             {
                 return getIndexHash(
-                    LedgerNameSpace::BookDir, in.getMptID(), out.currency, out.account);
+                    LedgerNameSpace::BookDir,
+                    kMPTToIssueTag,
+                    in.getMptID(),
+                    out.currency,
+                    out.account);
             }
             else
             {
-                return getIndexHash(LedgerNameSpace::BookDir, in.getMptID(), out.getMptID());
+                return getIndexHash(
+                    LedgerNameSpace::BookDir, kMPTToMPTTag, in.getMptID(), out.getMptID());
             }
         },
         book.in.value(),
diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp
index 2f3e25f823..a5adb294e9 100644
--- a/src/libxrpl/protocol/Permissions.cpp
+++ b/src/libxrpl/protocol/Permissions.cpp
@@ -10,6 +10,7 @@
 #include 
 #include   // IWYU pragma: keep
 #include 
+#include 
 
 #include 
 #include 
@@ -40,16 +41,24 @@ Permission::GranularPermissionEntry::GranularPermissionEntry(
 Permission::Permission()
 {
     {
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, delegable, amendment, ...) \
-    txDelegationMap_[static_cast(value)] = {amendment, delegable};
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                               \
+    {                                                                              \
+        TxSettings const s = UNWRAP settings;                                      \
+        txDelegationMap_[static_cast(value)] = {s.amendment, s.delegable}; \
+    }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
     }
 
     granularPermissionsByName_ = {
@@ -242,7 +251,7 @@ Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const
 
     // Tx-level permissions require the transaction type itself to be delegable, and
     // the corresponding amendment enabled.
-    return txIt != txDelegationMap_.end() && txIt->second.delegable != NotDelegable &&
+    return txIt != txDelegationMap_.end() && txIt->second.delegable != Delegation::NotDelegable &&
         amendmentEnabled(txIt->second);
 }
 
diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp
index e4d4c4b03c..c393c606fe 100644
--- a/src/libxrpl/protocol/TxFormats.cpp
+++ b/src/libxrpl/protocol/TxFormats.cpp
@@ -45,7 +45,7 @@ TxFormats::TxFormats()
 #undef TRANSACTION
 
 #define UNWRAP(...) __VA_ARGS__
-#define TRANSACTION(tag, value, name, delegable, amendment, privileges, fields) \
+#define TRANSACTION(tag, value, name, settings, fields) \
     add(jss::name, tag, UNWRAP fields, getCommonFields());
 
 #include 
diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp
index a12e524a5f..4319d0bcd4 100644
--- a/src/libxrpl/shamap/SHAMapSync.cpp
+++ b/src/libxrpl/shamap/SHAMapSync.cpp
@@ -143,6 +143,20 @@ SHAMap::visitDifferences(
         if (!function(*node))
             return;
 
+        // Nibbles run out at kLeafDepth, so only a leaf belongs there. A well-formed map never
+        // holds an inner node at that depth: addKnownNode marks the map invalid rather than hooking
+        // one in, and fetch-pack data is hash-verified against a validated root, so reaching this
+        // means a defect or a corrupt store, not something a peer can provoke. Report the node
+        // anyway - the wire form carries no depth, and the recipient hooks blobs in by hash - but
+        // skip the children rather than letting getChildNodeID throw on them.
+        if (nodeID.getDepth() >= kLeafDepth)
+        {
+            // LCOV_EXCL_START
+            UNREACHABLE("xrpl::SHAMap::visitDifferences : inner node at leaf depth");
+            continue;
+            // LCOV_EXCL_STOP
+        }
+
         // 2) push non-matching child inner nodes
         for (auto i = 0u; i < kBranchFactor; ++i)
         {
@@ -749,11 +763,9 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const
 
     do
     {
-        // An inner node is only reachable here at a depth below kLeafDepth in a well-formed map,
-        // where the loop always finds a leaf first. A malformed map could still have an inner
-        // node claiming kLeafDepth, and getChildNodeID below throws in that case: reject rather
-        // than let the throw escape uncaught. Not reachable through any public entry point,
-        // since addKnownNode already marks such a map invalid, so no test can cover this.
+        // Same kLeafDepth hazard as in visitDifferences above. That guard bounds the caller's own
+        // traversal, not the map queried here, and the loop below descends from this map's root
+        // independently, so this check is what keeps a malformed map from reaching getChildNodeID.
         if (nodeID.getDepth() >= kLeafDepth)
         {
             // LCOV_EXCL_START
@@ -830,15 +842,30 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector
             if (node->getHash() != hash)
                 return false;
 
-            auto const depth = std::distance(path.rbegin(), rit);
+            auto const depth = static_cast(std::distance(path.rbegin(), rit));
             if (node->isInner())
             {
-                auto nodeId = SHAMapNodeID::createID(static_cast(depth), key);
+                // Nibbles run out at kLeafDepth, so only the leaf terminating the path may sit
+                // there. These nodes come off the wire, so a peer can still claim an inner one;
+                // reject it rather than passing this depth to selectBranch.
+                SOMETIMES(
+                    depth >= kLeafDepth, "xrpl::SHAMap::verifyProofPath : inner at leaf depth");
+                if (depth >= kLeafDepth)
+                    return false;
+
+                auto nodeId = SHAMapNodeID::createID(depth, key);
                 hash = safeDowncast(node.get())
                            ->getChildHash(selectBranch(nodeId, key));
             }
             else
             {
+                // The hash chain up to rootHash only proves this leaf sits where the path claims,
+                // not that it is the leaf for `key`: a peer could substitute any other leaf whose
+                // subtree hashes to the same value at every level above it. Checking the terminal
+                // leaf's own key is what ties the proof to `key` specifically.
+                if (leafKey(*node) != key)
+                    return false;
+
                 // should exhaust all the blobs now
                 return depth + 1 == path.size();
             }
diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
index 8e97e730d9..4eb43d1596 100644
--- a/src/libxrpl/tx/Transactor.cpp
+++ b/src/libxrpl/tx/Transactor.cpp
@@ -1249,7 +1249,7 @@ removeExpiredNFTokenOffers(
 }
 
 static void
-removeExpiredCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ)
+removeDeletedCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ)
 {
     for (auto const& index : creds)
     {
@@ -1258,7 +1258,7 @@ removeExpiredCredentials(ApplyView& view, std::vector const& creds, bea
             if (auto const ter = credentials::deleteSLE(view, sle, viewJ); !isTesSuccess(ter))
             {
                 JLOG(viewJ.error())
-                    << "removeExpiredCredentials: failed to delete expired credential. Err: "
+                    << "removeDeletedCredentials: failed to delete credential. Err: "
                     << transToken(ter);
             }
         }
@@ -1440,7 +1440,8 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
     //        should be used, making it possible to do more useful work
     //        when transactions fail with a `tec` code.
 
-    auto typesForResult = [](TER const ter) {
+    auto typesForResult = [credentialCleanup =
+                               view().rules().enabled(fixCleanup3_4_0)](TER const ter) {
         std::unordered_set types;
         if ((ter == tecOVERSIZE) || (ter == tecKILLED))
         {
@@ -1449,6 +1450,11 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
         else if (ter == tecINCOMPLETE)
         {
             types.insert(ltRIPPLE_STATE);
+            // A bounded pseudo-account credential cleanup (VaultDelete /
+            // LoanBrokerDelete) persists its partial credential deletions so a
+            // later transaction can resume.
+            if (credentialCleanup)
+                types.insert(ltCREDENTIAL);
         }
         else if (ter == tecEXPIRED)
         {
@@ -1526,7 +1532,7 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
                     removeDeletedTrustLines(view(), ids, viewJ);
                     break;
                 case ltCREDENTIAL:
-                    removeExpiredCredentials(view(), ids, viewJ);
+                    removeDeletedCredentials(view(), ids, viewJ);
                     break;
                 // LCOV_EXCL_START
                 default:
diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
index d6039eabd8..272e52f09a 100644
--- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp
+++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
@@ -288,7 +288,8 @@ TransfersNotFrozen::validateFrozenState(
     // individually-frozen or deep-frozen AMM trust lines.
     // Post-fixCleanup3_4_0: AMMClawbacks are allowed to override all freeze types.
     bool const isAMMLine = change.line->isFlag(lsfAMMNode);
-    if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze))
+    if ((fixOverrideFreeze || !isAMMLine || globalFreeze) &&
+        hasPrivilege(tx, Privilege::OverrideFreeze))
     {
         JLOG(j.debug()) << "Invariant check allowing funds to be moved "
                         << (change.balanceChangeSign > 0 ? "to" : "from")
diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp
index 369206d9e6..aa4df8db42 100644
--- a/src/libxrpl/tx/invariants/InvariantCheck.cpp
+++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp
@@ -25,6 +25,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -40,12 +41,15 @@
 
 namespace xrpl {
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \
-    case tag: {                                                              \
-        return (privileges) & priv;                                          \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                  \
+    case tag: {                                                                       \
+        return ((TxSettings UNWRAP settings).privileges & priv) != Privilege::NoPriv; \
     }
 
 bool
@@ -63,6 +67,8 @@ hasPrivilege(STTx const& tx, Privilege priv)
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
 // Returns the human-readable name of a ledger entry's type, falling back to
 // the numeric type if the format is somehow unknown.
@@ -436,7 +442,7 @@ AccountRootsNotDeleted::finalize(
     // transaction when the total AMM LP Tokens balance goes to 0.
     // A successful AccountDelete or AMMDelete MUST delete exactly
     // one account root.
-    if (hasPrivilege(tx, MustDeleteAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::MustDeleteAcct) && isTesSuccess(result))
     {
         if (accountsDeleted_ == 1)
             return true;
@@ -457,7 +463,7 @@ AccountRootsNotDeleted::finalize(
     // A successful AMMWithdraw/AMMClawback MAY delete one account root
     // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw
     // deletes the AMM account, accountsDeleted_ is set if it is deleted.
-    if (hasPrivilege(tx, MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
+    if (hasPrivilege(tx, Privilege::MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1)
         return true;
 
     if (accountsDeleted_ == 0)
@@ -760,14 +766,15 @@ ValidNewAccountRoot::finalize(
     }
 
     // From this point on we know exactly one account was created.
-    if (hasPrivilege(tx, CreateAcct | CreatePseudoAcct) && isTesSuccess(result))
+    if (hasPrivilege(tx, Privilege::CreateAcct | Privilege::CreatePseudoAcct) &&
+        isTesSuccess(result))
     {
         bool const pseudoAccount =
             (pseudoAccount_ &&
              (view.rules().enabled(featureSingleAssetVault) ||
               view.rules().enabled(featureLendingProtocol)));
 
-        if (pseudoAccount && !hasPrivilege(tx, CreatePseudoAcct))
+        if (pseudoAccount && !hasPrivilege(tx, Privilege::CreatePseudoAcct))
         {
             JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a "
                                "wrong transaction type";
diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
index 9a7e96e44f..66b9028ed2 100644
--- a/src/libxrpl/tx/invariants/MPTInvariant.cpp
+++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp
@@ -144,6 +144,8 @@ ValidMPTIssuance::finalize(
     //     must not dangle outside that controlled lifecycle.
     if (rules.enabled(fixCleanup3_2_0))
     {
+        // Not an amendment gate like the same-named flags below, just an
+        // accumulator, so that every violation gets logged before returning.
         bool invariantPasses = true;
         if (referenceHoldingMutated_)
         {
@@ -209,7 +211,7 @@ ValidMPTIssuance::finalize(
         }
 
         auto const txnType = tx.getTxnType();
-        if (hasPrivilege(tx, CreateMptIssuance))
+        if (hasPrivilege(tx, Privilege::CreateMptIssuance))
         {
             if (mptIssuancesCreated_ == 0)
             {
@@ -230,8 +232,16 @@ ValidMPTIssuance::finalize(
             return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
         }
 
-        if (hasPrivilege(tx, DestroyMptIssuance))
+        if (hasPrivilege(tx, Privilege::DestroyMptIssuance))
         {
+            // A VaultDelete that is still cleaning up credentials pinned to its
+            // pseudo-account returns tecINCOMPLETE and has not yet reached the
+            // share issuance. Don't require the issuance to be removed until
+            // the deletion completes (a later transaction).
+            if (rules.enabled(fixCleanup3_4_0) && txnType == ttVAULT_DELETE &&
+                result == tecINCOMPLETE)
+                return mptIssuancesDeleted_ == 0 && mptIssuancesCreated_ == 0;
+
             if (mptIssuancesDeleted_ == 0)
             {
                 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
@@ -257,7 +267,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);
 
@@ -273,7 +284,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)
@@ -309,7 +320,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
@@ -322,7 +333,7 @@ ValidMPTIssuance::finalize(
             return true;
         }
 
-        if (hasPrivilege(tx, MayCreateMpt))
+        if (hasPrivilege(tx, Privilege::MayCreateMpt))
         {
             bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
 
@@ -377,7 +388,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;
@@ -474,7 +485,9 @@ ValidMPTBalanceChanges::finalize(
     ReadView const& view,
     beast::Journal const& j)
 {
-    if (isTesSuccess(result))
+    auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
+
+    if (isTesSuccess(result) || fix340Enabled)
     {
         // Confidential transactions are validated by ValidConfidentialMPToken.
         // They modify encrypted fields and sfConfidentialOutstandingAmount
@@ -486,7 +499,9 @@ ValidMPTBalanceChanges::finalize(
             return true;
         }
 
-        bool const invariantPasses = !view.rules().enabled(featureMPTokensV2);
+        // Returned when a violation is found below, so this is the log-only
+        // condition. Either amendment makes the checks enforcing.
+        auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
         if (overflow_)
         {
             JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
@@ -510,6 +525,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;
+            }
         }
     }
 
@@ -820,7 +847,7 @@ ValidMPTTransfer::isAuthorized(
     // auth.  Exempt them here rather than relying on requireAuth: the recursive
     // share -> underlying descent in requireAuth fails for a pseudo-account
     // that holds the share but not the underlying.
-    if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
+    if (isPseudoAccount(view, holder))
         return true;
 
     auto const key = keylet::mptoken(mptid, holder);
@@ -833,12 +860,12 @@ 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
@@ -864,9 +891,19 @@ ValidMPTTransfer::finalize(
         return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
     }();
 
-    // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes.
-    // Log invariant failure error even if MPTokensV2 is disabled.
-    auto const invariantPasses = !view.rules().enabled(featureMPTokensV2);
+    auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
+    // Returned when a violation is found below, so this is the log-only
+    // condition. Either amendment makes the checks enforcing.
+    auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
+
+    // A failed transaction must not persist an MPToken deletion. Pre-loop
+    // because deletedAuthorized_ is not issuance-scoped and orphans continue.
+    if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty())
+    {
+        JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " "
+                        << result;
+        return invariantPasses;
+    }
 
     for (auto const& [mptID, values] : amount_)
     {
@@ -876,6 +913,20 @@ ValidMPTTransfer::finalize(
         auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
         if (!sleIssuance)
         {
+            // MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so
+            // an orphaned MPToken can outlive its issuance and be cleaned up
+            // later by a transaction of any type. There are no transfer rules
+            // left to check, but its balance is zero and nothing can raise it,
+            // so any change other than deletion is a bug.
+            for (auto const& [account, value] : values)
+            {
+                if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
+                {
+                    JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed "
+                                    << txnType << " " << result;
+                    return invariantPasses;
+                }
+            }
             continue;
         }
 
@@ -939,6 +990,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/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp
index 5c25a22987..1bfb9d3d43 100644
--- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
+++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -305,6 +306,45 @@ ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const
     return true;
 }
 
+namespace {
+
+// sfAssetsTotal, sfAssetsAvailable and sfLossUnrealized are STNumber fields
+// with kSmdNeedsAsset, so IOU writes go through associateAsset -> roundToAsset
+// -> STAmount quantization. Since assetsTotal is the largest number, it lands
+// on the coarsest decimal grid, and strict equality on the deltas can fire on
+// a single unit of quantization noise even when the underlying flow is
+// correct. Absorb one unit at the coarsest scale.
+//
+// XRP and MPT are integer-domain assets (Asset::integral() is true) with no
+// sub-ULP quantization; treating a whole drop / MPT unit as "noise" would
+// hide real accounting bugs. Keep the strict comparison there. Note that
+// gating on the sign of `scale` would be wrong: IOU amounts >= 1e15 have a
+// non-negative STAmount exponent but still quantize.
+[[nodiscard]] bool
+agreesWithinOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
+{
+    if (asset.integral())
+        return lhs == rhs;
+    auto const diff = lhs - rhs;
+    Number const tolerance{1, scale};
+    return (diff < beast::kZero ? -diff : diff) <= tolerance;
+}
+
+// L, T and A are each independently quantized; the strict L <= T - A check
+// can fire on residual noise even when the true relationship holds. Tolerate
+// one unit at scale(assetsTotal) - the coarsest of the three grids. As with
+// the delta check above, the tolerance is meaningful only for IOU
+// (Asset::integral() is false); XRP and MPT keep the strict comparison.
+[[nodiscard]] bool
+lessOrEqualPlusOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale)
+{
+    if (asset.integral())
+        return lhs <= rhs;
+    return lhs <= rhs + Number{1, scale};
+}
+
+}  // namespace
+
 std::int32_t
 ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
 {
@@ -340,13 +380,14 @@ ValidVault::finalize(
     beast::Journal const& j)
 {
     bool const enforce = view.rules().enabled(featureSingleAssetVault);
+    bool const fixEnabled = view.rules().enabled(fixCleanup3_4_0);
 
     if (!isTesSuccess(ret))
         return true;  // Do not perform checks
 
     if (afterVault_.empty() && beforeVault_.empty())
     {
-        if (hasPrivilege(tx, MustModifyVault))
+        if (hasPrivilege(tx, Privilege::MustModifyVault))
         {
             JLOG(j.fatal()) <<  //
                 "Invariant failed: vault operation succeeded without modifying "
@@ -357,7 +398,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";
@@ -526,15 +568,32 @@ ValidVault::finalize(
                            "not be greater than assets outstanding";
         result = false;
     }
-    else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable)
+    else
     {
-        JLOG(j.fatal())  //
-            << "Invariant failed: loss unrealized must not exceed "
-               "the difference between assets outstanding and available";
-        result = false;
+        bool const gapExceeded = [&] {
+            if (!fixEnabled)
+            {
+                return afterVault.lossUnrealized >
+                    afterVault.assetsTotal - afterVault.assetsAvailable;
+            }
+
+            auto const s = scale(afterVault.assetsTotal, afterVault.asset);
+            return !lessOrEqualPlusOneUnit(
+                afterVault.lossUnrealized,
+                afterVault.assetsTotal - afterVault.assetsAvailable,
+                afterVault.asset,
+                s);
+        }();
+        if (gapExceeded)
+        {
+            JLOG(j.fatal())  //
+                << "Invariant failed: loss unrealized must not exceed "
+                   "the difference between assets outstanding and available";
+            result = false;
+        }
     }
 
-    if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero)
+    if (fixEnabled && afterVault.lossUnrealized < kZero)
     {
         JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative";
         result = false;
@@ -820,7 +879,14 @@ ValidVault::finalize(
                         result = false;
                     }
 
-                    if (localVaultDeltaAssets * -1 != accountDeltaAssets)
+                    bool const acctVaultAddsUp = fixEnabled
+                        ? agreesWithinOneUnit(
+                              localVaultDeltaAssets * -1,
+                              accountDeltaAssets,
+                              vaultAsset,
+                              localMinScale)
+                        : localVaultDeltaAssets * -1 == accountDeltaAssets;
+                    if (!acctVaultAddsUp)
                     {
                         JLOG(j.fatal()) << "Invariant failed: " <<  //
                             "deposit must change vault and depositor balance by equal amount";
@@ -868,7 +934,10 @@ ValidVault::finalize(
 
                 auto const assetTotalDelta = roundToAsset(
                     vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
-                if (assetTotalDelta != vaultDeltaAssets)
+                bool const totalAddsUp = fixEnabled
+                    ? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
+                    : assetTotalDelta == vaultDeltaAssets;
+                if (!totalAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: deposit and assets outstanding must add up";
@@ -877,7 +946,11 @@ ValidVault::finalize(
 
                 auto const assetAvailableDelta = roundToAsset(
                     vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
-                if (assetAvailableDelta != vaultDeltaAssets)
+                bool const availableAddsUp = fixEnabled
+                    ? agreesWithinOneUnit(
+                          assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
+                    : assetAvailableDelta == vaultDeltaAssets;
+                if (!availableAddsUp)
                 {
                     JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up";
                     result = false;
@@ -919,8 +992,8 @@ ValidVault::finalize(
                 // value merely rounds down to zero, so a missing delta while
                 // the pool still held positive effective value indicates a
                 // real accounting bug, not this exception.
-                bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) &&
-                    !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized;
+                bool const zeroDeltaIsLegitimate = fixEnabled && !maybeVaultDeltaAssets &&
+                    beforeVault.assetsTotal == beforeVault.lossUnrealized;
 
                 if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate)
                 {
@@ -1026,8 +1099,14 @@ ValidVault::finalize(
                                 vaultDeltaAssets.delta * -1 - destinationDelta.delta,
                                 destinationScale,
                                 Number::RoundingMode::Downward) == kZero;
-                        if (!destroyedIsSubUlp &&
-                            localPseudoDeltaAssets * -1 != roundedDestinationDelta)
+                        bool const withdrawAddsUp = fixEnabled
+                            ? agreesWithinOneUnit(
+                                  localPseudoDeltaAssets * -1,
+                                  roundedDestinationDelta,
+                                  vaultAsset,
+                                  localMinScale)
+                            : localPseudoDeltaAssets * -1 == roundedDestinationDelta;
+                        if (!destroyedIsSubUlp && !withdrawAddsUp)
                         {
                             JLOG(j.fatal()) << "Invariant failed: " <<  //
                                 "withdrawal must change vault and destination balance by equal "
@@ -1070,7 +1149,11 @@ ValidVault::finalize(
                 auto const assetTotalDelta = roundToAsset(
                     vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
                 // Note, vaultBalance is negative (see check above)
-                if (assetTotalDelta != vaultPseudoDeltaAssets)
+                bool const totalAddsUp = fixEnabled
+                    ? agreesWithinOneUnit(
+                          assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
+                    : assetTotalDelta == vaultPseudoDeltaAssets;
+                if (!totalAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: withdrawal and assets outstanding must add up";
@@ -1080,7 +1163,11 @@ ValidVault::finalize(
                 auto const assetAvailableDelta = roundToAsset(
                     vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
 
-                if (assetAvailableDelta != vaultPseudoDeltaAssets)
+                bool const availableAddsUp = fixEnabled
+                    ? agreesWithinOneUnit(
+                          assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale)
+                    : assetAvailableDelta == vaultPseudoDeltaAssets;
+                if (!availableAddsUp)
                 {
                     JLOG(j.fatal())
                         << "Invariant failed: withdrawal and assets available must add up";
@@ -1125,7 +1212,11 @@ ValidVault::finalize(
 
                     auto const assetsTotalDelta = roundToAsset(
                         vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
-                    if (assetsTotalDelta != vaultDeltaAssets)
+                    bool const totalAddsUp = fixEnabled
+                        ? agreesWithinOneUnit(
+                              assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale)
+                        : assetsTotalDelta == vaultDeltaAssets;
+                    if (!totalAddsUp)
                     {
                         JLOG(j.fatal()) <<  //
                             "Invariant failed: clawback and assets outstanding must add up";
@@ -1136,7 +1227,11 @@ ValidVault::finalize(
                         vaultAsset,
                         afterVault.assetsAvailable - beforeVault.assetsAvailable,
                         minScale);
-                    if (assetAvailableDelta != vaultDeltaAssets)
+                    bool const availableAddsUp = fixEnabled
+                        ? agreesWithinOneUnit(
+                              assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale)
+                        : assetAvailableDelta == vaultDeltaAssets;
+                    if (!availableAddsUp)
                     {
                         JLOG(j.fatal()) <<  //
                             "Invariant failed: clawback and assets available must add up";
diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp
index 2823627108..ae218a4cff 100644
--- a/src/libxrpl/tx/paths/BookStep.cpp
+++ b/src/libxrpl/tx/paths/BookStep.cpp
@@ -1500,6 +1500,13 @@ template 
 bool
 BookStep::checkMPTDEX(ReadView const& view, AccountID const& owner) const
 {
+    // Offer-owner locks on book_.in and book_.out are handled by the
+    // liquidity sources before an offer reaches this point. OfferStream
+    // filters CLOB offers through the assetIn deep-freeze check and the
+    // assetOut owner-funds check using FreezeHandling::ZeroIfFrozen, while
+    // AMMLiquidity gets pool balances through ammAccountHolds(), which zeroes
+    // locked holdings. This method only enforces MPT trade and transfer
+    // permissions.
     if (!isTesSuccess(canTrade(view, book_.in)) || !isTesSuccess(canTrade(view, book_.out)))
         return false;
 
@@ -1513,14 +1520,8 @@ BookStep::checkMPTDEX(ReadView const& view, AccountID const
             // Offer's owner is an issuer
             if (asset.getIssuer() == owner)
                 return true;
-            // The previous step could be MPTEndpointStep with non issuer account or
-            // BookStep. Fail both if in asset is locked. In the former case it is holder
-            // to locked holder transfer. In the latter case it is not possible to tell if
-            // it is issuer to holder or holder to holder transfer.
-            if (isFrozen(view, owner, book_.in.get()))
-                return false;
-            // Previous step is BookStep. BookStep only sends if CanTransfer is
-            // set and not locked or the offer is owned by an issuer
+            // Previous BookStep already enforced transferability for the asset
+            // it sends to this offer.
             if (prevStep_->bookStepBook())
                 return true;
             // Previous step is MPTEndpointStep and offer's owner is not an
diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
index a47cfa15a5..8fd69d3106 100644
--- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp
+++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -89,6 +90,13 @@ protected:
     void
     resetCache(DebtDirection dir);
 
+    [[nodiscard]] TER
+    sendWithMPTCreate(
+        ApplyView& view,
+        AccountID const& src,
+        AccountID const& dst,
+        MPTAmount const& amount);
+
 private:
     MPTEndpointStep(
         StrandContext const& ctx,
@@ -274,7 +282,7 @@ public:
 
     // Not applicable for payment
     static TER
-    checkCreateMPT(ApplyView&, DebtDirection)
+    checkCreateMPT(ApplyView&)
     {
         return tesSUCCESS;
     }
@@ -322,7 +330,7 @@ public:
 
     // Can be created in rev or fwd (if limiting step) direction.
     TER
-    checkCreateMPT(ApplyView& view, DebtDirection srcDebtDir);
+    checkCreateMPT(ApplyView& view);
 };
 
 //------------------------------------------------------------------------------
@@ -401,7 +409,7 @@ MPTEndpointOfferCrossingStep::check(StrandContext const& ctx, SLE::const_ref)
 }
 
 TER
-MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirection srcDebtDir)
+MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view)
 {
     // TakerPays is the last step if offer crossing
     if (isLast_)
@@ -412,9 +420,14 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio
         // crossed. See CreateOffer::applyGuts() for reserve check.
         if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err))
         {
+            // Unreachable: offer-crossing checks reject an offer whose owner
+            // could fail to create the MPToken.
+            // LCOV_EXCL_START
+            UNREACHABLE(
+                "xrpl::MPTEndpointOfferCrossingStep::checkCreateMPT : create MPToken failed");
             JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT";
-            resetCache(srcDebtDir);
             return err;
+            // LCOV_EXCL_STOP
         }
     }
     return tesSUCCESS;
@@ -422,6 +435,30 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio
 
 //------------------------------------------------------------------------------
 
+template 
+TER
+MPTEndpointStep::sendWithMPTCreate(
+    ApplyView& view,
+    AccountID const& src,
+    AccountID const& dst,
+    MPTAmount const& amount)
+{
+    // Only offer crossing can fail here (payment checkCreateMPT is a no-op),
+    // via the unreachable path excluded in checkCreateMPT() above.
+    if (auto const err = static_cast(this)->checkCreateMPT(view); !isTesSuccess(err))
+        return err;  // LCOV_EXCL_LINE
+
+    return directSendNoFee(
+        view,
+        src,
+        dst,
+        toSTAmount(amount, mptIssue_),
+        /*checkIssuer*/ false,
+        j_);
+}
+
+//------------------------------------------------------------------------------
+
 template 
 std::pair
 MPTEndpointStep::maxPaymentFlow(ReadView const& sb) const
@@ -478,8 +515,6 @@ MPTEndpointStep::revImp(
     auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Reverse);
     (void)dstQIn;
 
-    MPTIssue const srcToDstIss(mptIssue_);
-
     JLOG(j_.trace()) << "MPTEndpointStep::rev"
                      << " srcRedeems: " << redeems(srcDebtDir) << " outReq: " << to_string(out)
                      << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut
@@ -492,59 +527,41 @@ MPTEndpointStep::revImp(
         return {beast::kZero, beast::kZero};
     }
 
-    if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir);
-        !isTesSuccess(err))
-        return {beast::kZero, beast::kZero};
+    // When a previous step feeds this issuing step, srcQOut is the issuer's
+    // transfer rate and maxPaymentFlow() returns the issuance maximum rather
+    // than a real limit, so srcToDst * srcQOut need not be representable. Cap
+    // srcToDst at the largest amount whose input is; the previous step then
+    // limits the flow to what the source actually holds.
+    MPTAmount const maxRepresentable =
+        mulRatio(MPTAmount(kMaxMpTokenAmount), QUALITY_ONE, srcQOut, /*roundUp*/ false);
 
     // Don't have to factor in dstQIn since it is always QUALITY_ONE
-    MPTAmount const srcToDst = out;
+    MPTAmount const srcToDst = std::min({out, maxSrcToDst, maxRepresentable});
 
-    if (srcToDst <= maxSrcToDst)
-    {
-        MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-        cache_.emplace(in, srcToDst, srcToDst, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
-        {
-            JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter;
-            resetCache(srcDebtDir);
-            return {beast::kZero, beast::kZero};
-        }
-        JLOG(j_.trace()) << "MPTEndpointStep::rev: Non-limiting"
-                         << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
-                         << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
-        return {in, out};
-    }
+    // Can't overflow: srcToDst <= kMaxMpTokenAmount * QUALITY_ONE / srcQOut,
+    // so the rounded up product is at most kMaxMpTokenAmount.
+    MPTAmount const in = mulRatio(srcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
 
-    // limiting node
-    MPTAmount const in = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-    // Don't have to factor in dsqQIn since it's always QUALITY_ONE
-    MPTAmount const actualOut = maxSrcToDst;
-    cache_.emplace(in, maxSrcToDst, actualOut, srcDebtDir);
+    cache_.emplace(in, srcToDst, srcToDst, srcDebtDir);
 
-    auto const ter = directSendNoFee(
-        sb,
-        src_,
-        dst_,
-        toSTAmount(maxSrcToDst, srcToDstIss),
-        /*checkIssuer*/ false,
-        j_);
+    auto const ter = sendWithMPTCreate(sb, src_, dst_, srcToDst);
     if (!isTesSuccess(ter))
     {
+        // Unreachable: send fails only on funds/auth/overflow, precluded by
+        // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::revImp : send failed");
         JLOG(j_.trace()) << "MPTEndpointStep::rev: error " << ter;
         resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
     }
-    JLOG(j_.trace()) << "MPTEndpointStep::rev: Limiting"
+
+    JLOG(j_.trace()) << "MPTEndpointStep::rev: " << (srcToDst < out ? "Limiting" : "Non-limiting")
                      << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
-                     << " srcToDst: " << to_string(maxSrcToDst) << " out: " << to_string(out);
-    return {in, actualOut};
+                     << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
+
+    return {in, srcToDst};
 }
 
 // The forward pass should never have more liquidity than the reverse
@@ -609,8 +626,6 @@ MPTEndpointStep::fwdImp(
     auto const [srcQOut, dstQIn] = qualities(sb, srcDebtDir, StrandDirection::Forward);
     (void)dstQIn;
 
-    MPTIssue const srcToDstIss(mptIssue_);
-
     JLOG(j_.trace()) << "MPTEndpointStep::fwd"
                      << " srcRedeems: " << redeems(srcDebtDir) << " inReq: " << to_string(in)
                      << " maxSrcToDst: " << to_string(maxSrcToDst) << " srcQOut: " << srcQOut
@@ -618,63 +633,81 @@ MPTEndpointStep::fwdImp(
 
     if (maxSrcToDst.signum() <= 0)
     {
+        // Unreachable: the reverse pass owns dry detection; every path that
+        // reaches fwdImp (see StrandFlow::flow) has a funded source.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : dry source");
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: dry";
         resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
     }
 
-    if (auto const err = static_cast(this)->checkCreateMPT(sb, srcDebtDir);
-        !isTesSuccess(err))
+    auto const maybeSrcToDst = tryMulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false);
+    if (!maybeSrcToDst)
+    {
+        // Unreachable: divides by srcQOut >= QUALITY_ONE, so result <= in <=
+        // maxMPTAmount and can never overflow int64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : source to destination overflow");
+        JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow";
+        resetCache(srcDebtDir);
         return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
+    }
 
-    MPTAmount const srcToDst = mulRatio(in, QUALITY_ONE, srcQOut, /*roundUp*/ false);
+    MPTAmount const srcToDst = *maybeSrcToDst;
 
     if (srcToDst <= maxSrcToDst)
     {
         // Don't have to factor in dstQIn since it's always QUALITY_ONE
         MPTAmount const out = srcToDst;
         setCacheLimiting(in, srcToDst, out, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(cache_->srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
-        {
-            JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
-            resetCache(srcDebtDir);
-            return {beast::kZero, beast::kZero};
-        }
+
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: Non-limiting"
                          << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(in)
                          << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
     }
     else
     {
+        // Unreachable: the reverse pass owns all limiting; the forward driver
+        // (StrandFlow::flow) never re-finds a limit, so srcToDst <= maxSrcToDst.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : forward pass limiting");
         // limiting node
-        MPTAmount const actualIn = mulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
-        // Don't have to factor in dstQIn since it's always QUALITY_ONE
-        MPTAmount const out = maxSrcToDst;
-        setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir);
-        auto const ter = directSendNoFee(
-            sb,
-            src_,
-            dst_,
-            toSTAmount(cache_->srcToDst, srcToDstIss),
-            /*checkIssuer*/ false,
-            j_);
-        if (!isTesSuccess(ter))
+        auto const maybeActualIn = tryMulRatio(maxSrcToDst, srcQOut, QUALITY_ONE, /*roundUp*/ true);
+        if (!maybeActualIn)
         {
-            JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
+            JLOG(j_.trace()) << "MPTEndpointStep::fwd: overflow";
             resetCache(srcDebtDir);
             return {beast::kZero, beast::kZero};
         }
+
+        MPTAmount const actualIn = *maybeActualIn;
+
+        // Don't have to factor in dstQIn since it's always QUALITY_ONE
+        MPTAmount const out = maxSrcToDst;
+        setCacheLimiting(actualIn, maxSrcToDst, out, srcDebtDir);
+
         JLOG(j_.trace()) << "MPTEndpointStep::fwd: Limiting"
                          << " srcRedeems: " << redeems(srcDebtDir) << " in: " << to_string(actualIn)
                          << " srcToDst: " << to_string(srcToDst) << " out: " << to_string(out);
+        // LCOV_EXCL_STOP
     }
+
+    auto const ter = sendWithMPTCreate(sb, src_, dst_, cache_->srcToDst);
+    if (!isTesSuccess(ter))
+    {
+        // Unreachable: send fails only on funds/auth/overflow, precluded by
+        // maxPaymentFlow, check() requireAuth, and 2*kMaxMpTokenAmount < 2^64.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::MPTEndpointStep::fwdImp : send failed");
+        JLOG(j_.trace()) << "MPTEndpointStep::fwd: error " << ter;
+        resetCache(srcDebtDir);
+        return {beast::kZero, beast::kZero};
+        // LCOV_EXCL_STOP
+    }
+
     return {cache_->in, cache_->out};
     // NOLINTEND(bugprone-unchecked-optional-access)
 }
diff --git a/src/libxrpl/tx/transactors/dex/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 e690cd7693..b25c90069c 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -324,11 +324,13 @@ AMMClawback::equalWithdrawMatchingOneAmount(
     auto amount2Withdraw = amount2Balance * frac;
 
     auto const lpTokensWithdraw = toSTAmount(lptAMMBalance.asset(), lptAMMBalance * frac);
-    if (lpTokensWithdraw > holdLPtokens)
+    auto const& rules = sb.rules();
+    // Pre-fixCleanup3_4_0 only a strictly greater computed LP amount takes
+    // the withdraw-all path. Equality left the last holder unable to be
+    // fully clawed. The amendment treats equality as withdraw-all.
+    if (rules.enabled(fixCleanup3_4_0) ? lpTokensWithdraw >= holdLPtokens
+                                       : lpTokensWithdraw > holdLPtokens)
     {
-        // if lptoken balance less than what the issuer intended to clawback,
-        // clawback all the tokens. Because we are doing a two-asset withdrawal,
-        // tfee is actually not used, so pass tfee as 0.
         return AMMWithdraw::equalWithdrawTokens(
             sb,
             ammSle,
@@ -348,7 +350,6 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             ctx_.journal);
     }
 
-    auto const& rules = sb.rules();
     if (rules.enabled(fixAMMClawbackRounding))
     {
         auto tokensAdj = getRoundedLPTokens(rules, lptAMMBalance, frac, IsDeposit::No);
diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
index edd2cc2037..7744c128af 100644
--- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp
@@ -669,17 +669,16 @@ AMMWithdraw::withdraw(
         mptokenKey = std::nullopt;
         if (!enabledFixAmMv12 || isXRP(asset))
             return tesSUCCESS;
-        bool const isIssue = asset.holds();
-        bool const assetNotExists = [&] {
-            if (isIssue)
-                return !view.exists(keylet::trustLine(account, asset.get()));
-            auto const issuanceKey = keylet::mptokenIssuance(asset.get());
-            mptokenKey = keylet::mptoken(issuanceKey.key, account);
-            if (!view.exists(*mptokenKey))
-                return true;
-            mptokenKey = std::nullopt;
-            return false;
-        }();
+        bool const assetNotExists = asset.visit(
+            [&](Issue const& issue) { return !view.exists(keylet::trustLine(account, issue)); },
+            [&](MPTIssue const& issue) {
+                auto const issuanceKey = keylet::mptokenIssuance(issue);
+                mptokenKey = keylet::mptoken(issuanceKey.key, account);
+                if (!view.exists(*mptokenKey))
+                    return true;
+                mptokenKey = std::nullopt;
+                return false;
+            });
         if (assetNotExists)
         {
             auto sleAccount = view.peek(keylet::account(account));
@@ -693,7 +692,7 @@ AMMWithdraw::withdraw(
                     ? XRPAmount(beast::kZero)
                     : accountReserve(view, sleAccount, journal, {.ownerCountDelta = 1}));
 
-            auto const balanceAdj = isIssue ? std::max(priorBalance, balance) : priorBalance;
+            auto const balanceAdj = std::max(priorBalance, balance);
             if (balanceAdj < reserve)
                 return tecINSUFFICIENT_RESERVE;
         }
diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
index 0492f9c062..7ab1143d12 100644
--- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
+++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp
@@ -672,6 +672,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
     }
 
     bool crossed = false;
+    bool const mptV2 = ctx_.view().rules().enabled(featureMPTokensV2);
 
     if (isTesSuccess(result))
     {
@@ -694,7 +695,12 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
             if (sle && sle->isFieldPresent(sfTickSize))
                 uTickSize = std::min(uTickSize, (*sle)[sfTickSize]);
         }
-        if (uTickSize < Quality::kMaxTickSize)
+        // Quality's ctor is the same getRate() call that produced uRate, and
+        // round() maps zero to zero, so an unrepresentable quality would make
+        // divide() below throw (tefEXCEPTION). Skip the rounding instead: the
+        // offer still crosses, and any residual is stopped before placement.
+        bool const unrepresentableRate = mptV2 && uRate == 0;
+        if (uTickSize < Quality::kMaxTickSize && !unrepresentableRate)
         {
             auto const rate = Quality{saTakerGets, saTakerPays}.round(uTickSize).rate();
 
@@ -841,6 +847,20 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
         return {tesSUCCESS, true};
     }
 
+    // The remainder rests at uRate, the original pre-crossing rate. A zero
+    // rate (quality not representable) puts it in the directory whose index
+    // equals getBookBase(book), and BookTip scans keys strictly greater, so it
+    // could never be crossed while holding the owner's reserve. Don't place
+    // it; anything that crossed is kept, and a fully crossed offer has already
+    // returned above. Gated to preserve pre-amendment behavior.
+    if (mptV2 && uRate == 0)
+    {
+        JLOG(j_.debug()) << "Unrepresentable quality: remainder not placed";
+        if (!crossed)
+            return {tecKILLED, false};
+        return {tesSUCCESS, true};
+    }
+
     auto const sleCreator = sb.peek(keylet::account(accountID_));
     if (!sleCreator)
         return {tefINTERNAL, false};
diff --git a/src/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 433d77806a..06907ce366 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp
@@ -4,11 +4,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -140,6 +142,19 @@ LoanBrokerDelete::doApply()
 
     auto const brokerPseudoID = broker->at(sfAccount);
 
+    // Remove any credentials pinned to the broker pseudo-account before anything
+    // else. They would otherwise keep its owner directory alive and block
+    // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded,
+    // tecINCOMPLETE cleanup can be resumed by a later transaction without having
+    // already torn down the broker.
+    if (view().rules().enabled(fixCleanup3_4_0))
+    {
+        if (auto const ter = credentials::deletePseudoAccountCredentials(
+                view(), brokerPseudoID, kMaxDeletablePseudoAccountCredentials, j_);
+            !isTesSuccess(ter))
+            return ter;
+    }
+
     if (!view().dirRemove(
             keylet::ownerDir(accountID_), broker->at(sfOwnerNode), broker->key(), false))
     {
diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
index d6cda9c326..1ab4eb2ce0 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -8,7 +8,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,6 +146,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
     }
     else
     {
+        // LP V1.1: only closed-ended vaults may host a loan broker. The
+        // lending protocol relies on the closed-ended Subscription /
+        // Investment / Redemption phase structure; attaching a broker to
+        // an open-ended vault has no well-defined lifecycle. VaultCreate
+        // stays unrestricted so existing open-ended flows keep working;
+        // the constraint is enforced here, at the point where the vault
+        // is first bound to the lending protocol.
+        if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
+            getVaultKind(sleVault) != VaultKind::ClosedEnded)
+        {
+            JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault.";
+            return tecNO_PERMISSION;
+        }
+
         if (auto const ter = canAddHolding(ctx.view, asset))
             return ter;
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
index a312dba3b3..2d710ceebe 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -104,7 +104,11 @@ LoanManage::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
     if (tx.isFlag(tfLoanDefault) &&
-        !hasExpired(ctx.view, loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod)))
+        !hasExpired(
+            ctx.view,
+            loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod),
+            ctx.view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                                      : ExpiryComparison::Inclusive))
     {
         JLOG(ctx.j.warn()) << "A loan can not be defaulted before the next payment due date.";
         return tecTOO_SOON;
@@ -287,6 +291,14 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
+    bool const fixEnabled340 = view.rules().enabled(fixCleanup3_4_0);
+
+    if (fixEnabled340 && !isPaymentLate(view, loanSle))
+    {
+        JLOG(j.warn()) << "Cannot impair a loan that is not late";
+        return tecTOO_SOON;
+    }
+
     Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
 
     // The vault may be at a different scale than the loan. Reduce rounding
@@ -301,20 +313,22 @@ LoanManage::impairLoan(
     {
         // Having a loss greater than the vault's unavailable assets
         // will leave the vault in an invalid / inconsistent state.
-        JLOG(j.warn()) << "Vault unrealized loss is too large, and will "
-                          "corrupt the vault.";
+        JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
         return tecLIMIT_EXCEEDED;
     }
     view.update(vaultSle);
 
     // Update the Loan object
     loanSle->setFlag(lsfLoanImpaired);
-    auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
-    if (!hasExpired(view, loanNextDueProxy))
+
+    if (!fixEnabled340)
     {
-        // loan payment is not yet late -
-        // move the next payment due date to now
-        loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
+        if (!isPaymentLate(view, loanSle))
+        {
+            // loan payment is not yet late move the next payment due date to now
+            loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        }
     }
     view.update(loanSle);
 
@@ -351,19 +365,24 @@ LoanManage::unimpairLoan(
 
     // Update the Loan object
     loanSle->clearFlag(lsfLoanImpaired);
-    auto const paymentInterval = loanSle->at(sfPaymentInterval);
-    auto const normalPaymentDueDate =
-        std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval;
-    if (!hasExpired(view, normalPaymentDueDate))
+    if (!view.rules().enabled(fixCleanup3_4_0))
     {
-        // loan was unimpaired within the payment interval
-        loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
-    }
-    else
-    {
-        // loan was unimpaired after the original payment due date
-        loanSle->at(sfNextPaymentDueDate) =
-            view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        auto const paymentInterval = loanSle->at(sfPaymentInterval);
+        auto const normalPaymentDueDate =
+            std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) +
+            paymentInterval;
+
+        if (!hasExpired(view, normalPaymentDueDate))
+        {
+            // loan was unimpaired within the payment interval
+            loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
+        }
+        else
+        {
+            // loan was unimpaired after the original payment due date
+            loanSle->at(sfNextPaymentDueDate) =
+                view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        }
     }
     view.update(loanSle);
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index c5bfd8e9ee..18886b2682 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -134,10 +133,13 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
         return normalCost;
     }
 
-    if (hasExpired(view, loanSle->at(sfNextPaymentDueDate)))
+    if (isPaymentLate(view, loanSle))
     {
         // If the payment is late, and the late payment flag is not set, it'll
-        // fail
+        // fail. Uses isPaymentLate() so the fee matches apply at the exact
+        // NextPaymentDueDate boundary (Exclusive once fixCleanup3_4_0 is
+        // enabled): a catch-up at that instant can still process up to
+        // kLoanMaximumPaymentsPerTransaction payments.
         return normalCost;
     }
 
@@ -620,7 +622,12 @@ LoanPay::doApply()
         ? STAmount{asset, 0}
         : conservationBalance(view, brokerPayee, asset, j_);
 
-    if (totalPaidToVaultRounded != beast::kZero)
+    // Only ledgers without the rule below reach these payee checks. Once it is in force
+    // requireAuth can no longer reject a pseudo-account, so the whole block goes away with the
+    // gate.
+    bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0);
+
+    if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero)
     {
         if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
             return ter;
@@ -644,8 +651,11 @@ LoanPay::doApply()
                 return ter;
             }
         }
-        if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
-            return ter;
+        if (!skipPayeeAuth)
+        {
+            if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
+                return ter;
+        }
     }
 
     if (auto const ter = accountSendMulti(
diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
index c19b8f64d7..60b5c6d3af 100644
--- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
+++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp
@@ -150,7 +150,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
     // always authorized. No need to amendment gate since Vault and LoanBroker
     // can only be created if the Vault amendment is enabled; AMM with MPToken asset
     // can only be created if MPTokensV2 is enabled.
-    if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
+    if (isPseudoAccount(ctx.view, *holderID))
         return tecNO_PERMISSION;
 
     return tesSUCCESS;
diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index d0eeaed071..059da7cc0f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -95,6 +96,17 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // A pseudo-account holds no vault shares, so a clawback naming one is a no-op: the vault's own
+    // pseudo-account issues the shares, and no flow hands them to another one.
+    // Pre-fixCleanup3_4_0: an implicit amount ends in tecPRECISION_LOSS, an explicit one debits the
+    // vault and trips the "shares must move" invariant.
+    // Post-fixCleanup3_4_0: refused here.
+    if (ctx.view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(ctx.view, holder))
+    {
+        JLOG(ctx.j.debug()) << "VaultClawback: holder is a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     Asset const share = MPTIssue{mptIssuanceID};
 
     // Ambiguous case: If Issuer is Owner they must specify the asset
@@ -225,6 +237,7 @@ VaultClawback::assetsToClawback(
     AccountID const& holder,
     STAmount const& clawbackAmount)
 {
+    bool const fix340Enabled = ctx_.view().rules().enabled(fixCleanup3_4_0);
     if (clawbackAmount.asset() != vault->at(sfAsset))
     {
         // preclaim should have blocked this , now it's an internal error
@@ -256,14 +269,35 @@ VaultClawback::assetsToClawback(
     STAmount sharesDestroyed;
     STAmount assetsRecovered;
 
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
+        // Do not discount a sole holder's shares: clawing back AssetsAvailable
+        // at the discounted rate can burn every share while loan assets remain.
+        auto const waiveUnrealizedLoss =
+            fix340Enabled && isSoleShareholder(view(), holder, sleShareIssuance)
+            ? WaiveUnrealizedLoss::Yes
+            : WaiveUnrealizedLoss::No;
+
         if (clawbackAmount == beast::kZero)
         {
-            sharesDestroyed = accountHolds(
-                view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            // Zero amount means clawback all shares the holder has; derive the corresponding asset
+            // amount from the share balance.
+            // isSoleShareholder already established that the holder owns the
+            // entire outstanding share supply whenever the waiver applies, so
+            // sfOutstandingAmount gives sharesDestroyed directly, avoiding a
+            // redundant MPToken read via accountHolds.
+            sharesDestroyed = waiveUnrealizedLoss == WaiveUnrealizedLoss::Yes
+                ? STAmount{share, sleShareIssuance->at(sfOutstandingAmount)}
+                : accountHolds(
+                      view(),
+                      holder,
+                      share,
+                      FreezeHandling::IgnoreFreeze,
+                      AuthHandling::IgnoreAuth,
+                      j_);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
 
@@ -271,38 +305,48 @@ VaultClawback::assetsToClawback(
         }
         else
         {
-            auto const maybeShares =
-                assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
+            // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
+            // round-trip back to assets could exceed clawbackAmount.
+            // Post-amendment: truncate shares so assetsRecovered <=
+            // clawbackAmount by construction (matches the clamp branch
+            // below).
+            auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No;
+            auto const maybeShares = assetsToSharesWithdraw(
+                vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
             if (!maybeShares)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             sharesDestroyed = *maybeShares;
 
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
         }
-        // Clamp to maximum.
+        // Clamp assetsRecovered to sfAssetsAvailable, then re-derive shares and assets so the pair
+        // stays consistent.
         if (assetsRecovered > *assetsAvailable)
         {
             assetsRecovered = *assetsAvailable;
-            // Note, it is important to truncate the number of shares,
-            // otherwise the corresponding assets might breach the
-            // AssetsAvailable
             {
                 auto const maybeShares = assetsToSharesWithdraw(
-                    vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
+                    vault,
+                    sleShareIssuance,
+                    assetsRecovered,
+                    TruncateShares::Yes,
+                    waiveUnrealizedLoss);
                 if (!maybeShares)
                     return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
                 sharesDestroyed = *maybeShares;
             }
 
-            auto const maybeAssets =
-                sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
+            auto const maybeAssets = sharesToAssetsWithdraw(
+                vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
             if (!maybeAssets)
                 return std::unexpected(tecINTERNAL);  // LCOV_EXCL_LINE
             assetsRecovered = *maybeAssets;
+            // Truncation should guarantee the invariant holds. If it does not, a conversion
+            // helper is broken; refuse rather than over-recover.
             if (assetsRecovered > *assetsAvailable)
             {
                 // LCOV_EXCL_START
@@ -311,6 +355,18 @@ VaultClawback::assetsToClawback(
                 // LCOV_EXCL_STOP
             }
         }
+
+        // Post-fixCleanup3_4_0: round the recovery down at the posterior sfAssetsTotal scale so all
+        // rails change by the same representable delta. sharesDestroyed is intentionally NOT
+        // re-derived here: the holder's shares are burned for their pre-clamp value, so any
+        // sub-ULP trimmed off stays in the vault for the remaining shareholders.
+        if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero)
+        {
+            auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsRecovered);
+            if (!maybeClamped)
+                return std::unexpected(maybeClamped.error());
+            assetsRecovered = *maybeClamped;
+        }
     }
     catch (std::overflow_error const&)
     {
@@ -322,6 +378,8 @@ VaultClawback::assetsToClawback(
             << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
             << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount)
             << ", amount=" << clawbackAmount.value();
+        // Overflow means this transaction cannot apply, but ledger state is still consistent.
+        // Return tecPATH_DRY rather than a hard internal error.
         return std::unexpected(tecPATH_DRY);
     }
 
@@ -353,11 +411,6 @@ VaultClawback::doApply()
     auto assetsAvailable = vault->at(sfAssetsAvailable);
     auto assetsTotal = vault->at(sfAssetsTotal);
 
-    [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
-    XRPL_ASSERT(
-        lossUnrealized <= (assetsTotal - assetsAvailable),
-        "xrpl::VaultClawback::doApply : loss and assets do balance");
-
     AccountID const holder = tx[sfHolder];
     STAmount sharesDestroyed = {share};
     STAmount assetsRecovered = {vault->at(sfAsset)};
@@ -380,21 +433,44 @@ VaultClawback::doApply()
         sharesDestroyed = clawbackParts->second;
     }
 
+    // The holder has no shares (or the recovery clamped to zero). Nothing to burn; refuse rather
+    // than modifying vault state.
     if (sharesDestroyed == beast::kZero)
         return tecPRECISION_LOSS;
 
-    // A recovered amount can be genuinely non-zero yet still be dust relative to a
-    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's significant-digit
-    // precision: subtracting it below rounds the stored total right back to where it started.
-    // The shares still move, so ValidVault would fail after the fact with "clawback must
-    // decrease vault balance" instead of a clean upfront rejection.
-    if (view().rules().enabled(fixCleanup3_4_0) &&
-        (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) ||
-         debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered)))
+    // Number arithmetic can throw overflow_error when Scale and totals are large.
+    if (view().rules().enabled(fixCleanup3_4_0))
     {
-        JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault"
-                            " balance";
-        return tecPRECISION_LOSS;
+        try
+        {
+            // A non-zero recovery can be too small to change the stored sfAssetsTotal at
+            // STAmount's precision. Shares would still be burned, reject it instead.
+            if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered))
+            {
+                // LCOV_EXCL_START
+                JLOG(j_.debug())
+                    << "VaultClawback: clawback amount too small to change stored vault"
+                       " balance";
+                return tecPRECISION_LOSS;
+                // LCOV_EXCL_STOP
+            }
+        }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultClawback: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still
+            // consistent. Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
     }
 
     assetsTotal -= assetsRecovered;
diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
index 497a2f2465..35bf80c29f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +34,7 @@ VaultDelete::preflight(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
         return temDISABLED;
 
+    // The sfMemoData field is an optional field used to record the deletion reason.
     if (!validDataLength(ctx.tx[~sfMemoData], kMaxDataPayloadLength))
         return temMALFORMED;
 
@@ -100,6 +102,19 @@ VaultDelete::doApply()
     if (!vault)
         return tefINTERNAL;  // LCOV_EXCL_LINE
 
+    // Remove any credentials pinned to the vault pseudo-account before anything
+    // else. They would otherwise keep its owner directory alive and block
+    // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded,
+    // tecINCOMPLETE cleanup can be resumed by a later transaction without having
+    // already torn down the vault.
+    if (view().rules().enabled(fixCleanup3_4_0))
+    {
+        if (auto const ter = credentials::deletePseudoAccountCredentials(
+                view(), vault->at(sfAccount), kMaxDeletablePseudoAccountCredentials, j_);
+            !isTesSuccess(ter))
+            return ter;
+    }
+
     // Destroy the asset holding.
     auto asset = vault->at(sfAsset);
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
index a3c0a94eb5..adb8b3f8f2 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp
@@ -2,12 +2,14 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -47,6 +49,39 @@ roundToVaultScale(STAmount const& amount, SLE::const_ref vault)
     return roundToScale(amount, postScale, Number::RoundingMode::Downward);
 }
 
+// True if debiting `assets` would leave the depositor's balance where it started, so the deposit
+// would mint shares against a transfer that never happened. Asking the balance directly whether it
+// notices the debit avoids having to infer the rounding step: it has to be the stored balance that
+// answers, because that magnitude is what governs the rounding, and it is not the same as the
+// spendable amount, which also counts what the counterparty's limit allows.
+[[nodiscard]]
+static bool
+roundsToZeroForDepositor(
+    ReadView const& view,
+    AccountID const& account,
+    STAmount const& assets,
+    beast::Journal j)
+{
+    if (assets.integral())
+        return false;
+
+    auto const balance = accountHolds(
+        view,
+        account,
+        assets.asset(),
+        FreezeHandling::ZeroIfFrozen,
+        AuthHandling::ZeroIfUnauthorized,
+        j,
+        SpendableHandling::SimpleBalance);
+
+    if (balance - assets != balance)
+        return false;
+
+    JLOG(j.warn()) << "VaultDeposit: amount " << assets.getFullText()
+                   << " leaves the depositor's balance " << balance.getFullText() << " unchanged";
+    return true;
+}
+
 NotTEC
 VaultDeposit::preflight(PreflightContext const& ctx)
 {
@@ -139,26 +174,13 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
             return tecLOCKED;
     }
 
+    // The vault owner is authorized to deposit unconditionally. An expired
+    // credential is tolerated here because doApply deletes it.
     if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner))
     {
-        auto const maybeDomainID = sleIssuance->at(~sfDomainID);
-        // Since this is a private vault and the account is not its owner, we
-        // perform authorization check based on DomainID read from sleIssuance.
-        // Had the vault shares been a regular MPToken, we would allow
-        // authorization granted by the Issuer explicitly, but Vault uses Issuer
-        // pseudo-account, which cannot grant an authorization.
-        if (maybeDomainID)
-        {
-            // As per validDomain documentation, we suppress tecEXPIRED error
-            // here, so we can delete any expired credentials inside doApply.
-            if (auto const err = credentials::validDomain(ctx.view, *maybeDomainID, account);
-                !isTesSuccess(err) && err != tecEXPIRED)
-                return err;
-        }
-        else
-        {
-            return tecNO_AUTH;
-        }
+        if (auto const err = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::Yes);
+            !isTesSuccess(err))
+            return err;
     }
 
     // Source MPToken must exist (if asset is an MPT)
@@ -208,6 +230,7 @@ TER
 VaultDeposit::doApply()
 {
     bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
+    bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     auto applyViewContext = ctx_.getApplyViewContext();
     if (!vault)
@@ -284,6 +307,8 @@ VaultDeposit::doApply()
     }
 
     STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
+
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
         // Compute exchange before transferring any amounts.
@@ -293,14 +318,20 @@ VaultDeposit::doApply()
                 return tecINTERNAL;  // LCOV_EXCL_LINE
             sharesCreated = *maybeShares;
         }
+
         if (sharesCreated == beast::kZero)
             return tecPRECISION_LOSS;
 
+        // Convert shares back to assets so the depositor is debited for the amount actually minted.
+        // The truncated share count is worth <= amount; without this the difference would be
+        // credited to the vault for free.
         auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
         if (!maybeAssets)
         {
             return tecINTERNAL;  // LCOV_EXCL_LINE
         }
+        // The round-trip must never return more than the original amount. If it does, a conversion
+        // helper is broken. Reject rather than overcharge the depositor.
         if (*maybeAssets > amount)
         {
             // LCOV_EXCL_START
@@ -309,6 +340,50 @@ VaultDeposit::doApply()
             // LCOV_EXCL_STOP
         }
         assetsDeposited = *maybeAssets;
+
+        // Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting
+        // fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same
+        // representable delta.
+        if (fix340Enabled)
+        {
+            // Round down at the posterior sfAssetsTotal scale so the vault is credited by no more
+            // than the depositor paid.
+            auto const maybeClamped = clampToAssetsTotalScale(vault, assetsDeposited);
+            if (!maybeClamped)
+                return maybeClamped.error();
+            assetsDeposited = *maybeClamped;
+
+            // The pre-clamp share count would over-issue by the trimmed ULP and give the depositor
+            // more value than they credited.
+            auto const maybeReShares = assetsToSharesDeposit(vault, sleIssuance, assetsDeposited);
+            if (!maybeReShares)
+                return tecINTERNAL;  // LCOV_EXCL_LINE
+
+            sharesCreated = *maybeReShares;
+
+            if (sharesCreated == beast::kZero)
+                return tecPRECISION_LOSS;
+
+            // The re-derived share count would over-issue if it round-trips back to more assets
+            // than the clamped amount actually paid. Unreachable unless a conversion helper is
+            // broken.
+            // LCOV_EXCL_START
+            auto const maybeReAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
+            if (!maybeReAssets)
+                return tecINTERNAL;
+            if (*maybeReAssets > assetsDeposited)
+            {
+                JLOG(j_.error()) << "VaultDeposit: would take more than offered.";
+                return tecINTERNAL;
+            }
+            // LCOV_EXCL_STOP
+
+            // The actual deposit amount is truncated to whole shares, converted back to assets,
+            // and clamped to the sfAssetsTotal scale (post-fixCleanup3_4_0). Check the depositor's
+            // balance here—after clamping—before making any state changes.
+            if (roundsToZeroForDepositor(view(), accountID_, assetsDeposited, j_))
+                return tecPRECISION_LOSS;
+        }
     }
     catch (std::overflow_error const&)
     {
diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
index 7e32e720d6..697612af3e 100644
--- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
@@ -1,11 +1,14 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -27,6 +30,13 @@
 
 namespace xrpl {
 
+bool
+VaultWithdraw::checkExtraFeatures(PreflightContext const& ctx)
+{
+    return !ctx.tx.isFieldPresent(sfCredentialIDs) ||
+        (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0));
+}
+
 static WaiveUnrealizedLoss
 shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
 {
@@ -59,6 +69,9 @@ VaultWithdraw::preflight(PreflightContext const& ctx)
         }
     }
 
+    if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err))
+        return err;
+
     return tesSUCCESS;
 }
 
@@ -68,6 +81,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     auto const fix313Enabled = ctx.view.rules().enabled(fixCleanup3_1_3);
     auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0);
     auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0);
+    auto const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0);
 
     auto const vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID]));
     if (!vault)
@@ -113,6 +127,23 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // Validate credentials (if any) before canWithdraw, since canWithdraw may
+    // call credentials::authorizedDepositPreauth which assumes credentials
+    // already exist.
+    if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err))
+        return err;
+
+    // A pseudo-account belongs to a ledger object rather than to a person and
+    // must never receive funds from a user-initiated transaction. Deposit
+    // authorization, which every pseudo-account carries, already refuses the
+    // payout, but it reports only that the destination declines deposits and
+    // leaves the real reason unsaid.
+    if (fix340Enabled && isPseudoAccount(ctx.view, dstAcct))
+    {
+        JLOG(ctx.j.debug()) << "VaultWithdraw: cannot withdraw into a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     if (fix313Enabled && amount.asset() == vaultShare)
     {
         // Post-fixCleanup3_1_3: if the user specified shares, convert
@@ -144,7 +175,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
                     account,
                     dstAcct,
                     *maybeAssets,
-                    ctx.tx.isFieldPresent(sfDestinationTag)))
+                    ctx.tx.isFieldPresent(sfDestinationTag),
+                    ctx.tx[~sfCredentialIDs]))
                 return ret;
         }
         catch (std::overflow_error const&)
@@ -173,6 +205,39 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
     if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter))
         return ter;
 
+    // The checks above only establish that an account may hold the asset. A
+    // private vault additionally restricts who may take part in it, so paying
+    // its asset out to a third party requires both ends of that payout to be
+    // inside the vault's permissioned domain. VaultDeposit applies the same
+    // domain check on the way in.
+    //
+    // Two cases deliberately skip the check. Withdrawing to self is never
+    // restricted: losing vault access must not strand funds already deposited.
+    // The asset issuer is always allowed to receive, which keeps the return
+    // path for frozen assets open even for a submitter who lost access.
+    if (fix340Enabled && vault->isFlag(lsfVaultPrivate) && dstAcct != account &&
+        dstAcct != vaultAsset.getIssuer())
+    {
+        auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(vaultShare));
+        if (!sleIssuance)
+        {
+            // LCOV_EXCL_START
+            JLOG(ctx.j.error()) << "VaultWithdraw: missing issuance of vault shares.";
+            return tefINTERNAL;
+            // LCOV_EXCL_STOP
+        }
+
+        // Unlike VaultDeposit we do not suppress tecEXPIRED: there is no
+        // doApply step here that would clean up the expired credential.
+        if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::No);
+            !isTesSuccess(ter))
+            return ter;
+
+        if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, dstAcct, SuppressExpired::No);
+            !isTesSuccess(ter))
+            return ter;
+    }
+
     if (fix330Enabled)
     {
         // checkWithdrawFreeze checks the underlying asset on the source
@@ -203,6 +268,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
 TER
 VaultWithdraw::doApply()
 {
+    bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0);
     auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID]));
     auto applyViewContext = ctx_.getApplyViewContext();
     if (!vault)
@@ -221,7 +287,9 @@ VaultWithdraw::doApply()
     // Note, we intentionally do not check lsfVaultPrivate flag on the Vault. If
     // you have a share in the vault, it means you were at some point authorized
     // to deposit into it, and this means you are also indefinitely authorized
-    // to withdraw from it.
+    // to withdraw it to yourself. Sending the proceeds to somebody else is a
+    // different matter, and preclaim checks such a withdrawal against the
+    // vault's permissioned domain.
 
     auto const amount = ctx_.tx[sfAmount];
     Asset const vaultAsset = vault->at(sfAsset);
@@ -234,21 +302,37 @@ VaultWithdraw::doApply()
     // We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their
     // shares but keeping future value in the vault.
     auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance);
+    // Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
     try
     {
         if (amount.asset() == vaultAsset)
         {
             // Fixed assets, variable shares.
+            //
+            // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
+            // round-trip back to assets could exceed the requested amount.
+            // That over-delivers to the depositor and can bypass the
+            // preclaim canWithdraw check on the destination, which was
+            // validated against the requested amount only.
+            // Post-amendment: truncate shares so assetsWithdrawn <=
+            // requested amount by construction. If truncation yields zero
+            // shares, the tecPRECISION_LOSS guard below fires.
+            auto const truncate =
+                view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
             {
                 auto const maybeShares = assetsToSharesWithdraw(
-                    vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
+                    vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
                 if (!maybeShares)
                     return tecINTERNAL;  // LCOV_EXCL_LINE
                 sharesRedeemed = *maybeShares;
             }
 
+            // Shares are MPT (integer). Small requested amounts truncate to zero; refuse rather
+            // than burn nothing while paying out assets.
             if (sharesRedeemed == beast::kZero)
                 return tecPRECISION_LOSS;
+            // Convert shares back to assets so the payout matches the shares actually burned, not
+            // the requested amount. The extra would otherwise be paid from the vault for free.
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
             if (!maybeAssets)
@@ -257,7 +341,8 @@ VaultWithdraw::doApply()
         }
         else if (amount.asset() == share)
         {
-            // Fixed shares, variable assets.
+            // Fixed shares, variable assets. No round-trip: the share count is exactly what the
+            // caller specified; only the payout amount is derived.
             sharesRedeemed = amount;
             auto const maybeAssets =
                 sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
@@ -280,6 +365,8 @@ VaultWithdraw::doApply()
             << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
             << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
             << ", amount=" << amount.value();
+        // Overflow means this transaction cannot apply, but ledger state is still consistent.
+        // Return tecPATH_DRY rather than a hard internal error.
         return tecPATH_DRY;
     }
 
@@ -291,16 +378,12 @@ VaultWithdraw::doApply()
     auto assetsAvailable = vault->at(sfAssetsAvailable);
     auto assetsTotal = vault->at(sfAssetsTotal);
     auto const lossUnrealized = vault->at(sfLossUnrealized);
-    XRPL_ASSERT(
-        lossUnrealized <= (assetsTotal - assetsAvailable),
-        "xrpl::VaultWithdraw::doApply : loss and assets do balance");
 
-    if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal)
+    if (fix340Enabled && !isFinalWithdrawal)
     {
-        // A withdrawal for a fixed share amount (variable assets) has no requested-asset
-        // amount to check for rounding, unlike the fixed-assets branch above: a small enough
-        // share amount can round down to an exact zero even though the vault still holds
-        // positive effective value backing outstanding shares.
+        // Fixed-shares path: a small share count can round to zero assets even though the vault has
+        // backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch
+        // above has already rejected zero via the sharesRedeemed check.
         if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
             assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
         {
@@ -308,17 +391,34 @@ VaultWithdraw::doApply()
             return tecPRECISION_LOSS;
         }
 
-        // assetsWithdrawn can also be genuinely non-zero and still too small to move
-        // sfAssetsTotal or sfAssetsAvailable once canonicalized to STAmount's precision. Either
-        // way the shares still move, so ValidVault would otherwise fail after the fact instead
-        // of a clean upfront rejection.
-        if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) ||
-            debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn))
+        // Number arithmetic can throw overflow_error when Scale and totals are large.
+        try
         {
-            JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
-                                " vault balance";
-            return tecPRECISION_LOSS;
+            // A non-zero payout can be too small to change the stored sfAssetsTotal at
+            // STAmount's precision. Shares would still be burned, reject it instead.
+            if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn))
+            {
+                JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
+                                    " vault balance";
+                return tecPRECISION_LOSS;
+            }
         }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultWithdraw: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still consistent.
+            // Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
     }
 
     // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
@@ -335,6 +435,54 @@ VaultWithdraw::doApply()
         return tecINSUFFICIENT_FUNDS;
     }
 
+    // Post-fixCleanup3_4_0: round the payout to the sfAssetsTotal scale so all three rails
+    // (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same representable delta.
+    // Skip when assetsWithdrawn is already zero: the earlier fix340 guard above deliberately
+    // permits fixed-share zero-asset withdrawals in a fully-impaired vault (where
+    // assetsTotalForWithdrawal == 0), and clamping-then-rejecting would undo that. Also skip on
+    // the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below.
+    if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero)
+    {
+        // Check availability against the unclamped amount first, so a withdrawal that is both
+        // over the vault's available balance and sub-ULP at the posterior sfAssetsTotal scale
+        // reports tecINSUFFICIENT_FUNDS rather than tecPRECISION_LOSS. The clamp below only ever
+        // shrinks assetsWithdrawn, so this check stays valid; the post-clamp check further down
+        // remains in place to catch the (now smaller) clamped value too.
+        if (*assetsAvailable < assetsWithdrawn)
+        {
+            JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
+            return tecINSUFFICIENT_FUNDS;
+        }
+
+        // Number arithmetic can throw overflow_error when Scale and totals are large.
+        try
+        {
+            // Round down at the posterior sfAssetsTotal scale so the payout never exceeds the
+            // value represented by the redeemed shares. sharesRedeemed is intentionally not
+            // re-derived: any trimmed residue stays with remaining shareholders.
+            auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn);
+            if (!maybeClamped)
+                return maybeClamped.error();  // LCOV_EXCL_LINE
+            assetsWithdrawn = *maybeClamped;
+        }
+        // LCOV_EXCL_START
+        catch (std::overflow_error const&)
+        {
+            // It's easy to hit this exception from Number with large enough Scale
+            // so we avoid spamming the log and only use debug here.
+            JLOG(j_.debug())  //
+                << "VaultWithdraw: overflow error with"
+                << " scale=" << (int)vault->at(sfScale).value()  //
+                << ", assetsTotal=" << vault->at(sfAssetsTotal).value()
+                << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
+                << ", amount=" << amount.value();
+            // Overflow means this transaction cannot apply, but ledger state is still consistent.
+            // Return tecPATH_DRY rather than a hard internal error.
+            return tecPATH_DRY;
+        }
+        // LCOV_EXCL_STOP
+    }
+
     // The vault must have enough assets on hand.
     if (*assetsAvailable < assetsWithdrawn)
     {
@@ -342,14 +490,12 @@ VaultWithdraw::doApply()
         return tecINSUFFICIENT_FUNDS;
     }
 
-    // Post-fixCleanup3_2_0 "final withdrawal" rule:
-    // a transaction that would burn every outstanding share is only permitted when the vault is in
-    // a clean state — no outstanding receivables and no unrealized loss. Otherwise the resulting
-    // (shares == 0, assetsTotal > 0) state would violate the zero-sized-vault invariant.
+    // Post-fixCleanup3_2_0: burning every outstanding share is only allowed when the vault has no
+    // unrealized loss. Otherwise the resulting (shares == 0, assetsTotal > 0) state would violate
+    // the zero-sized-vault invariant.
     //
-    // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
-    // the helper result should already equal that value, and any mismatch is a rounding artifact
-    // worth logging.
+    // The payout is set to the remaining sfAssetsAvailable. The helper result should already
+    // equal that value in a clean vault; any mismatch is a rounding artifact and is logged.
     if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
     {
         // Unreachable: a final withdrawal with lossUnrealized > 0 has
@@ -383,6 +529,8 @@ VaultWithdraw::doApply()
     }
     else
     {
+        // Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step,
+        // as required by the ValidVault invariant.
         assetsTotal -= assetsWithdrawn;
         assetsAvailable -= assetsWithdrawn;
     }
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index 90bface1fb..230d148ff9 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -13,7 +13,9 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -2713,6 +2715,67 @@ class AMMClawback_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testExactLPTokenEquality(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        if (!features[fixAMMv1_3] || !features[fixAMMClawbackRounding])
+            return;
+
+        testcase("test exact LP token equality boundary");
+
+        Env env(*this, features);
+        Account const gw{"gateway"}, alice{"alice"}, bob{"bob"};
+        env.fund(XRP(100000), gw, alice, bob);
+        env.close();
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        auto const usd = gw["USD"];
+        env.trust(usd(100000), alice);
+        env(pay(gw, alice, usd(50000)));
+        env.trust(usd(100000), bob);
+        env(pay(gw, bob, usd(40000)));
+        env.close();
+
+        // bob keeps alice from being the sole LP, otherwise the clawback
+        // first rewrites the AMM's LP balance to alice's tokens and the
+        // boundary is no longer distinguishable.
+        AMM amm(env, alice, XRP(2), usd(1));
+        amm.deposit(alice, IOUAmount{1'876123487565916, -15});
+        amm.deposit(bob, IOUAmount{1'000'000});
+
+        auto const [amountBalance, amount2Balance, lptAMMBalance] = amm.balances(usd, XRP);
+        auto const aliceLP = amm.getLPTokensBalance(alice);
+        auto const holderLPTokens = STAmount{aliceLP, amm.lptIssue()};
+        BEAST_EXPECT(lptAMMBalance > holderLPTokens);
+
+        // Clawing alice's pro-rata share lands the transactor's computed LP
+        // amount exactly on her balance.
+        auto const amount = toSTAmount(usd, Number{amountBalance} * holderLPTokens / lptAMMBalance);
+        BEAST_EXPECT(
+            toSTAmount(lptAMMBalance.asset(), lptAMMBalance * (Number{amount} / amountBalance)) ==
+            holderLPTokens);
+
+        env(amm::ammClawback(gw, alice, usd, XRP, amount));
+        env.close();
+
+        auto const aliceLPAfter = amm.getLPTokensBalance(alice);
+        if (features[fixCleanup3_4_0])
+        {
+            // Equality takes the withdraw-all path, redeeming alice's tokens
+            // exactly.
+            BEAST_EXPECT(aliceLPAfter == IOUAmount(0));
+        }
+        else
+        {
+            // The fall-through re-rounds the LP amount against the much
+            // larger pool balance, leaving alice with dust.
+            BEAST_EXPECT(aliceLPAfter != IOUAmount(0) && aliceLPAfter < aliceLP);
+        }
+    }
+
     void
     run() override
     {
@@ -2746,6 +2809,7 @@ class AMMClawback_test : public beast::unit_test::Suite
             testAssetFrozen(features);
             testSingleDepositAndClawback(features);
             testLastHolderLPTokenBalance(features);
+            testExactLPTokenEquality(features);
         }
     }
 };
diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp
index 83c848b7c4..971a540ff7 100644
--- a/src/test/app/AMMExtended_test.cpp
+++ b/src/test/app/AMMExtended_test.cpp
@@ -1303,6 +1303,78 @@ private:
         BEAST_EXPECT(expectHolding(env, bob_, USD(0)));
     }
 
+    // Same shape as testRequireAuth, except the issuer never authorizes the AMM's own trust line.
+    // An AMM holds the asset for its liquidity providers and cannot sign a TrustSet for itself, so
+    // once pseudo-accounts are implicitly authorized the pool keeps trading. Before that the offer
+    // stream drops it and the taker's offer stays on the book.
+    void
+    testPseudoAccountRequireAuth(FeatureBitset features)
+    {
+        testcase("lsfRequireAuth, unauthorized AMM pseudo-account");
+
+        using namespace jtx;
+
+        bool const pseudoExempt = features[fixCleanup3_4_0];
+
+        Env env{*this, features};
+
+        auto const aliceUSD = alice_["USD"];
+        auto const bobUSD = bob_["USD"];
+
+        env.fund(XRP(400'000), gw_, alice_, bob_);
+        env.close();
+
+        env(fset(gw_, asfRequireAuth));
+        env.close();
+
+        env(trust(gw_, bobUSD(100)), Txflags(tfSetfAuth));
+        env(trust(bob_, USD(100)));
+        env(trust(gw_, aliceUSD(100)), Txflags(tfSetfAuth));
+        env(trust(alice_, USD(2'000)));
+        env(pay(gw_, alice_, USD(1'000)));
+        env.close();
+
+        AMM const ammAlice(env, alice_, USD(1'000), XRP(1'050));
+
+        // The pool's own line stays unauthorized: AMMCreate opens it without the flag, and the
+        // pseudo-account has no key to ask for one.
+        auto const ammLineAuthorized = [&]() -> bool {
+            auto const line =
+                env.le(keylet::trustLine(ammAlice.ammAccount(), USD.issue().account, USD.currency));
+            if (!BEAST_EXPECT(line))
+                return false;
+            return line->isFlag(
+                ammAlice.ammAccount() > USD.issue().account ? lsfLowAuth : lsfHighAuth);
+        };
+        BEAST_EXPECT(!ammLineAuthorized());
+
+        env(pay(gw_, bob_, USD(50)));
+        env.close();
+        BEAST_EXPECT(expectHolding(env, bob_, USD(50)));
+
+        // Bob sells USD into the pool, so the pool is the side that has to be authorized to hold
+        // the asset.
+        env(offer(bob_, XRP(50), USD(50)));
+        env.close();
+
+        if (pseudoExempt)
+        {
+            BEAST_EXPECT(ammAlice.expectBalances(USD(1'050), XRP(1'000), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, bob_, 0));
+            BEAST_EXPECT(expectHolding(env, bob_, USD(0)));
+        }
+        else
+        {
+            // The pool is skipped, so nothing crosses and the offer rests on the book.
+            BEAST_EXPECT(ammAlice.expectBalances(USD(1'000), XRP(1'050), ammAlice.tokens()));
+            BEAST_EXPECT(expectOffers(env, bob_, 1));
+            BEAST_EXPECT(expectHolding(env, bob_, USD(50)));
+        }
+
+        // Either way the exemption skips the check rather than setting the flag.
+        BEAST_EXPECT(!ammLineAuthorized());
+    }
+
     void
     testMissingAuth(FeatureBitset features)
     {
@@ -1400,6 +1472,8 @@ private:
         testDirectToDirectPath(all_);
         testDirectToDirectPath(all_ - fixAMMv1_1 - fixAMMv1_3);
         testRequireAuth(all_);
+        testPseudoAccountRequireAuth(all_);
+        testPseudoAccountRequireAuth(all_ - fixCleanup3_4_0);
         testMissingAuth(all_);
     }
 
diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp
index bfd2d529b5..37e0ed585d 100644
--- a/src/test/app/AMMMPT_test.cpp
+++ b/src/test/app/AMMMPT_test.cpp
@@ -15,6 +15,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -3318,6 +3319,65 @@ private:
         }
     }
 
+    void
+    testWithdrawReserveUsesLiveBalance()
+    {
+        testcase("Withdraw reserve check uses live balance");
+
+        using namespace jtx;
+
+        auto const test = [&](auto&& makeToken) {
+            Env env(*this);
+            env.fund(XRP(30'000), gw_, alice_, bob_);
+            env.close();
+
+            auto const token = makeToken(env);
+            AMM amm(env, gw_, XRP(100), token(100));
+
+            // The EUR trustline is an unrelated owner object. The XRP-only
+            // AMM deposit adds the LP token trustline, so Alice has 2 owners.
+            env.trust(gw_["EUR"](1), alice_);
+            amm.deposit(DepositArg{.account = alice_, .asset1In = XRP(10)});
+            BEAST_EXPECT(env.ownerCount(alice_) == 2);
+            env.require(Balance(alice_, token(kNone)));
+
+            // Drain Alice to one drop below the reserve for a third owner
+            // object, accounting for the fee on the drain payment.
+            auto const reserveForToken = reserve(env, 3);
+            auto const targetBalance = reserveForToken - XRPAmount{1};
+            auto const baseFee = env.current()->fees().base;
+            auto const currentBalance = env.balance(alice_).value().xrp();
+            auto const drainAmount = currentBalance - targetBalance - baseFee;
+            BEAST_EXPECT(drainAmount > XRPAmount{0});
+            env(pay(alice_, bob_, drops(drainAmount)));
+            env.close();
+
+            // AMMWithdraw captures priorBalance before the fee, then the XRP
+            // leg raises the live sandbox balance before the token leg.
+            // XRP(2) keeps the integral MPT side positive after rounding.
+            auto const xrpOut = XRP(2);
+            auto const tokenOut = token(2);
+            auto const priorBalance = env.balance(alice_).value().xrp();
+            auto const liveBalanceAfterXrpLeg = priorBalance - baseFee + xrpOut.value().xrp();
+            BEAST_EXPECT(priorBalance < reserveForToken);
+            BEAST_EXPECT(liveBalanceAfterXrpLeg > priorBalance);
+            BEAST_EXPECT(liveBalanceAfterXrpLeg >= reserveForToken);
+
+            // The XRP leg runs first, so the missing IOU trustline or MPToken
+            // is reserved against the updated sandbox balance.
+            amm.withdraw(
+                WithdrawArg{.account = alice_, .asset1Out = xrpOut, .asset2Out = tokenOut});
+
+            // The withdrawal succeeds only if the missing token holding can be
+            // reserved from the live balance after the XRP leg.
+            BEAST_EXPECT(env.ownerCount(alice_) == 3);
+            BEAST_EXPECT(env.balance(alice_, token).value().signum() > 0);
+        };
+
+        test([&](Env&) -> PrettyAsset { return gw_["USD"]; });
+        test([&](Env& env) -> PrettyAsset { return MPTTester({.env = env, .issuer = gw_}); });
+    }
+
     void
     testInvalidFeeVote()
     {
@@ -3992,24 +4052,30 @@ private:
             [&](AMM& ammAlice, Env& env) {
                 // Bid a tiny amount
                 auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset};
+                auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0);
+                auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)};
+                auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny};
                 env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}}));
-                // Auction slot purchase price is equal to the tiny amount
-                // since the minSlotPrice is 0 with no trading fee.
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny}));
-                // The purchase price is too small to affect the total tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}}
+                               : ammAlice.tokens()));
                 // Bid the tiny amount
                 env(ammAlice.bid({
                     .account = alice_,
                     .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset},
                 }));
                 // Pay slightly higher price
-                BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}}));
-                // The purchase price is still too small to affect the total
-                // tokens
+                BEAST_EXPECT(ammAlice.expectAuctionSlot(
+                    0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}}));
                 BEAST_EXPECT(ammAlice.expectBalances(
-                    MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens()));
+                    MPT(ammAlice[0])(10'000'000'000),
+                    USD(10'000),
+                    cleanup340
+                        ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}}
+                        : ammAlice.tokens()));
             },
             {{gAmmmpt(10'000'000'000), USD(10'000)}});
 
@@ -7485,10 +7551,12 @@ private:
         testDeposit();
         testInvalidWithdraw();
         testWithdraw();
+        testWithdrawReserveUsesLiveBalance();
         testInvalidFeeVote();
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testClawback();
         testClawbackFromAMMAccount(all);
         testClawbackFromAMMAccount(all - featureSingleAssetVault);
diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp
index e1732aaf0e..a1d5260606 100644
--- a/src/test/app/AMM_test.cpp
+++ b/src/test/app/AMM_test.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -3127,27 +3128,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,
@@ -5160,6 +5193,51 @@ private:
             {features});
     }
 
+    void
+    testCredentialPinsPseudoAccount()
+    {
+        testcase("Credential pins AMM pseudo-account");
+
+        using namespace jtx;
+        FeatureBitset const all{testableAmendments()};
+
+        // A credential issued to an AMM pseudo-account can't be accepted or
+        // deleted by it. A pin created before the cure activates stays pinned
+        // in the pseudo-account's owner directory and makes AMM deletion fail
+        // with tecINTERNAL (deleteAMMTrustLines rejects the unexpected
+        // directory entry).
+        Account const attacker{"attacker"};
+        char const credType[] = "FN36";
+
+        Env env(*this, all - fixCleanup3_3_0 - fixCleanup3_4_0);
+        fund(env, gw_, {alice_}, XRP(20'000), {USD(10'000)});
+        env.fund(XRP(1'000), attacker);
+        env.close();
+
+        AMM amm(env, alice_, XRP(10'000), USD(10'000));
+        Account const ammAcct{"amm pseudo-account", amm.ammAccount()};
+        env.memoize(ammAcct);
+
+        env(credentials::create(ammAcct, attacker, credType));
+        env.close();
+        auto const credKey = credentials::keylet(ammAcct, attacker, credType);
+        BEAST_EXPECT(env.le(credKey));
+
+        // Emptying the AMM would auto-delete it, but the pinned credential makes
+        // deleteAMMAccount fail; the withdraw is rolled back and the AMM stays.
+        amm.withdrawAll(alice_, std::nullopt, Ter(tecINTERNAL));
+        BEAST_EXPECT(amm.ammExists());
+
+        env.enableFeature(fixCleanup3_4_0);
+        env.close();
+
+        // The pre-existing pin is cleaned up and the AMM deletes.
+        amm.withdrawAll(alice_);
+        BEAST_EXPECT(!amm.ammExists());
+        BEAST_EXPECT(!env.le(credKey));
+        BEAST_EXPECT(!env.le(keylet::ownerDir(amm.ammAccount())));
+    }
+
     void
     testAutoDelete()
     {
@@ -7427,6 +7505,7 @@ private:
         FeatureBitset const all{testableAmendments()};
         testInvalidInstance();
         testInstanceCreate();
+        testCredentialPinsPseudoAccount();
         for (auto const& f : amendmentCombinations({fixCleanup3_3_0, featureAMMClawback}))
             testInvalidDeposit(f);
         testDeposit();
@@ -7436,6 +7515,7 @@ private:
         testFeeVote();
         testInvalidBid();
         testBid(all);
+        testBid(all - fixCleanup3_4_0);
         testBid(all - fixAMMv1_3);
         testBid(all - fixAMMv1_1 - fixAMMv1_3);
         testInvalidAMMPayment();
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index c332b26a5b..7e6ecfb8ca 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -3169,7 +3169,12 @@ class Batch_test : public beast::unit_test::Suite
         auto const debtMaximumValue = asset(25'000).value();
         auto const coverDepositValue = asset(1000).value();
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a subscription
+        // window that lets the lender deposit now, then advance the clock
+        // past SubscriptionDate before creating loans.
+        auto [tx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = asset});
         env(tx);
         env.close();
         BEAST_EXPECT(env.le(vaultKeylet));
@@ -3177,6 +3182,9 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
+        // Move into the Investment phase before creating loans.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp
index 1166816115..3ff90c2a8f 100644
--- a/src/test/app/Delegate_test.cpp
+++ b/src/test/app/Delegate_test.cpp
@@ -47,6 +47,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -2718,19 +2719,24 @@ class Delegate_test : public beast::unit_test::Suite
 
         std::size_t delegableCount = 0;
 
+#pragma push_macro("UNWRAP")
+#undef UNWRAP
 #pragma push_macro("TRANSACTION")
 #undef TRANSACTION
 
-#define TRANSACTION(tag, value, name, txDelegable, ...) \
-    if (txDelegable == xrpl::Delegable)                 \
-    {                                                   \
-        delegableCount++;                               \
+#define UNWRAP(...) __VA_ARGS__
+#define TRANSACTION(tag, value, name, settings, ...)                                 \
+    if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \
+    {                                                                                \
+        delegableCount++;                                                            \
     }
 
 #include 
 
 #undef TRANSACTION
 #pragma pop_macro("TRANSACTION")
+#undef UNWRAP
+#pragma pop_macro("UNWRAP")
 
         // ====================================================================
         // IMPORTANT NOTICE:
diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp
index 49e3f9be94..0f88814d4f 100644
--- a/src/test/app/FlowMPT_test.cpp
+++ b/src/test/app/FlowMPT_test.cpp
@@ -29,6 +29,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -638,6 +639,149 @@ struct FlowMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testMPTEndpointTransferRateOverflow(FeatureBitset features)
+    {
+        testcase("MPT Endpoint transfer rate overflow");
+
+        using namespace jtx;
+
+        Account const iouGW("iou_gateway");
+        Account const mptGW("mpt_gateway");
+        Account const alice("alice");
+        Account const bob("bob");
+
+        {
+            // Control: the same issuer-owned offer path works when the
+            // transfer-fee-adjusted input amount remains representable.
+            Env env(*this, features);
+
+            std::int64_t constexpr deliverAmount = 1'000'000'000'000'000'000LL;
+            std::int64_t constexpr offerAmount = deliverAmount + (deliverAmount / 2);
+
+            env.fund(XRP(10'000), iouGW, mptGW, alice, bob);
+            env.close();
+
+            auto const usd = iouGW["USD"];
+            env.trust(usd(offerAmount), alice);
+            env.trust(usd(offerAmount), mptGW);
+            env(pay(iouGW, alice, usd(offerAmount)));
+
+            MPTTester const mpt(
+                {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee});
+
+            env(offer(mptGW, usd(offerAmount), mpt(offerAmount)));
+
+            env(pay(alice, bob, mpt(deliverAmount)),
+                Path(~mpt),
+                Sendmax(usd(offerAmount)),
+                Txflags(tfNoRippleDirect | tfPartialPayment));
+
+            env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliverAmount)));
+            BEAST_EXPECT(!isOffer(env, mptGW, usd(offerAmount), mpt(offerAmount)));
+        }
+        {
+            // Regression: an extreme transfer-fee-adjusted MPT amount used to
+            // throw from MPTAmount::mulRatio during the endpoint reverse pass.
+            // The reverse pass now caps srcToDst at the largest amount whose
+            // transfer-fee-adjusted input is representable, so the offer limits
+            // the strand and a partial payment goes through.
+            Env env(*this, features);
+
+            std::int64_t constexpr overflowAmount = 7'000'000'000'000'000'000LL;
+            // The offer caps the input at overflowAmount, which the maximum
+            // transfer rate of 1.5 scales down to 7e18 * 2 / 3, rounded down
+            std::int64_t constexpr deliveredAmount = 4'666'666'666'666'666'666LL;
+
+            env.fund(XRP(10'000), iouGW, mptGW, alice, bob);
+            env.close();
+
+            auto const usd = iouGW["USD"];
+            env.trust(usd(overflowAmount), alice);
+            env.trust(usd(overflowAmount), mptGW);
+            env(pay(iouGW, alice, usd(overflowAmount)));
+
+            MPTTester const mpt(
+                {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee});
+
+            env(offer(mptGW, usd(overflowAmount), mpt(overflowAmount)));
+
+            env(pay(alice, bob, mpt(overflowAmount)),
+                Path(~mpt),
+                Sendmax(usd(overflowAmount)),
+                Txflags(tfNoRippleDirect | tfPartialPayment));
+
+            env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliveredAmount)));
+            BEAST_EXPECT(!isOffer(env, mptGW, usd(overflowAmount), mpt(overflowAmount)));
+        }
+    }
+
+    void
+    testMPTEndpointRipplingInputOverflow(FeatureBitset features)
+    {
+        // A payment between the holders of an MPT with a transfer fee ripples
+        // through the issuer, and the issuing step has to charge the transfer
+        // rate on the amount it receives. maxPaymentFlow() returns the issuance
+        // maximum for that step, so srcToDst * transferRate is not necessarily
+        // representable as an MPT amount. The reverse pass must cap the flow at
+        // the largest representable input instead of declaring the strand dry,
+        // otherwise a deliverable partial payment fails with tecPATH_DRY.
+        //
+        // Same defect as the case above, reached without an offer: holder ->
+        // issuer -> holder, one case per branch of the pre-fix revImp.
+        testcase("MPT Endpoint rippling input overflow");
+
+        using namespace jtx;
+
+        Account const gw("gateway");
+        Account const alice("alice");
+        Account const bob("bob");
+
+        // The maximum transfer fee gives a transfer rate of 1.5, so an input of
+        // kMaxMpTokenAmount covers at most kMaxMpTokenAmount * 2 / 3 of output.
+        std::int64_t constexpr maxRepresentable = 6'148'914'691'236'517'204LL;
+        std::int64_t constexpr aliceBalance = 1'000;
+        // The forward pass rounds the delivered amount down: 1000 / 1.5
+        std::int64_t constexpr bobBalance = 666;
+
+        auto const test =
+            [&](std::uint64_t maxAmt, std::int64_t deliver, std::string const& label) {
+                Env env(*this, features);
+                env.fund(XRP(10'000), gw, alice, bob);
+                env.close();
+
+                auto mpt = MPTTester(
+                    {.env = env,
+                     .issuer = gw,
+                     .holders = {alice, bob},
+                     .transferFee = kMaxTransferFee,
+                     .maxAmt = maxAmt});
+
+                env(pay(gw, alice, mpt(aliceBalance)));
+                env.close();
+
+                // alice asks to deliver more than the transfer rate can scale,
+                // so the issuing step caps the flow and her balance limits it
+                // further
+                env(pay(alice, bob, mpt(deliver)),
+                    Sendmax(mpt(kMaxMpTokenAmount)),
+                    Txflags(tfPartialPayment));
+                BEAST_EXPECTS(env.ter() == tesSUCCESS, label);
+                BEAST_EXPECTS(env.balance(alice, mpt) == mpt(0), label);
+                BEAST_EXPECTS(env.balance(bob, mpt) == mpt(bobBalance), label);
+                BEAST_EXPECTS(mpt.checkMPTokenOutstandingAmount(bobBalance), label);
+            };
+
+        // The requested amount is below MaximumAmount, so the reverse pass
+        // takes the non-limiting branch and overflows on the requested amount
+        test(kMaxMpTokenAmount, maxRepresentable + 1, "non-limiting");
+
+        // MaximumAmount is below the requested amount but still large enough
+        // that scaling it by the transfer rate is not representable, so the
+        // reverse pass takes the limiting branch and overflows on the maximum
+        test(maxRepresentable + 1, kMaxMpTokenAmount, "limiting");
+    }
+
     void
     testFalseDry(FeatureBitset features)
     {
@@ -2271,6 +2415,103 @@ struct FlowMPT_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testLockedMidPathHolder(FeatureBitset features)
+    {
+        // Regression: a cross-currency strand whose second book step
+        // consumes the offer of a holder that is locked on the step's
+        // in-asset (an MPT). The strand is XRP -> [book1: XRP/USD] ->
+        // USD -> [book2: USD/EUR] -> EUR, so book2 has book_.in == USD
+        // (an MPT) and its previous step is another BookStep. That is
+        // exactly the checkMPTDEX() branch that trusts the preceding
+        // BookStep and no longer re-checks isFrozen(owner, book_.in).
+        //
+        // The bypass the branch might appear to open does not exist:
+        // for MPT, isDeepFrozen() == isFrozen() (frozen MPTs can neither
+        // send nor receive), and OfferStream gates every offer through
+        // isDeepFrozen(owner, assetIn) before it can reach checkMPTDEX().
+        // So a locked mid-path holder's offer is removed by the liquidity
+        // source and the strand simply finds no liquidity at book2.
+        testcase("Locked mid-path holder behind a BookStep");
+
+        using namespace jtx;
+
+        Account const gw("gw");
+        Account const alice("alice");  // book1 (XRP/USD) offer owner
+        Account const mid("mid");      // book2 (USD/EUR) offer owner
+        Account const sam("sam");      // source
+        Account const bill("bill");    // destination
+
+        auto const test = [&](bool lock) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), gw, alice, mid, sam, bill);
+            env.close();
+
+            auto usd = MPTTester(
+                {.env = env,
+                 .issuer = gw,
+                 .holders = {alice, mid},
+                 .flags = kMptDexFlags | tfMPTCanLock,
+                 .maxAmt = 1'000});
+            auto const eur = gw["EUR"];
+
+            // alice funds book1 (sells USD for XRP); mid funds book2
+            // (sells EUR for USD, i.e. receives the mid-path USD).
+            env(pay(gw, alice, usd(100)));
+            env(trust(mid, eur(100)));
+            env(pay(gw, mid, eur(100)));
+            env(trust(bill, eur(100)));
+            env.close();
+
+            env(offer(alice, XRP(100), usd(100)));  // XRP/USD, sells USD
+            env.close();
+            env(offer(mid, usd(100), eur(100)));  // USD/EUR, sells EUR
+            env.close();
+            BEAST_EXPECT(expectOffers(env, alice, 1));
+            BEAST_EXPECT(expectOffers(env, mid, 1));
+
+            // Lock mid on USD *after* its offer is already on the book:
+            // the reviewer's "frozen holder's offer sits behind a
+            // BookStep" scenario.
+            if (lock)
+            {
+                usd.set({.holder = mid, .flags = tfMPTLock});
+                env.close();
+            }
+
+            env(pay(sam, bill, eur(100)),
+                Sendmax(XRP(100)),
+                Path(~usd, ~eur),
+                Txflags(tfNoRippleDirect),
+                // book1 (XRP/USD) still has liquidity, so the strand is
+                // not fully dry; it just cannot cross book2 once mid's
+                // offer is removed, hence PARTIAL rather than DRY.
+                Ter(lock ? TER(tecPATH_PARTIAL) : TER(tesSUCCESS)));
+            env.close();
+
+            if (lock)
+            {
+                // No liquidity reached book2: mid neither received USD
+                // nor delivered EUR, so bill received nothing.
+                BEAST_EXPECT(env.balance(bill, eur) == eur(0));
+                BEAST_EXPECT(env.balance(mid, usd) == usd(0));
+            }
+            else
+            {
+                // The strand crosses both books: mid receives the
+                // mid-path USD and bill receives EUR.
+                BEAST_EXPECT(env.balance(bill, eur) == eur(100));
+                BEAST_EXPECT(env.balance(mid, usd) == usd(100));
+                BEAST_EXPECT(env.balance(alice, usd) == usd(0));
+                BEAST_EXPECT(expectOffers(env, alice, 0));
+                BEAST_EXPECT(expectOffers(env, mid, 0));
+            }
+        };
+
+        test(false);  // baseline: unlocked strand succeeds
+        test(true);   // locked mid-path holder: strand finds no liquidity
+    }
+
     void
     testWithFeats(FeatureBitset features)
     {
@@ -2282,6 +2523,8 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testBookStep(features);
         testOfferOwnerMPTCreation(features);
         testTransferRate(features);
+        testMPTEndpointTransferRateOverflow(features);
+        testMPTEndpointRipplingInputOverflow(features);
         testSelfPayment1(features);
         testSelfPayment2(features);
         testSelfFundedXRPEndpoint(false, features);
@@ -2289,6 +2532,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
         testUnfundedOffer(features);
         testReExecuteDirectStep(features);
         testSelfPayLowQualityOffer(features);
+        testLockedMidPathHolder(features);
     }
 
     void
diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp
deleted file mode 100644
index ced2dea9bb..0000000000
--- a/src/test/app/Invariants_test.cpp
+++ /dev/null
@@ -1,6809 +0,0 @@
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-#include 
-
-namespace xrpl {
-
-// Test-only factory — not part of the public API.
-// The returned Transactor holds a raw reference to ctx; the caller must ensure
-// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp
-std::unique_ptr
-makeTransactor(ApplyContext& ctx);
-
-}  // namespace xrpl
-
-namespace xrpl::test {
-
-class Invariants_test : public beast::unit_test::Suite
-{
-    // The optional Preclose function is used to process additional transactions
-    // on the ledger after creating two accounts, but before closing it, and
-    // before the Precheck function. These should only be valid functions, and
-    // not direct manipulations. Preclose is not commonly used.
-    using Preclose = std::function<
-        bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>;
-
-    // this is common setup/method for running a failing invariant check. The
-    // precheck function is used to manipulate the ApplyContext with view
-    // changes that will cause the check to fail.
-    using Precheck = std::function<
-        bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>;
-
-    static FeatureBitset
-    defaultAmendments()
-    {
-        return xrpl::test::jtx::testableAmendments() | fixCleanup3_1_3 | fixCleanup3_2_0;
-    }
-
-    test::jtx::Env
-    makeEnv(FeatureBitset features)
-    {
-        return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled};
-    }
-
-    /**
-     * Run a specific test case to put the ledger into a state that will be
-     * detected by an invariant. Simulates the actions of a transaction that
-     * would violate an invariant.
-     *
-     * @param expect_logs One or more messages related to the failing invariant
-     *  that should be in the log output
-     * @precheck See "Precheck" above
-     * @fee If provided, the fee amount paid by the simulated transaction.
-     * @tx A mock transaction that took the actions to trigger the invariant. In
-     *  most cases, only the type matters.
-     * @ters The TER results expected on the two passes of the invariant
-     *  checker.
-     * @preclose See "Preclose" above. Note that @preclose runs *before*
-     * @precheck, but is the last parameter for historical reasons
-     * @setTxAccount optionally set to add sfAccount to tx (either A1 or A2)
-     */
-    enum class TxAccount : int { None = 0, A1, A2 };
-    void
-    doInvariantCheck(
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-        Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None,
-        std::source_location const& loc = std::source_location::current())
-    {
-        doInvariantCheck(
-            makeEnv(defaultAmendments()),
-            expectLogs,
-            precheck,
-            fee,
-            tx,
-            ters,
-            preclose,
-            setTxAccount,
-            loc);
-    }
-
-    void
-    doInvariantCheck(
-        test::jtx::Env&& env,
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-        Preclose const& preclose = {},
-        TxAccount setTxAccount = TxAccount::None,
-        std::source_location const& loc = std::source_location::current())
-    {
-        using namespace test::jtx;
-
-        Account const a1{"A1"};
-        Account const a2{"A2"};
-        env.fund(XRP(1000), a1, a2);
-        if (preclose)
-            BEAST_EXPECT(preclose(a1, a2, env));
-        env.close();
-
-        if (setTxAccount != TxAccount::None)
-            tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
-
-        doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc);
-    }
-
-    void
-    doInvariantCheck(
-        // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
-        test::jtx::Env&& env,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::vector const& expectLogs,
-        Precheck const& precheck,
-        XRPAmount fee = XRPAmount{},
-        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
-        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-        std::source_location const& loc = std::source_location::current())
-    {
-        using namespace test::jtx;
-
-        OpenView ov{*env.current()};
-        test::StreamSink sink{beast::Severity::Warning};
-        beast::Journal const jlog{sink};
-        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-
-        // Invariants normally run in the Transaction's "apply" (operator()) context, and can always
-        // access global Rules.
-        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-        BEAST_EXPECT(precheck(a1, a2, ac));
-
-        auto transactor = makeTransactor(ac);
-        if (!BEAST_EXPECT(transactor))
-            return;
-
-        // invoke check twice to cover tec and tef cases
-        if (!BEAST_EXPECT(ters.size() == 2))
-            return;
-
-        TER terActual = tesSUCCESS;
-        for (TER const& terExpect : ters)
-        {
-            terActual =
-                transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
-            expect(
-                terExpect == terActual,
-                "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
-                loc.file_name(),
-                loc.line());
-            auto const messages = sink.messages().str();
-
-            if (!isTesSuccess(terActual))
-            {
-                expect(
-                    messages.starts_with("Invariant failed:") ||
-                        messages.starts_with("Transaction caused an exception"),
-                    messages,
-                    loc.file_name(),
-                    loc.line());
-            }
-
-            // std::cerr << messages << '\n';
-            for (auto const& m : expectLogs)
-            {
-                expect(messages.contains(m), m, loc.file_name(), loc.line());
-            }
-        }
-    }
-
-    void
-    testXRPNotCreated()
-    {
-        using namespace test::jtx;
-        testcase << "XRP created";
-        doInvariantCheck(
-            {{"XRP net change was positive: 500"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // put a single account in the view and "manufacture" some XRP
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto amt = sle->getFieldAmount(sfBalance);
-                sle->setFieldAmount(sfBalance, amt + STAmount{500});
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    void
-    testAccountRootsNotRemoved()
-    {
-        using namespace test::jtx;
-        testcase << "account root removed";
-
-        // An account was deleted, but not by an AccountDelete transaction.
-        doInvariantCheck(
-            {{"an account root was deleted"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // remove an account from the view
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                ac.view().erase(sle);
-                return true;
-            });
-
-        // Successful AccountDelete transaction that didn't delete an account.
-        //
-        // Note that this is a case where a second invocation of the invariant
-        // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED.
-        // After a discussion with the team, we believe that's okay.
-        doInvariantCheck(
-            {{"account deletion succeeded without deleting an account"}},
-            [](Account const&, Account const&, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // Successful AccountDelete that deleted more than one account.
-        doInvariantCheck(
-            {{"account deletion succeeded but deleted multiple accounts"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // remove two accounts from the view
-                auto sleA1 = ac.view().peek(keylet::account(a1.id()));
-                auto sleA2 = ac.view().peek(keylet::account(a2.id()));
-                if (!sleA1 || !sleA2)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA2->at(sfBalance) = beast::kZero;
-                ac.view().erase(sleA1);
-                ac.view().erase(sleA2);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-    }
-
-    void
-    testAccountRootsDeletedClean()
-    {
-        using namespace test::jtx;
-        testcase << "account root deletion left artifact";
-
-        doInvariantCheck(
-            {{"account deletion left behind a non-zero balance"}},
-            // NOLINTNEXTLINE(readability-identifier-naming)
-            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                // A1 has a balance. Delete A1
-                auto const a1 = A1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1));
-                if (!sleA1)
-                    return false;
-                if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero))
-                    return false;
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a non-zero owner count"}},
-            // NOLINTNEXTLINE(readability-identifier-naming)
-            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                // Increment A1's owner count, then delete A1
-                auto const a1 = A1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1));
-                if (!sleA1)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sleA1->at(sfBalance) = beast::kZero;
-                BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0);
-                increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoredOwnerCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoringOwnerCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const a1Id = a1.id();
-                auto const sleA1 = ac.view().peek(keylet::account(a1Id));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setFieldU32(sfSponsoringAccountCount, 1);
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setAccountID(sfSponsor, a2.id());
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            Env{*this, FeatureBitset{featureSponsor}},
-            {{"account deletion left behind a sponsorship field"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
-                if (!sleA1)
-                    return false;
-                sleA1->at(sfBalance) = beast::kZero;
-                sleA1->setAccountID(sfSponsor, a2.id());
-
-                ac.view().erase(sleA1);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-
-        for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets)
-        {
-            if (!includeInTests)
-                continue;
-
-            using namespace std::string_literals;
-
-            doInvariantCheck(
-                {{"account deletion left behind a "s + type.cStr() + " object"}},
-                // NOLINTNEXTLINE(readability-identifier-naming)
-                [&](Account const& A1, Account const& A2, ApplyContext& ac) {
-                    // Add an object to the ledger for account A1, then delete
-                    // A1
-                    auto const a1 = A1.id();
-                    auto sleA1 = ac.view().peek(keylet::account(a1));
-                    if (!sleA1)
-                        return false;
-
-                    auto const key = std::invoke(keyletfunc, a1);
-                    auto const newSLE = std::make_shared(key);
-                    ac.view().insert(newSLE);
-                    // Clear the balance so the "account deletion left behind a
-                    // non-zero balance" check doesn't trip earlier than the
-                    // desired check.
-                    sleA1->at(sfBalance) = beast::kZero;
-                    ac.view().erase(sleA1);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
-        }
-
-        // NFT special case
-        doInvariantCheck(
-            {{"account deletion left behind a NFTokenPage object"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                // remove an account from the view
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const&, Env& env) {
-                // Preclose callback to mint the NFT which will be deleted in
-                // the Precheck callback above.
-                env(token::mint(a1));
-
-                return true;
-            });
-
-        // AMM special cases
-        AccountID ammAcctID;
-        uint256 ammKey;
-        Issue ammIssue;
-        doInvariantCheck(
-            {{"account deletion left behind a DirectoryNode object"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // Delete the AMM account without cleaning up the directory or
-                // deleting the AMM object
-                auto sle = ac.view().peek(keylet::account(ammAcctID));
-                if (!sle)
-                    return false;
-
-                BEAST_EXPECT(sle->at(~sfAMMID));
-                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
-
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                // Preclose callback to create the AMM which will be partially
-                // deleted in the Precheck callback above.
-                AMM const amm(env, a1, XRP(100), a1["USD"](50));
-                ammAcctID = amm.ammAccount();
-                ammKey = amm.ammID();
-                ammIssue = amm.lptIssue();
-                return true;
-            });
-        doInvariantCheck(
-            {{"account deletion left behind a AMM object"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // Delete all the AMM's trust lines, remove the AMM from the AMM
-                // account's directory (this deletes the directory), and delete
-                // the AMM account. Do not delete the AMM object.
-                auto sle = ac.view().peek(keylet::account(ammAcctID));
-                if (!sle)
-                    return false;
-
-                BEAST_EXPECT(sle->at(~sfAMMID));
-                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
-
-                for (auto const& trustKeylet :
-                     {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)})
-                {
-                    auto const line = ac.view().peek(trustKeylet);
-                    if (!line)
-                    {
-                        return false;
-                    }
-
-                    STAmount const lowLimit = line->at(sfLowLimit);
-                    STAmount const highLimit = line->at(sfHighLimit);
-                    BEAST_EXPECT(
-                        trustDelete(
-                            ac.view(),
-                            line,
-                            lowLimit.getIssuer(),
-                            highLimit.getIssuer(),
-                            ac.journal) == tesSUCCESS);
-                }
-
-                auto const ammSle = ac.view().peek(keylet::amm(ammKey));
-                if (!BEAST_EXPECT(ammSle))
-                    return false;
-                auto const ownerDirKeylet = keylet::ownerDir(ammAcctID);
-
-                BEAST_EXPECT(
-                    ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false));
-                BEAST_EXPECT(
-                    !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet));
-
-                // Clear the balance so the "account deletion left behind a
-                // non-zero balance" check doesn't trip earlier than the desired
-                // check.
-                sle->at(sfBalance) = beast::kZero;
-                sle->at(sfOwnerCount) = 0;
-                ac.view().erase(sle);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                // Preclose callback to create the AMM which will be partially
-                // deleted in the Precheck callback above.
-                AMM const amm(env, a1, XRP(100), a1["USD"](50));
-                ammAcctID = amm.ammAccount();
-                ammKey = amm.ammID();
-                ammIssue = amm.lptIssue();
-                return true;
-            });
-    }
-
-    void
-    testTypesMatch()
-    {
-        using namespace test::jtx;
-        testcase << "ledger entry types don't match";
-        doInvariantCheck(
-            {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // replace an entry in the table with an SLE of a different type
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto const sleNew = std::make_shared(ltTICKET, sle->key());
-                ac.rawView().rawReplace(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"invalid ledger entry type added"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // add an entry in the table with an SLE of an invalid type
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                // make a dummy escrow ledger entry, then change the type to an
-                // unsupported value so that the valid type invariant check
-                // will fail.
-                auto const sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                // We don't use ltNICKNAME directly since it's marked deprecated
-                // to prevent accidental use elsewhere.
-                sleNew->type_ = static_cast('n');
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoXRPTrustLine()
-    {
-        using namespace test::jtx;
-        testcase << "trust lines with XRP not allowed";
-        doInvariantCheck(
-            {{"an XRP trust line was created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create simple trust SLE with xrp currency
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency));
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoDeepFreezeTrustLinesWithoutFreeze()
-    {
-        using namespace test::jtx;
-        testcase << "trust lines with deep freeze flag without freeze "
-                    "not allowed";
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowDeepFreeze | lsfHighFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"a trust line with deep freeze flag without normal freeze was "
-              "created"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sleNew =
-                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
-                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
-                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
-                std::uint32_t uFlags = 0u;
-                uFlags |= lsfLowFreeze | lsfHighDeepFreeze;
-                sleNew->setFieldU32(sfFlags, uFlags);
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testTransfersNotFrozen()
-    {
-        using namespace test::jtx;
-        testcase << "transfers when frozen";
-
-        Account const g1{"G1"};
-        // Helper function to establish the trustlines
-        auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) {
-            // Preclose callback to establish trust lines with gateway
-            env.fund(XRP(1000), g1);
-
-            env.trust(g1["USD"](10000), a1);
-            env.trust(g1["USD"](10000), a2);
-            env.close();
-
-            env(pay(g1, a1, g1["USD"](1000)));
-            env(pay(g1, a2, g1["USD"](1000)));
-            env.close();
-
-            return true;
-        };
-
-        auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
-            createTrustlines(a1, a2, env);
-            env(trust(g1, a1["USD"](10000), tfSetFreeze));
-            env.close();
-
-            return true;
-        };
-
-        auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
-            a1FrozenByIssuer(a1, a2, env);
-            env(trust(g1, a1["USD"](10000), tfSetDeepFreeze));
-            env.close();
-
-            return true;
-        };
-
-        auto const changeBalances = [&](Account const& a1,
-                                        Account const& a2,
-                                        ApplyContext& ac,
-                                        int a1Balance,
-                                        int a2Balance) {
-            auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"]));
-            auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"]));
-
-            sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance));
-            sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance));
-
-            ac.view().update(sleA1);
-            ac.view().update(sleA2);
-        };
-
-        // test: imitating frozen A1 making a payment to A2.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -900, -1100);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1FrozenByIssuer);
-
-        // test: imitating deep frozen A1 making a payment to A2.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -900, -1100);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1DeepFrozenByIssuer);
-
-        // test: imitating A2 making a payment to deep frozen A1.
-        doInvariantCheck(
-            {{"Attempting to move frozen funds"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                changeBalances(a1, a2, ac, -1100, -900);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            a1DeepFrozenByIssuer);
-    }
-
-    void
-    testXRPBalanceCheck()
-    {
-        using namespace test::jtx;
-        testcase << "XRP balance checks";
-
-        doInvariantCheck(
-            {{"Cannot return non-native STAmount as XRPAmount"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // non-native balance
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                STAmount const nonNative(a2["USD"](51));
-                sle->setFieldAmount(sfBalance, nonNative);
-                ac.view().update(sle);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}},
-            [this](Account const& a1, Account const&, ApplyContext& ac) {
-                // balance exceeds genesis amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                // Use `drops(1)` to bypass a call to STAmount::canonicalize
-                // with an invalid value
-                sle->setFieldAmount(sfBalance, kInitialXrp + drops(1));
-                BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative());
-                ac.view().update(sle);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"incorrect account XRP balance"},
-             {"XRP net change of -1000000001 doesn't match fee 0"}},
-            [this](Account const& a1, Account const&, ApplyContext& ac) {
-                // balance is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                sle->setFieldAmount(sfBalance, STAmount{1, true});
-                BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative());
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    void
-    testTransactionFeeCheck()
-    {
-        using namespace test::jtx;
-        using namespace std::string_literals;
-        testcase << "Transaction fee checks";
-
-        doInvariantCheck(
-            {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{-1});
-
-        doInvariantCheck(
-            {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)},
-             {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{kInitialXrp});
-
-        doInvariantCheck(
-            {{"fee paid is 20 exceeds fee specified in transaction."},
-             {"XRP net change of 0 doesn't match fee 20"}},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{20},
-            STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }});
-    }
-
-    void
-    testNoBadOffers()
-    {
-        using namespace test::jtx;
-        testcase << "no bad offers";
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer with negative takerpays
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer with negative takergets
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleNew->setFieldAmount(sfTakerGets, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
-                // offer XRP to XRP
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
-                sleNew->setFieldAmount(sfTakerPays, XRP(10));
-                sleNew->setFieldAmount(sfTakerGets, XRP(11));
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testNoZeroEscrow()
-    {
-        using namespace test::jtx;
-        testcase << "no zero escrow";
-
-        doInvariantCheck(
-            {{"XRP net change of -1000000 doesn't match fee 0"},
-             {"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with negative amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-                sleNew->setFieldAmount(sfAmount, XRP(-1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"XRP net change was positive: 100000000000000001"},
-             {"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-large amount
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-                // Use `drops(1)` to bypass a call to STAmount::canonicalize
-                // with an invalid value
-                sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // IOU < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-little iou
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
-                STAmount const amt(usd, -1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // IOU bad currency
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with bad iou currency
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                Issue const bad{badCurrency(), AccountID(0x4985601)};
-                STAmount const amt(bad, 1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // escrow with too-little mpt
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                auto sleNew = std::make_shared(
-                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                STAmount const amt(mpt, -1);
-                sleNew->setFieldAmount(sfAmount, amt);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT LockedAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance locked is less than locked
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfLockedAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount < LockedAmount
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is less than locked
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 1);
-                sleNew->setFieldU64(sfLockedAmount, 10);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT MPTAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptoken amount is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
-                sleNew->setFieldU64(sfMPTAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT LockedAmount < 0
-        doInvariantCheck(
-            {{"escrow specifies invalid amount"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptoken locked amount is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
-                sleNew->setFieldU64(sfLockedAmount, -1);
-                ac.view().insert(sleNew);
-                return true;
-            });
-    }
-
-    void
-    testValidNewAccountRoot()
-    {
-        using namespace test::jtx;
-        testcase << "valid new account root";
-
-        doInvariantCheck(
-            {{"account root created illegally"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert a new account root created by a non-payment into
-                // the view.
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"multiple accounts created in a single transaction"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert two new account roots into the view.
-                {
-                    Account const a3{"A3"};
-                    Keylet const acctKeylet = keylet::account(a3);
-                    auto const sleA3 = std::make_shared(acctKeylet);
-                    ac.view().insert(sleA3);
-                }
-                {
-                    Account const a4{"A4"};
-                    Keylet const acctKeylet = keylet::account(a4);
-                    auto const sleA4 = std::make_shared(acctKeylet);
-                    ac.view().insert(sleA4);
-                }
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"account created with wrong starting sequence number"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                // Insert a new account root with the wrong starting sequence.
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, ac.view().seq() + 1);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created by a wrong transaction type"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"account created with wrong starting sequence number"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, ac.view().seq());
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_CREATE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created with wrong flags"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject& tx) {}});
-
-        doInvariantCheck(
-            {{"pseudo-account created with wrong flags"}},
-            [](Account const&, Account const&, ApplyContext& ac) {
-                Account const a3{"A3"};
-                Keylet const acctKeylet = keylet::account(a3);
-                auto const sleNew = std::make_shared(acctKeylet);
-                sleNew->setFieldU32(sfSequence, 0);
-                sleNew->setFieldH256(sfAMMID, uint256(1));
-                sleNew->setFieldU32(
-                    sfFlags,
-                    lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttAMM_CREATE, [](STObject& tx) {}});
-    }
-
-    void
-    testNFTokenPageInvariants()
-    {
-        using namespace test::jtx;
-        testcase << "NFTokenPage";
-
-        // lambda that returns an STArray of NFTokenIDs.
-        uint256 const firstNFTID(
-            "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000");
-        auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) {
-            SOTemplate const* nfTokenTemplate =
-                InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken);
-
-            uint256 nftID(firstNFTID);
-            STArray ret;
-            for (int i = 0; i < nftCount; ++i)
-            {
-                STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) {
-                    object.setFieldH256(sfNFTokenID, nftID);
-                });
-                ret.pushBack(std::move(newNFToken));
-                ++nftID;
-            }
-            return ret;
-        };
-
-        doInvariantCheck(
-            {{"NFT page has invalid size"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0));
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page has invalid size"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33));
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFTs on page are not sorted"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(2);
-                std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1);
-
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT contains empty URI"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(1);
-                nfTokens[0].setFieldVL(sfURI, Blob{});
-
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
-                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
-                nftPage->setFieldH256(sfNextPageMin, nftPage->key());
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT page is improperly linked"}},
-            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(1);
-                auto nftPage = std::make_shared(keylet::nftokenPage(
-                    keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-                nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"NFT found in incorrect page"}},
-            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
-                STArray nfTokens = makeNFTokenIDs(2);
-                auto nftPage = std::make_shared(keylet::nftokenPage(
-                    keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
-                nftPage->setFieldArray(sfNFTokens, nfTokens);
-
-                ac.view().insert(nftPage);
-                return true;
-            });
-    }
-
-    void
-    testAMMDeleteInvariants(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const enforceAMMDelete = features[fixCleanup3_3_0];
-        testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : "");
-
-        Env env(*this, features);
-        Account const issuer{"issuer"};
-        Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()};
-        STAmount const zeroLP{lptIssue, 0};
-        STAmount const nonZeroLP{lptIssue, 1};
-
-        auto const makeAMM = [](STAmount const& lptBalance) {
-            auto sleAMM = std::make_shared(keylet::amm(uint256(1)));
-            sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance);
-            return sleAMM;
-        };
-
-        auto const checkInvariant = [&](TxType txType,
-                                        TER result,
-                                        std::optional const& deletedLPBalance,
-                                        bool expected,
-                                        std::string const& expectedLog) {
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            ValidAMM invariant;
-
-            if (deletedLPBalance)
-                invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr);
-
-            bool const actual = invariant.finalize(
-                STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog);
-
-            BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result");
-            auto const messages = sink.messages().str();
-            auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : "";
-            if (!expectedLogWhenEnforced.empty())
-            {
-                BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced);
-            }
-            else
-            {
-                BEAST_EXPECTS(messages.empty(), messages);
-            }
-        };
-
-        checkInvariant(
-            ttPAYMENT,
-            tesSUCCESS,
-            nonZeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMM failed, unexpected AMM deletion by");
-        checkInvariant(
-            ttAMM_DELETE,
-            tesSUCCESS,
-            std::nullopt,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS");
-        checkInvariant(
-            ttAMM_DELETE,
-            tesSUCCESS,
-            nonZeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance");
-        checkInvariant(
-            ttAMM_DELETE,
-            tecINCOMPLETE,
-            zeroLP,
-            !enforceAMMDelete,
-            "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS");
-
-        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, "");
-        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, "");
-
-        checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, "");
-        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, "");
-        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, "");
-    }
-
-    static SLE::pointer
-    createPermissionedDomain(
-        ApplyContext& ac,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::uint32_t numCreds = 2,
-        std::uint32_t seq = 10)
-    {
-        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq));
-        auto sle = std::make_shared(pdKeylet);
-
-        sle->setAccountID(sfOwner, a1);
-        sle->setFieldU32(sfSequence, seq);
-
-        if (numCreds != 0u)
-        {
-            // This array is sorted naturally, but if you are going to change
-            // this behavior, don't forget to use credentials::makeSorted
-            STArray credentials(sfAcceptedCredentials, numCreds);
-            for (std::size_t n = 0; n < numCreds; ++n)
-            {
-                auto cred = STObject::makeInnerObject(sfCredential);
-                cred.setAccountID(sfIssuer, a2);
-                auto credType = "cred_type" + std::to_string(n);
-                cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                credentials.pushBack(std::move(cred));
-            }
-            sle->setFieldArray(sfAcceptedCredentials, credentials);
-        }
-
-        ac.view().insert(sle);
-        return sle;
-    };
-
-    void
-    testPermissionedDomainInvariants(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const fixEnabled = features[fixCleanup3_1_3];
-        std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED};
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-
-        testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : "");
-
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain with no rules."}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                return createPermissionedDomain(ac, a1, a2, 0).get();
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 2";
-
-        static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1;
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                return !!createPermissionedDomain(ac, a1, a2, kTooBig);
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 3";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't sorted"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
-
-                STArray credentials(sfAcceptedCredentials, 2);
-                for (std::size_t n = 0; n < 2; ++n)
-                {
-                    auto cred = STObject::makeInnerObject(sfCredential);
-                    cred.setAccountID(sfIssuer, a2);
-                    auto credType = std::string("cred_type") + std::to_string(9 - n);
-                    cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                    credentials.pushBack(std::move(cred));
-                }
-                slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                ac.view().update(slePd);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain 4";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't unique"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
-
-                STArray credentials(sfAcceptedCredentials, 2);
-                for (std::size_t n = 0; n < 2; ++n)
-                {
-                    auto cred = STObject::makeInnerObject(sfCredential);
-                    cred.setAccountID(sfIssuer, a2);
-                    cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
-                    credentials.pushBack(std::move(cred));
-                }
-                slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                ac.view().update(slePd);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 1";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain with no rules."}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD with empty rules
-                {
-                    STArray const credentials(sfAcceptedCredentials, 2);
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 2";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, kTooBig);
-
-                    for (std::size_t n = 0; n < kTooBig; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        auto credType = "cred_type2" + std::to_string(n);
-                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                        credentials.pushBack(std::move(cred));
-                    }
-
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 3";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't sorted"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, 2);
-                    for (std::size_t n = 0; n < 2; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        auto credType = std::string("cred_type2") + std::to_string(9 - n);
-                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
-                        credentials.pushBack(std::move(cred));
-                    }
-
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        testcase << "PermissionedDomain Set 4";
-        doInvariantCheck(
-            makeEnv(features),
-            {{"permissioned domain credentials aren't unique"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // create PD
-                auto slePd = createPermissionedDomain(ac, a1, a2);
-
-                // update PD
-                {
-                    STArray credentials(sfAcceptedCredentials, 2);
-                    for (std::size_t n = 0; n < 2; ++n)
-                    {
-                        auto cred = STObject::makeInnerObject(sfCredential);
-                        cred.setAccountID(sfIssuer, a2);
-                        cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
-                        credentials.pushBack(std::move(cred));
-                    }
-                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
-                    ac.view().update(slePd);
-                }
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-            fixEnabled ? failTers : badTers);
-
-        std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS};
-
-        std::vector const badMoreThan1{
-            {"transaction affected more than 1 permissioned domain entry."}};
-        std::vector const emptyV;
-        std::vector const badNoDomains{{"no domain objects affected by"}};
-        std::vector const badNotDeleted{
-            {"domain object modified, but not deleted by "}};
-        std::vector const badDeleted{{"domain object deleted by"}};
-        std::vector const badTx{
-            {"domain object(s) affected by an unauthorized transaction."}};
-
-        {
-            testcase << "PermissionedDomain set 2 domains ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badMoreThan1 : emptyV,
-                [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    createPermissionedDomain(ac, a1, a2, 2, 11);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del 2 domains";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? badMoreThan1 : emptyV,
-                [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
-                    auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2});
-                    ac.view().erase(sle1);
-                    ac.view().erase(sle2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain set 0 domains ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badNoDomains : emptyV,
-                [](Account const&, Account const&, ApplyContext&) { return true; },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? badTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del 0 domains";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                makeEnv(features),
-                a1,
-                a2,
-                fixEnabled ? badNoDomains : emptyV,
-                [](Account const&, Account const&, ApplyContext&) { return true; },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? badTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain set, delete domain";
-
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? badDeleted : emptyV,
-                [&pd1](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
-                    ac.view().erase(sle1);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain del, create domain ";
-            doInvariantCheck(
-                makeEnv(features),
-                fixEnabled ? badNotDeleted : emptyV,
-                [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
-                fixEnabled ? failTers : goodTers);
-        }
-
-        {
-            testcase << "PermissionedDomain invalid tx";
-
-            doInvariantCheck(
-                fixEnabled ? badTx : emptyV,
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    createPermissionedDomain(ac, a1, a2);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttPAYMENT, [](STObject&) {}},
-                failTers);
-        }
-    }
-
-    void
-    testValidPseudoAccounts()
-    {
-        testcase << "valid pseudo accounts";
-
-        using namespace jtx;
-
-        AccountID pseudoAccountID;
-        Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) {
-            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-            // Create vault
-            Vault const vault{env};
-            auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset});
-            env(tx);
-            env.close();
-            if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle))
-            {
-                pseudoAccountID = vSle->at(sfAccount);
-            }
-
-            return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID)));
-        };
-
-        /* Cases to check
-            "pseudo-account has 0 pseudo-account fields set"
-            "pseudo-account has 2 pseudo-account fields set"
-            "pseudo-account sequence changed"
-            "pseudo-account flags are not set"
-            "pseudo-account has a regular key"
-            "pseudo-account has a sponsorship field"
-        */
-        struct Mod
-        {
-            std::string expectedFailure;
-            std::function func;
-        };
-        auto const mods = std::to_array({
-            {
-                .expectedFailure = "pseudo-account has 0 pseudo-account fields set",
-                .func =
-                    [this](SLE::pointer& sle) {
-                        BEAST_EXPECT(sle->at(~sfVaultID));
-                        sle->at(~sfVaultID) = std::nullopt;
-                    },
-            },
-            {
-                .expectedFailure = "pseudo-account sequence changed",
-                .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; },
-            },
-            {
-                .expectedFailure = "pseudo-account flags are not set",
-                .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a regular key",
-                .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; },
-            },
-            {
-                .expectedFailure = "pseudo-account has a sponsorship field",
-                .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); },
-            },
-        });
-
-        for (auto const& mod : mods)
-        {
-            doInvariantCheck(
-                {{mod.expectedFailure}},
-                [&](Account const& a1, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
-                    if (!sle)
-                        return false;
-                    mod.func(sle);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createPseudo);
-        }
-        for (auto const pField : getPseudoAccountFields())
-        {
-            // createPseudo creates a vault, so sfVaultID will be set, and
-            // setting it again will not cause an error
-            if (pField == &sfVaultID)
-                continue;
-            doInvariantCheck(
-                {{"pseudo-account has 2 pseudo-account fields set"}},
-                [&](Account const& a1, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
-                    if (!sle)
-                        return false;
-
-                    auto const vaultID = ~sle->at(~sfVaultID);
-                    BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField));
-                    sle->setFieldH256(*pField, *vaultID);
-
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createPseudo);
-        }
-
-        // Take one of the regular accounts and set the sequence to 0, which
-        // will make it look like a pseudo-account
-        doInvariantCheck(
-            {{"pseudo-account has 0 pseudo-account fields set"},
-             {"pseudo-account sequence changed"},
-             {"pseudo-account flags are not set"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-                sle->at(sfSequence) = 0;
-                ac.view().update(sle);
-                return true;
-            });
-    }
-
-    static std::pair
-    createPermissionedDomainEnv(
-        test::jtx::Env& env,
-        test::jtx::Account const& a1,
-        test::jtx::Account const& a2,
-        std::uint32_t numCreds = 2)
-    {
-        using namespace test::jtx;
-
-        pdomain::Credentials credentials;
-
-        for (std::size_t n = 0; n < numCreds; ++n)
-        {
-            auto credType = "cred_type" + std::to_string(n);
-            credentials.push_back({.issuer = a2, .credType = credType});
-        }
-
-        std::uint32_t const seq = env.seq(a1);
-        env(pdomain::setTx(a1, credentials));
-        uint256 const key = pdomain::getNewDomain(env.meta());
-
-        // std::cout << "PD, acc: " << A1.id() << ", seq: " << seq << ", k: " <<
-        // key << std::endl;
-        return {seq, key};
-    }
-
-    void
-    testPermissionedDEX(FeatureBitset features)
-    {
-        using namespace test::jtx;
-
-        bool const fixEnabled = features[fixCleanup3_1_3];
-
-        testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : "");
-
-        doInvariantCheck(
-            makeEnv(features),
-            {{"domain doesn't exist"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10));
-                auto sleOffer = std::make_shared(offerKey);
-                sleOffer->setAccountID(sfAccount, a1);
-                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                ac.view().insert(sleOffer);
-                return true;
-            },
-            XRPAmount{},
-            STTx{
-                ttOFFER_CREATE,
-                [](STObject& tx) {
-                    tx.setFieldH256(
-                        sfDomainID,
-                        uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33"
-                                "70F3649CE134E5"});
-                    Account const a1{"A1"};
-                    tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                    tx.setFieldAmount(sfTakerGets, XRP(1));
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // missing domain ID in offer object
-        doInvariantCheck(
-            makeEnv(features),
-            {{"hybrid offer is malformed"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                auto sleOffer = std::make_shared(offerKey);
-                sleOffer->setAccountID(sfAccount, a2);
-                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                sleOffer->setFlag(lsfHybrid);
-
-                STArray bookArr;
-                bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                ac.view().insert(sleOffer);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttOFFER_CREATE, [&](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        // more than one entry in sfAdditionalBooks
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"hybrid offer is malformed"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-
-                    STArray bookArr;
-                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
-                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        // empty sfAdditionalBooks (size 0)
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                fixEnabled ? std::vector{{"hybrid offer is malformed"}}
-                           : std::vector{},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-
-                    STArray const bookArr;  // empty array, size 0
-                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED}
-                           : std::initializer_list{tesSUCCESS, tesSUCCESS});
-        }
-
-        // hybrid offer missing sfAdditionalBooks
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"hybrid offer is malformed"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFlag(lsfHybrid);
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttOFFER_CREATE, [&](STObject&) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"transaction consumed wrong domains"}},
-                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    sleOffer->setFieldH256(sfDomainID, pd1);
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttOFFER_CREATE,
-                    [&pd2, &a1](STObject& tx) {
-                        tx.setFieldH256(sfDomainID, pd2);
-                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                        tx.setFieldAmount(sfTakerGets, XRP(1));
-                    }},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-
-        {
-            Env env1(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env1.fund(XRP(1000), a1, a2);
-            env1.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
-            env1.close();
-
-            doInvariantCheck(
-                std::move(env1),
-                a1,
-                a2,
-                {{"domain transaction affected regular offers"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
-                    auto sleOffer = std::make_shared(offerKey);
-                    sleOffer->setAccountID(sfAccount, a2);
-                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-                    ac.view().insert(sleOffer);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttOFFER_CREATE,
-                    [&](STObject& tx) {
-                        Account const a1{"A1"};
-                        tx.setFieldH256(sfDomainID, pd1);
-                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                        tx.setFieldAmount(sfTakerGets, XRP(1));
-                    }},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-        }
-    }
-
-    void
-    testPermissionedDEXDeletedOfferFallback()
-    {
-        using namespace test::jtx;
-
-        testcase << "PermissionedDEX null after";
-
-        // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that
-        // domain lands in the set finalize consults. after == null is never
-        // tracked (pre-340: after-only; post-340: early return) — same result,
-        // both sides are coverage/regression that we do not fall back to before.
-        auto const check = [this](
-                               FeatureBitset features,
-                               bool const afterIsNull,
-                               bool const isDelete,
-                               bool const expectInvariantFailure) {
-            Env env(*this, features);
-
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env.fund(XRP(1000), a1, a2);
-            env.close();
-
-            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2);
-            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2);
-            env.close();
-
-            auto sleOffer =
-                std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10)));
-            sleOffer->setAccountID(sfAccount, a2);
-            sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
-            sleOffer->setFieldAmount(sfTakerGets, XRP(1));
-            sleOffer->setFieldH256(sfDomainID, pd1);
-
-            CurrentTransactionRulesGuard const rulesGuard(env.current()->rules());
-
-            ValidPermissionedDEX invariant;
-            if (afterIsNull)
-            {
-                // Defensive path: after is null. Must not fall back to before.
-                invariant.visitEntry(isDelete, sleOffer, nullptr);
-            }
-            else
-            {
-                // Normal / real-erase path: after is the offer on pd1.
-                invariant.visitEntry(isDelete, nullptr, sleOffer);
-            }
-
-            STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) {
-                              tx.setFieldH256(sfDomainID, pd2);
-                              tx.setFieldAmount(sfTakerPays, a1["USD"](10));
-                              tx.setFieldAmount(sfTakerGets, XRP(1));
-                          }};
-
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            bool const passed =
-                invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog);
-            BEAST_EXPECT(passed != expectInvariantFailure);
-            if (expectInvariantFailure)
-            {
-                BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains"));
-            }
-            else
-            {
-                BEAST_EXPECT(sink.messages().str().empty());
-            }
-        };
-
-        auto const pre = defaultAmendments() - fixCleanup3_4_0;
-        auto const post = defaultAmendments() | fixCleanup3_4_0;
-
-        // after == null: not tracked
-        check(pre, true, true, false);
-        check(post, true, true, false);
-
-        // after == offer on pd1
-        // pre-340: domainsOld_ (delete still inserted) → fail
-        check(pre, false, true, true);
-        // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail
-        check(post, false, true, false);
-        check(post, false, false, true);
-    }
-
-    void
-    testBookDirectoryExchangeRate()
-    {
-        using namespace test::jtx;
-        testcase << "book directory exchange rate";
-
-        auto const getBookRootKey = [](Account const& account, std::uint64_t quality) {
-            Book const book{xrpIssue(), account["USD"], std::nullopt};
-            return keylet::quality(keylet::book(book), quality);
-        };
-
-        // Root book-directory pages carry exchange-rate metadata that must
-        // match the quality encoded in the directory key.
-        auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) {
-            auto sleDir = std::make_shared(dir);
-            sleDir->setFieldH256(sfRootIndex, dir.key);
-            STVector256 indexes;
-            indexes.pushBack(uint256{1});
-            sleDir->setFieldV256(sfIndexes, indexes);
-            sleDir->setFieldU64(sfExchangeRate, exchangeRate);
-            return sleDir;
-        };
-
-        // Child pages do not carry quality metadata; they only point back to
-        // the root directory.
-        auto const makeChildPage = [](Keylet const& rootDir) {
-            auto sleDir = std::make_shared(keylet::page(rootDir, 1));
-            sleDir->setFieldH256(sfRootIndex, rootDir.key);
-            STVector256 indexes;
-            indexes.pushBack(uint256{2});
-            sleDir->setFieldV256(sfIndexes, indexes);
-            return sleDir;
-        };
-
-        auto const makeOfferCreateTx = [] {
-            return STTx{ttOFFER_CREATE, [](STObject& tx) {
-                            Account const account{"A1"};
-                            tx.setFieldAmount(sfTakerPays, XRP(1));
-                            tx.setFieldAmount(sfTakerGets, account["USD"](1));
-                        }};
-        };
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-
-        // Creating a root book directory with mismatched exchange-rate
-        // metadata violates the invariant.
-        doInvariantCheck(
-            {{"book directory exchange rate does not match directory quality"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const directoryQuality = STAmount::kURateOne;
-                auto const dir = getBookRootKey(a1, directoryQuality);
-                ac.view().insert(makeRootPage(dir, directoryQuality + 1));
-                return true;
-            },
-            XRPAmount{},
-            makeOfferCreateTx(),
-            failTers);
-
-        // A new child page must point to an existing root page.
-        doInvariantCheck(
-            {{"book directory root missing"}},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const directoryQuality = STAmount::kURateOne;
-                auto const rootDir = getBookRootKey(a1, directoryQuality);
-                // Insert only the child page.  It points at rootDir, but the
-                // corresponding root page is intentionally missing.
-                ac.view().insert(makeChildPage(rootDir));
-                return true;
-            },
-            XRPAmount{},
-            makeOfferCreateTx(),
-            failTers);
-
-        // Legacy bad-root tolerance:
-        // - The view contains a pre-existing root page with bad sfExchangeRate
-        //   metadata.
-        // - The simulated transaction only creates a child page pointing to
-        //   that root.
-        // - The invariant must pass because this transaction did not create
-        //   the bad root, only adding a child page.
-        {
-            Env env{*this, defaultAmendments()};
-            Account const a1{"A1"};
-            env.fund(XRP(1000), a1);
-            env.close();
-
-            OpenView view{*env.current()};
-            auto const directoryQuality = STAmount::kURateOne;
-            auto const rootDir = getBookRootKey(a1, directoryQuality);
-            view.rawInsert(makeRootPage(rootDir, directoryQuality + 1));
-
-            ValidBookDirectory invariant;
-            invariant.visitEntry(false, nullptr, makeChildPage(rootDir));
-
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            BEAST_EXPECT(
-                invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-        }
-
-        // A bad root is rejected when added, ignored when a legacy bad root is
-        // modified without changing sfRootIndex or deleted, and checked when a
-        // modified directory changes sfRootIndex.
-        {
-            Env env{*this, defaultAmendments()};
-            Account const a1{"A1"};
-            env.fund(XRP(1000), a1);
-            env.close();
-
-            OpenView view{*env.current()};
-            auto const directoryQuality = STAmount::kURateOne;
-            auto const rootDir = getBookRootKey(a1, directoryQuality);
-            auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1);
-            auto const badRoot = makeRootPage(rootDir, directoryQuality + 1);
-            view.rawInsert(badRoot);
-
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-
-            {
-                // add
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, nullptr, badRoot);
-
-                BEAST_EXPECT(
-                    !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-            {
-                // modify (without changing the sfRootIndex)
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, badRoot, badRoot);
-
-                BEAST_EXPECT(
-                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-            {
-                // modify (changing sfRootIndex to a missing root)
-                auto const childBefore = makeChildPage(rootDir);
-                auto const childAfter = std::make_shared(*childBefore, childBefore->key());
-                childAfter->setFieldH256(sfRootIndex, missingRootDir.key);
-
-                ValidBookDirectory invariant;
-                invariant.visitEntry(false, childBefore, childAfter);
-
-                test::StreamSink missingRootSink{beast::Severity::Warning};
-                beast::Journal const missingRootJlog{missingRootSink};
-                BEAST_EXPECT(!invariant.finalize(
-                    makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog));
-                BEAST_EXPECT(
-                    missingRootSink.messages().str().contains("book directory root missing"));
-            }
-            {
-                // delete
-                view.rawErase(badRoot);
-                BEAST_EXPECT(!view.exists(rootDir));
-
-                ValidBookDirectory invariant;
-                invariant.visitEntry(true, badRoot, badRoot);
-                BEAST_EXPECT(
-                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
-            }
-        }
-    }
-
-    Keylet
-    createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset)
-    {
-        using namespace jtx;
-
-        // Create vault
-        uint256 vaultID;
-        Vault const vault{env};
-        auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset});
-        env(tx);
-        BEAST_EXPECT(env.le(vKeylet));
-
-        vaultID = vKeylet.key;
-
-        // Create Loan Broker
-        using namespace loan_broker;
-
-        auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a)));
-        // Create a Loan Broker with all default values.
-        env(set(a, vaultID), Fee(kIncrement));
-
-        return loanBrokerKeylet;
-    };
-
-    void
-    testNoModifiedUnmodifiableFields()
-    {
-        testcase("no modified unmodifiable fields");
-        using namespace jtx;
-
-        // Initialize with a placeholder value because there's no default ctor
-        Keylet loanBrokerKeylet = keylet::amendments();
-        Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) {
-            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-
-            loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset);
-            return BEAST_EXPECT(env.le(loanBrokerKeylet));
-        };
-
-        {
-            auto const mods = std::to_array>({
-                [](SLE::pointer& sle) { sle->at(sfSequence) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); },
-                [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); },
-                [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); },
-                [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); },
-            });
-
-            for (auto const& mod : mods)
-            {
-                doInvariantCheck(
-                    {{"changed an unchangeable field"}},
-                    [&](Account const& a1, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(loanBrokerKeylet);
-                        if (!sle)
-                            return false;
-                        mod(sle);
-                        ac.view().update(sle);
-                        return true;
-                    },
-                    XRPAmount{},
-                    STTx{ttACCOUNT_SET, [](STObject& tx) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    createLoanBroker);
-            }
-        }
-
-        // TODO: Loan Object
-
-        // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
-        // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
-        Keylet closedEndedVaultKeylet = keylet::amendments();
-        Preclose const createClosedEndedVault = [&, this](
-                                                    Account const& a, Account const&, Env& env) {
-            auto const sub = env.now().time_since_epoch().count() + 60;
-            auto const red = sub + kMinInvestmentPeriod + 1'000'000;
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create(
-                {.owner = a,
-                 .asset = xrpIssue(),
-                 .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
-                 .subscriptionDate = sub,
-                 .redemptionDate = red});
-            env(tx);
-            closedEndedVaultKeylet = keylet;
-            return BEAST_EXPECT(env.le(closedEndedVaultKeylet));
-        };
-
-        {
-            // Each mutation must keep the vault otherwise valid so that only the immutability check
-            // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind
-            // stays within the recognised range.
-            auto const mods = std::to_array>({
-                [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; },
-            });
-
-            for (auto const& mod : mods)
-            {
-                doInvariantCheck(
-                    {{"changed an unchangeable field"}},
-                    [&](Account const&, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(closedEndedVaultKeylet);
-                        if (!sle)
-                            return false;
-                        mod(sle);
-                        ac.view().update(sle);
-                        return true;
-                    },
-                    XRPAmount{},
-                    STTx{ttACCOUNT_SET, [](STObject&) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    createClosedEndedVault);
-            }
-        }
-
-        {
-            auto const mods = std::to_array>({
-                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
-                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); },
-            });
-
-            for (auto const& mod : mods)
-            {
-                doInvariantCheck(
-                    {{"changed an unchangeable field"}},
-                    [&](Account const& a1, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(keylet::account(a1.id()));
-                        if (!sle)
-                            return false;
-                        mod(sle);
-                        ac.view().update(sle);
-                        return true;
-                    });
-            }
-        }
-    }
-
-    void
-    testValidLoanBroker()
-    {
-        testcase << "valid loan broker";
-
-        using namespace jtx;
-
-        enum class Asset { XRP, IOU, MPT };
-        auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT});
-
-        for (auto const assetType : assetTypes)
-        {
-            // Initialize with a placeholder value because there's no default
-            // ctor
-            auto const setupAsset =
-                [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset {
-                switch (assetType)
-                {
-                    case Asset::IOU: {
-                        PrettyAsset const iouAsset = issuer["IOU"];
-                        env(trust(alice, iouAsset(1000)));
-                        env(pay(issuer, alice, iouAsset(1000)));
-                        env.close();
-                        return iouAsset;
-                    }
-                    case Asset::MPT: {
-                        MPTTester mptt{env, issuer, kMptInitNoFund};
-                        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
-                        PrettyAsset const mptAsset = mptt.issuanceID();
-                        mptt.authorize({.account = alice});
-                        env(pay(issuer, alice, mptAsset(1000)));
-                        env.close();
-                        return mptAsset;
-                    }
-                    case Asset::XRP:
-                    default:
-                        return PrettyAsset{xrpIssue(), 1'000'000};
-                }
-            };
-
-            Keylet loanBrokerKeylet = keylet::amendments();
-            Preclose const createLoanBroker =
-                [&, this](Account const& alice, Account const& issuer, Env& env) {
-                    auto const asset = setupAsset(alice, issuer, env);
-                    loanBrokerKeylet = this->createLoanBroker(alice, env, asset);
-                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
-                };
-
-            // Ensure the test scenarios are set up completely. The test cases
-            // will need to recompute any of these values it needs for itself
-            // rather than trying to return a bunch of items
-            auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac)
-                -> std::optional> {
-                if (loanBrokerKeylet.type != ltLOAN_BROKER)
-                    return {};
-                auto sleBroker = ac.view().peek(loanBrokerKeylet);
-                if (!sleBroker)
-                    return {};
-                if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0))
-                    return {};
-                // Need to touch sleBroker so that it is included in the
-                // modified entries for the invariant to find
-                ac.view().update(sleBroker);
-
-                // The pseudo-account holds the directory, so get it
-                auto const pseudoAccountID = sleBroker->at(sfAccount);
-                auto const pseudoAccountKeylet = keylet::account(pseudoAccountID);
-                // Strictly speaking, we don't need to load the
-                // ACCOUNT_ROOT, but check anyway
-                auto slePseudo = ac.view().peek(pseudoAccountKeylet);
-                if (!BEAST_EXPECT(slePseudo))
-                    return {};
-                // Make sure the directory doesn't already exist
-                auto const dirKeylet = keylet::ownerDir(pseudoAccountID);
-                auto sleDir = ac.view().peek(dirKeylet);
-                auto const describe = describeOwnerDir(pseudoAccountID);
-                if (!sleDir)
-                {
-                    // Create the directory
-                    BEAST_EXPECT(
-                        ::xrpl::directory::createRoot(
-                            ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0);
-
-                    sleDir = ac.view().peek(dirKeylet);
-                }
-
-                return std::make_pair(slePseudo, sleDir);
-            };
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has multiple directory "
-                  "pages"}},
-                [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
-
-                    BEAST_EXPECT(
-                        ::xrpl::directory::insertPage(
-                            ac.view(),
-                            0,
-                            sleDir,
-                            0,
-                            sleDir,
-                            slePseudo->key(),
-                            keylet::page(sleDir->key(), 0),
-                            describe) == 1);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has multiple indexes in "
-                  "the Directory root"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto indexes = sleDir->getFieldV256(sfIndexes);
-
-                    // Put some extra garbage into the directory
-                    for (auto const& key : {slePseudo->key(), sleDir->key()})
-                    {
-                        ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
-                    }
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker directory corrupt"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
-                    // Empty vector will overwrite the existing entry for the
-                    // holding, if any, avoiding the "has multiple indexes"
-                    // failure.
-                    STVector256 indexes;
-
-                    // Put one meaningless key into the directory
-                    auto const key = keylet::account(Account("random").id()).key;
-                    ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker with zero OwnerCount has an unexpected entry in "
-                  "the directory"}},
-                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto test = setupTest(a1, a2, ac);
-                    if (!test || !test->first || !test->second)
-                        return false;
-
-                    auto slePseudo = test->first;
-                    auto sleDir = test->second;
-                    // Empty vector will overwrite the existing entry for the
-                    // holding, if any, avoiding the "has multiple indexes"
-                    // failure.
-                    STVector256 indexes;
-
-                    ::xrpl::directory::insertKey(
-                        ac.view(), sleDir, 0, false, indexes, slePseudo->key());
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            doInvariantCheck(
-                {{"Loan Broker sequence number decreased"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    if (loanBrokerKeylet.type != ltLOAN_BROKER)
-                        return false;
-                    auto sleBroker = ac.view().peek(loanBrokerKeylet);
-                    if (!sleBroker)
-                        return false;
-                    if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0))
-                        return false;
-                    // Need to touch sleBroker so that it is included in the
-                    // modified entries for the invariant to find
-                    ac.view().update(sleBroker);
-
-                    sleBroker->at(sfLoanSequence) -= 1;
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-
-            // Test: cover available less than pseudo-account asset balance
-            {
-                Keylet brokerKeylet = keylet::amendments();
-                Preclose const createBrokerWithCover =
-                    [&, this](Account const& alice, Account const& issuer, Env& env) {
-                        auto const asset = setupAsset(alice, issuer, env);
-                        brokerKeylet = this->createLoanBroker(alice, env, asset);
-                        if (!BEAST_EXPECT(env.le(brokerKeylet)))
-                            return false;
-                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
-                        env.close();
-                        return BEAST_EXPECT(env.le(brokerKeylet));
-                    };
-
-                doInvariantCheck(
-                    {{"Loan Broker cover available is less than pseudo-account asset balance"}},
-                    [&](Account const&, Account const&, ApplyContext& ac) {
-                        auto sle = ac.view().peek(brokerKeylet);
-                        if (!BEAST_EXPECT(sle))
-                            return false;
-                        // Pseudo-account holds 10 units, set cover to 5
-                        sle->at(sfCoverAvailable) = Number(5);
-                        ac.view().update(sle);
-                        return true;
-                    },
-                    XRPAmount{},
-                    STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    createBrokerWithCover);
-            }
-
-            // Test: cover available greater than pseudo-account asset balance
-            // (requires fixCleanup3_1_3)
-            doInvariantCheck(
-                {{"Loan Broker cover available is greater than pseudo-account asset balance"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(loanBrokerKeylet);
-                    if (!BEAST_EXPECT(sle))
-                        return false;
-                    // Pseudo-account has no cover deposited; set cover
-                    // higher than any incidental balance
-                    sle->at(sfCoverAvailable) = Number(1'000'000);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                createLoanBroker);
-        }
-    }
-
-    void
-    testVault()  // NOLINT(readability-function-size)
-    {
-        using namespace test::jtx;
-
-        struct AccountAmount
-        {
-            AccountID account;
-            int amount;
-        };
-        struct Adjustments
-        {
-            // NOLINTBEGIN(readability-redundant-member-init)
-            std::optional assetsTotal = std::nullopt;
-            std::optional assetsAvailable = std::nullopt;
-            std::optional lossUnrealized = std::nullopt;
-            std::optional assetsMaximum = std::nullopt;
-            std::optional sharesTotal = std::nullopt;
-            std::optional vaultAssets = std::nullopt;
-            std::optional accountAssets = std::nullopt;
-            std::optional accountShares = std::nullopt;
-            // NOLINTEND(readability-redundant-member-init)
-        };
-        constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
-            auto sleVault = ac.peek(keylet);
-            if (!sleVault)
-                return false;
-
-            auto const mptIssuanceID = (*sleVault)[sfShareMPTID];
-            auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID));
-            if (!sleShares)
-                return false;
-
-            // These two fields are adjusted in absolute terms
-            if (args.lossUnrealized)
-                (*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
-            if (args.assetsMaximum)
-                (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
-
-            // Remaining fields are adjusted in terms of difference
-            if (args.assetsTotal)
-                (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal;
-            if (args.assetsAvailable)
-            {
-                (*sleVault)[sfAssetsAvailable] =
-                    *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable;
-            }
-            ac.update(sleVault);
-
-            if (args.sharesTotal)
-            {
-                (*sleShares)[sfOutstandingAmount] =
-                    *(*sleShares)[sfOutstandingAmount] + *args.sharesTotal;
-                ac.update(sleShares);
-            }
-
-            auto const assets = *(*sleVault)[sfAsset];
-            auto const pseudoId = *(*sleVault)[sfAccount];
-            if (args.vaultAssets)
-            {
-                if (assets.native())
-                {
-                    auto slePseudoAccount = ac.peek(keylet::account(pseudoId));
-                    if (!slePseudoAccount)
-                        return false;
-                    (*slePseudoAccount)[sfBalance] =
-                        *(*slePseudoAccount)[sfBalance] + *args.vaultAssets;
-                    ac.update(slePseudoAccount);
-                }
-                else if (assets.holds())
-                {
-                    auto const mptId = assets.get().getMptID();
-                    auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId));
-                    if (!sleMPToken)
-                        return false;
-                    (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + *args.vaultAssets;
-                    ac.update(sleMPToken);
-                }
-                else
-                {
-                    return false;  // Not supporting testing with IOU
-                }
-            }
-
-            if (args.accountAssets)
-            {
-                auto const& pair = *args.accountAssets;
-                if (assets.native())
-                {
-                    auto sleAccount = ac.peek(keylet::account(pair.account));
-                    if (!sleAccount)
-                        return false;
-                    (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount;
-                    ac.update(sleAccount);
-                }
-                else if (assets.holds())
-                {
-                    auto const mptID = assets.get().getMptID();
-                    auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account));
-                    if (!sleMPToken)
-                        return false;
-                    (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount;
-                    ac.update(sleMPToken);
-                }
-                else
-                {
-                    return false;  // Not supporting testing with IOU
-                }
-            }
-
-            if (args.accountShares)
-            {
-                auto const& pair = *args.accountShares;
-                auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account));
-                if (!sleMPToken)
-                    return false;
-                (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount;
-                ac.update(sleMPToken);
-            }
-            return true;
-        };
-
-        static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments {
-            Adjustments sample = {
-                .assetsTotal = adjustment,
-                .assetsAvailable = adjustment,
-                .lossUnrealized = 0,
-                .sharesTotal = adjustment,
-                .vaultAssets = adjustment,
-                .accountAssets =  //
-                AccountAmount{.account = id, .amount = -adjustment},
-                .accountShares =  //
-                AccountAmount{.account = id, .amount = adjustment}};
-            fn(sample);
-            return sample;
-        };
-
-        Account const a3{"A3"};
-        Account const a4{"A4"};
-        auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-            env.fund(XRP(1000), a3, a4);
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-            env(tx);
-            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
-            env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
-            return true;
-        };
-
-        testcase << "Vault general checks";
-        doInvariantCheck(
-            {"vault deletion succeeded without deleting a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault updated by a wrong transaction type"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-                sleVault->setAccountID(sfAccount, a1.id());
-                ac.view().insert(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttPAYMENT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"vault deleted by a wrong transaction type",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation updated more than single vault",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                {
-                    auto const keylet =
-                        keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                    auto sleVault = ac.view().peek(keylet);
-                    if (!sleVault)
-                        return false;
-                    ac.view().erase(sleVault);
-                }
-                {
-                    auto const keylet =
-                        keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq()));
-                    auto sleVault = ac.view().peek(keylet);
-                    if (!sleVault)
-                        return false;
-                    ac.view().erase(sleVault);
-                }
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                {
-                    auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                    env(tx);
-                }
-                {
-                    auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()});
-                    env(tx);
-                }
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation updated more than single vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const insertVault = [&](Account const a) {
-                    auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence));
-                    auto sleVault = std::make_shared(vaultKeylet);
-                    auto const vaultPage = ac.view().dirInsert(
-                        keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id()));
-                    sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-                    sleVault->setAccountID(sfAccount, a.id());
-                    ac.view().insert(sleVault);
-                };
-                insertVault(a1);
-                insertVault(a2);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"deleted vault must also delete shares",
-             "deleted Vault without deleting its pseudo-account"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().erase(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"deleted vault must have no shares outstanding",
-             "deleted vault must have no assets outstanding",
-             "deleted vault must have no assets available"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().erase(sleVault);
-                ac.view().erase(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                // Note, such an "orphaned" update of MPT issuance attached to a
-                // vault is invalid; ttVAULT_SET must also update Vault object.
-                sleShares->setFieldH256(sfDomainID, uint256(13));
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without modifying a vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
-            XRPAmount{},
-            STTx{ttVAULT_DELETE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"updated vault must have shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsMaximum] = 200;
-                ac.view().update(sleVault);
-
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().erase(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault operation succeeded without updating shares",
-             "assets available must not be greater than assets outstanding"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsTotal] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-                return true;
-            });
-
-        doInvariantCheck(
-            {"set must not change assets outstanding",
-             "set must not change assets available",
-             "set must not change shares outstanding",
-             "set must not change vault balance",
-             "assets available must not be negative",
-             "assets available must not be greater than assets outstanding",
-             "assets outstanding must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount]));
-                if (!slePseudoAccount)
-                    return false;
-                (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10;
-                ac.view().update(slePseudoAccount);
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsAvailable = (kDropsPerXrp * -100).value();
-                                   sample.assetsTotal = (kDropsPerXrp * -200).value();
-                                   sample.sharesTotal = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                sleVault->setAccountID(sfAccount, a2.id());
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"violation of vault immutable data"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfShareMPTID] = MPTID(42);
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"vault transaction must not change loss unrealized",
-             "set must not change assets outstanding"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = 13;
-                                   sample.assetsTotal = 20;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"loss unrealized must not exceed the difference "
-             "between assets outstanding and available",
-             "vault transaction must not change loss unrealized"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = 13;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is
-        // allowed to change loss unrealized, so it isolates this check from the
-        // "must not change loss unrealized" invariant. Gated behind
-        // fixCleanup3_4_0 (see below).
-        doInvariantCheck(
-            {"loss unrealized must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // Without fixCleanup3_4_0 the same state must NOT trip the invariant,
-        // preserving pre-amendment behavior (no fork risk).
-        doInvariantCheck(
-            makeEnv(defaultAmendments() - fixCleanup3_4_0),
-            {},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.lossUnrealized = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
-            {tesSUCCESS, tesSUCCESS},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"set assets outstanding must not exceed assets maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = 1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"assets maximum must not be negative"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = -1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"set must not change shares outstanding",
-             "updated zero sized vault must have no assets outstanding",
-             "updated zero sized vault must have no assets available"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfOutstandingAmount] = 0;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject& tx) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"updated shares must not exceed maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfMaximumAmount] = 10;
-                ac.view().update(sleShares);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"updated shares must not exceed maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
-
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        testcase << "Vault create";
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "updated zero sized vault must have no assets outstanding",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsTotal] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "updated zero sized vault must have no assets available",
-                "assets available must not be greater than assets outstanding",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsAvailable] = 9;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "loss unrealized must not exceed the difference between assets "
-                "outstanding and available",
-                "vault transaction must not change loss unrealized",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfLossUnrealized] = 1;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "created vault must be empty",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().update(sleVault);
-                (*sleShares)[sfOutstandingAmount] = 9;
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {
-                "assets maximum must not be negative",
-                "create operation must not have updated a vault",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                (*sleVault)[sfAssetsMaximum] = Number(-1);
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"create operation must not have updated a vault",
-             "shares issuer and vault pseudo-account must be the same",
-             "shares issuer must be a pseudo-account",
-             "shares issuer pseudo-account must point back to the vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                auto sleVault = ac.view().peek(keylet);
-                if (!sleVault)
-                    return false;
-                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
-                if (!sleShares)
-                    return false;
-                ac.view().update(sleVault);
-                (*sleShares)[sfIssuer] = a1.id();
-                ac.view().update(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const& a2, Env& env) {
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                env(tx);
-                return true;
-            });
-
-        doInvariantCheck(
-            {"vault created by a wrong transaction type", "account root created illegally"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // The code below will create a valid vault with (almost) all
-                // the invariants holding. Except one: it is created by the
-                // wrong transaction type.
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
-                // Create pseudo-account.
-                auto sleAccount = std::make_shared(keylet::account(pseudoId));
-                sleAccount->setAccountID(sfAccount, pseudoId);
-                sleAccount->setFieldAmount(sfBalance, STAmount{});
-                std::uint32_t const seqno =                             //
-                    ac.view().rules().enabled(featureSingleAssetVault)  //
-                    ? 0                                                 //
-                    : sequence;
-                sleAccount->setFieldU32(sfSequence, seqno);
-                sleAccount->setFieldU32(
-                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
-                ac.view().insert(sleAccount);
-
-                auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                sleShares->at(sfIssuer) = pseudoId;
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                sleVault->at(sfAccount) = pseudoId;
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"shares issuer and vault pseudo-account must be the same",
-             "shares issuer pseudo-account must point back to the vault"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
-                // Create pseudo-account.
-                auto sleAccount = std::make_shared(keylet::account(pseudoId));
-                sleAccount->setAccountID(sfAccount, pseudoId);
-                sleAccount->setFieldAmount(sfBalance, STAmount{});
-                std::uint32_t const seqno =                             //
-                    ac.view().rules().enabled(featureSingleAssetVault)  //
-                    ? 0                                                 //
-                    : sequence;
-                sleAccount->setFieldU32(sfSequence, seqno);
-                sleAccount->setFieldU32(
-                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-                // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
-                // Setting wrong vault key
-                sleAccount->setFieldH256(sfVaultID, uint256(42));
-                ac.view().insert(sleAccount);
-
-                auto const sharesMptId = makeMptID(sequence, pseudoId);
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                sleShares->at(sfIssuer) = pseudoId;
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                // sleVault->at(sfAccount) = pseudoId;
-                // Setting wrong pseudo account ID
-                sleVault->at(sfAccount) = a2.id();
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        doInvariantCheck(
-            {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sequence = ac.view().seq();
-                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
-                auto sleVault = std::make_shared(vaultKeylet);
-                auto const vaultPage = ac.view().dirInsert(
-                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
-                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-                auto const sharesMptId = makeMptID(sequence, a2.id());
-                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-                auto sleShares = std::make_shared(sharesKeylet);
-                auto const sharesPage = ac.view().dirInsert(
-                    keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id()));
-                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-
-                sleShares->at(sfFlags) = 0;
-                // Setting wrong pseudo account ID
-                sleShares->at(sfIssuer) = AccountID(42);
-                sleShares->at(sfOutstandingAmount) = 0;
-                sleShares->at(sfSequence) = sequence;
-
-                sleVault->at(sfAccount) = a2.id();
-                sleVault->at(sfFlags) = 0;
-                sleVault->at(sfSequence) = sequence;
-                sleVault->at(sfOwner) = a1.id();
-                sleVault->at(sfAssetsTotal) = Number(0);
-                sleVault->at(sfAssetsAvailable) = Number(0);
-                sleVault->at(sfLossUnrealized) = Number(0);
-                sleVault->at(sfShareMPTID) = sharesMptId;
-                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-
-                ac.view().insert(sleVault);
-                ac.view().insert(sleShares);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        testcase << "Vault deposit";
-        doInvariantCheck(
-            {"deposit must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"deposit assets outstanding must not exceed assets maximum"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) {
-                                   sample.assetsMaximum = 1;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        // This really convoluted unit tests makes the zero balance on the
-        // depositor, by sending them the same amount as the transaction fee.
-        // The operation makes no sense, but the defensive check in
-        // ValidVault::finalize is otherwise impossible to trigger.
-        doInvariantCheck(
-            {"deposit must increase vault balance", "deposit must change depositor balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = -100;
-                               }));
-            },
-            XRPAmount{100},
-            STTx{
-                ttVAULT_DEPOSIT,
-                [&](STObject& tx) {
-                    tx[sfFee] = XRPAmount(100);
-                    tx[sfAccount] = a3.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {"deposit must increase vault balance",
-             "deposit must decrease depositor balance",
-             "deposit must change vault and depositor balance by equal amount",
-             "deposit and assets outstanding must add up",
-             "deposit and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A2 to A3 to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.vaultAssets = -20;
-                                   sample.accountAssets->amount = 10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change depositor balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A3 to vault to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change depositor shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit must increase depositor shares",
-             "deposit must change depositor and vault shares by equal amount",
-             "deposit must not change vault balance by more than deposited "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = -5;
-                                   sample.sharesTotal = -10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit and assets outstanding must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
-                ac.view().update(sleA3);
-
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = 11;
-                               }));
-            },
-            XRPAmount{2000},
-            STTx{
-                ttVAULT_DEPOSIT,
-                [&](STObject& tx) {
-                    tx[sfAmount] = XRPAmount(10);
-                    tx[sfDelegate] = a3.id();
-                    tx[sfFee] = XRPAmount(2000);
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"deposit and assets outstanding must add up",
-             "deposit and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = 7;
-                                   sample.assetsAvailable = 7;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        testcase << "Vault withdrawal";
-        doInvariantCheck(
-            {"withdrawal must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        // Almost identical to the really convoluted test for deposit, where the
-        // depositor spends only the transaction fee. In case of withdrawal,
-        // this test is almost the same as normal withdrawal where the
-        // sfDestination would have been A4, but has been omitted.
-        doInvariantCheck(
-            {"withdrawal must change one destination balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops to A4 to enforce total XRP balance
-                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
-                if (!sleA4)
-                    return false;
-                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
-                ac.view().update(sleA4);
-
-                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountAssets->amount = -100;
-                               }));
-            },
-            XRPAmount{100},
-            STTx{
-                ttVAULT_WITHDRAW,
-                [&](STObject& tx) {
-                    tx[sfFee] = XRPAmount(100);
-                    tx[sfAccount] = a3.id();
-                    // This commented out line causes the invariant violation.
-                    // tx[sfDestination] = A4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        doInvariantCheck(
-            {
-                "withdrawal must change vault and destination balance by equal amount",
-                "withdrawal must decrease vault balance",
-                "withdrawal must increase destination balance",
-                "withdrawal and assets outstanding must add up",
-                "withdrawal and assets available must add up",
-            },
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-
-                // Move 10 drops from A2 to A3 to enforce total XRP balance
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
-                ac.view().update(sleA3);
-
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.vaultAssets = 10;
-                                   sample.accountAssets->amount = -20;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change one destination balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                 *sample.vaultAssets -= 5;
-                             })))
-                    return false;
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                if (!sleA3)
-                    return false;
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5;
-                ac.view().update(sleA3);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change depositor shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal must decrease depositor shares",
-             "withdrawal must change depositor and vault shares by equal "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = 5;
-                                   sample.sharesTotal = 10;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal and assets outstanding must add up",
-             "withdrawal and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = -15;
-                                   sample.assetsAvailable = -15;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        doInvariantCheck(
-            {"withdrawal and assets outstanding must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
-                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
-                ac.view().update(sleA3);
-
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.assetsTotal = -7;
-                               }));
-            },
-            XRPAmount{2000},
-            STTx{
-                ttVAULT_WITHDRAW,
-                [&](STObject& tx) {
-                    tx[sfAmount] = XRPAmount(10);
-                    tx[sfDelegate] = a3.id();
-                    tx[sfFee] = XRPAmount(2000);
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp,
-            TxAccount::A2);
-
-        auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-            env.fund(XRP(1000), a3, a4);
-
-            // Create MPT asset
-            {
-                json::Value jv;
-                jv[sfAccount] = a3.human();
-                jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
-                jv[sfFlags] = tfMPTCanTransfer;
-                env(jv);
-                env.close();
-            }
-
-            auto const mptID = makeMptID(env.seq(a3) - 1, a3);
-            Asset const asset = MPTIssue(mptID);
-            // Authorize A1 A2 A4
-            {
-                json::Value jv;
-                jv[sfAccount] = a1.human();
-                jv[sfTransactionType] = jss::MPTokenAuthorize;
-                jv[sfMPTokenIssuanceID] = to_string(mptID);
-                env(jv);
-                jv[sfAccount] = a2.human();
-                env(jv);
-                jv[sfAccount] = a4.human();
-                env(jv);
-
-                env.close();
-            }
-            // Send tokens to A1 A2 A4
-            {
-                env(pay(a3, a1, asset(1000)));
-                env(pay(a3, a2, asset(1000)));
-                env(pay(a3, a4, asset(1000)));
-                env.close();
-            }
-
-            Vault const vault{env};
-            auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-            env(tx);
-            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)}));
-            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)}));
-            env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)}));
-            return true;
-        };
-
-        doInvariantCheck(
-            {"withdrawal must decrease depositor shares",
-             "withdrawal must change depositor and vault shares by equal "
-             "amount"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = 5;
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt,
-            TxAccount::A2);
-
-        testcase << "Vault clawback";
-        doInvariantCheck(
-            {"clawback must change vault balance"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) {
-                                   sample.vaultAssets.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        // Not the same as below check: attempt to clawback XRP
-        doInvariantCheck(
-            {"clawback may only be performed by the asset issuer"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseXrp);
-
-        // Not the same as above check: attempt to clawback MPT by bad account
-        doInvariantCheck(
-            {"clawback may only be performed by the asset issuer"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must decrease vault balance",
-             "clawback must decrease holder shares",
-             "clawback must change vault shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) {
-                                   sample.sharesTotal = 0;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must change holder shares"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares.reset();
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        doInvariantCheck(
-            {"clawback must change holder and vault shares by equal amount",
-             "clawback and assets outstanding must add up",
-             "clawback and assets available must add up"},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const keylet =
-                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
-                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
-                                   sample.accountShares->amount = -8;
-                                   sample.assetsTotal = -7;
-                                   sample.assetsAvailable = -7;
-                               }));
-            },
-            XRPAmount{},
-            STTx{
-                ttVAULT_CLAWBACK,
-                [&](STObject& tx) {
-                    tx[sfAccount] = a3.id();
-                    tx[sfHolder] = a4.id();
-                }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseMpt);
-
-        // ─────────────────────────────────────────────────────────────
-        // Closed-ended vault invariants added in ValidVault::finalize (create must supply both
-        // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase,
-        // withdraw not in Investment, loan origination only in Investment.
-
-        using d = NetClock::duration;
-        using tp = NetClock::time_point;
-
-        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
-
-        // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it
-        // from ac.view().seq(), which depends on how many env.close() calls preclose issued.
-        Keylet closedEndedKeylet = keylet::amendments();
-
-        // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with
-        // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and
-        // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub
-        // leaves the vault in Subscription.
-        auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) {
-            return [&, advanceBySub, doDeposit](
-                       Account const& a1, Account const& a2, Env& env) -> bool {
-                env.fund(XRP(1000), a3, a4);
-                auto const sub = env.now().time_since_epoch().count() + 60;
-                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create(
-                    {.owner = a1,
-                     .asset = xrpIssue(),
-                     .vaultKind = closedEnded,
-                     .subscriptionDate = sub,
-                     .redemptionDate = red});
-                env(tx);
-                closedEndedKeylet = keylet;
-                if (doDeposit)
-                {
-                    env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
-                    env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
-                    env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
-                }
-                if (advanceBySub >= 0)
-                    env.close(tp{d{sub + advanceBySub}});
-                return true;
-            };
-        };
-
-        // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance)
-        // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE
-        // states no legitimate transactor would produce.
-        auto const insertBareClosedEndedVault =
-            [closedEnded](
-                ApplyContext& ac,
-                Account const& owner,
-                std::optional subscriptionDate,
-                std::optional redemptionDate) -> bool {
-            auto const sequence = ac.view().seq();
-            auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence));
-            auto sleVault = std::make_shared(vaultKeylet);
-            auto const vaultPage = ac.view().dirInsert(
-                keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id()));
-            if (!vaultPage)
-                return false;
-            sleVault->setFieldU64(sfOwnerNode, *vaultPage);
-
-            auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
-            auto sleAccount = std::make_shared(keylet::account(pseudoId));
-            sleAccount->setAccountID(sfAccount, pseudoId);
-            sleAccount->setFieldAmount(sfBalance, STAmount{});
-            sleAccount->setFieldU32(sfSequence, 0);
-            sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
-            sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
-            ac.view().insert(sleAccount);
-
-            auto const sharesMptId = makeMptID(sequence, pseudoId);
-            auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
-            auto sleShares = std::make_shared(sharesKeylet);
-            auto const sharesPage = ac.view().dirInsert(
-                keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
-            if (!sharesPage)
-                return false;
-            sleShares->setFieldU64(sfOwnerNode, *sharesPage);
-            sleShares->at(sfFlags) = 0;
-            sleShares->at(sfIssuer) = pseudoId;
-            sleShares->at(sfOutstandingAmount) = 0;
-            sleShares->at(sfSequence) = sequence;
-
-            sleVault->at(sfAccount) = pseudoId;
-            sleVault->at(sfFlags) = 0;
-            sleVault->at(sfSequence) = sequence;
-            sleVault->at(sfOwner) = owner.id();
-            sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}});
-            sleVault->at(sfAssetsTotal) = Number(0);
-            sleVault->at(sfAssetsAvailable) = Number(0);
-            sleVault->at(sfLossUnrealized) = Number(0);
-            sleVault->at(sfShareMPTID) = sharesMptId;
-            sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
-            sleVault->at(sfVaultKind) = closedEnded;
-            if (subscriptionDate)
-                sleVault->at(sfSubscriptionDate) = *subscriptionDate;
-            if (redemptionDate)
-                sleVault->at(sfRedemptionDate) = *redemptionDate;
-
-            ac.view().insert(sleVault);
-            ac.view().insert(sleShares);
-            return true;
-        };
-
-        testcase << "Vault create closed-ended";
-
-        // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate.
-        doInvariantCheck(
-            {"closed-ended vault must have SubscriptionDate and RedemptionDate"},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt);
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate;
-        // exercises the sub-minimum branch of the gap check.
-        doInvariantCheck(
-            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
-             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                std::uint32_t const sub = 1'000'000'000;
-                std::uint32_t const red = sub + kMinInvestmentPeriod - 1;
-                return insertBareClosedEndedVault(ac, a1, sub, red);
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and
-        // is caught by the sub-minimum branch of the gap check.
-        doInvariantCheck(
-            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
-             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                std::uint32_t const sub = 1'000'000'000;
-                std::uint32_t const red = sub - 1;
-                return insertBareClosedEndedVault(ac, a1, sub, red);
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right).
-        doInvariantCheck(
-            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
-             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                std::uint32_t const sub = 1'000'000'000;
-                std::uint32_t const red = sub + kMaxInvestmentPeriod;
-                return insertBareClosedEndedVault(ac, a1, sub, red);
-            },
-            XRPAmount{},
-            STTx{ttVAULT_CREATE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-
-        testcase << "Vault deposit closed-ended";
-
-        // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs
-        // simulates an otherwise valid deposit shape so only the phase invariant fires.
-        doInvariantCheck(
-            {"deposit only allowed in Subscription or NoPhase"},
-            [&](Account const&, Account const& a2, ApplyContext& ac) {
-                return kAdjust(
-                    ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
-            TxAccount::A2);
-
-        testcase << "Vault withdrawal closed-ended";
-
-        // A withdrawal from a closed-ended vault in the Investment phase.
-        doInvariantCheck(
-            {"withdrawal not allowed during Investment phase"},
-            [&](Account const&, Account const& a2, ApplyContext& ac) {
-                return kAdjust(
-                    ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {}));
-            },
-            XRPAmount{},
-            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
-            TxAccount::A2);
-
-        testcase << "Vault loan set";
-
-        // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires
-        // on any vault mutation; touching the vault SLE with no field change is sufficient.
-        doInvariantCheck(
-            {"loan origination only allowed in Investment phase"},
-            [&](Account const&, Account const&, ApplyContext& ac) {
-                auto sleVault = ac.view().peek(closedEndedKeylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttLOAN_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false));
-
-        testcase << "Vault loan set - closed-ended final payment past "
-                    "RedemptionDate";
-
-        // A newly-created loan against a closed-ended vault must satisfy StartDate +
-        // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same
-        // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant
-        // catches it even when preclaim is bypassed.
-        Keylet closedEndedBrokerKeylet = keylet::amendments();
-        std::uint32_t closedEndedRed = 0;
-        doInvariantCheck(
-            {"closed-ended loan final payment must precede RedemptionDate"},
-            [&](Account const& a1, Account const&, ApplyContext& ac) {
-                // Touch the vault so ValidVault::finalizeLoanSet sees an
-                // entry in afterVault_; the vault is in Investment, so
-                // finalizeLoanSet itself passes.
-                auto sleVault = ac.view().peek(closedEndedKeylet);
-                if (!sleVault)
-                    return false;
-                ac.view().update(sleVault);
-
-                // Read the broker's next loan sequence to build the loan
-                // keylet the same way LoanSet::doApply would.
-                auto sleBroker = ac.view().peek(closedEndedBrokerKeylet);
-                if (!sleBroker)
-                    return false;
-                std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence);
-
-                // Synthesize a Loan whose final scheduled payment lands
-                // exactly at RedemptionDate: StartDate = red, interval = 60,
-                // remaining = 1 => red + 60 >= red.
-                auto sleLoan = std::make_shared(
-                    keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
-                sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
-                sleLoan->at(sfLoanSequence) = loanSeq;
-                sleLoan->at(sfBorrower) = a1.id();
-                sleLoan->at(sfStartDate) = closedEndedRed;
-                sleLoan->at(sfPaymentInterval) = 60;
-                sleLoan->at(sfPaymentRemaining) = 1;
-                sleLoan->at(sfTotalValueOutstanding) = Number(100);
-                sleLoan->at(sfPeriodicPayment) = Number(1);
-                ac.view().insert(sleLoan);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttLOAN_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            [&](Account const& a1, Account const&, Env& env) -> bool {
-                auto const sub = env.now().time_since_epoch().count() + 60;
-                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
-                closedEndedRed = red;
-
-                Vault const vault{env};
-                auto [tx, keylet] = vault.create(
-                    {.owner = a1,
-                     .asset = xrpIssue(),
-                     .vaultKind = closedEnded,
-                     .subscriptionDate = sub,
-                     .redemptionDate = red});
-                env(tx);
-                closedEndedKeylet = keylet;
-
-                // Create the loan broker; LoanBrokerSet has no phase gate.
-                closedEndedBrokerKeylet =
-                    keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1)));
-                env(loan_broker::set(a1, keylet.key));
-
-                // Advance parent close time into Investment so
-                // ValidVault::finalizeLoanSet is satisfied.
-                env.close(tp{d{sub + 1}});
-                return true;
-            });
-    }
-
-    void
-    testMPT()
-    {
-        using namespace test::jtx;
-        testcase << "MPT";
-
-        MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))};
-        auto const nonCanonicalMPTAmount = [&](SField const& field) {
-            return STAmount{
-                field,
-                nonCanonicalMPTIssue,
-                kMaxMpTokenAmount + std::uint64_t{1},
-                0,
-                false,
-                STAmount::Unchecked{}};
-        };
-        auto const negativeMPTAmount = [&](SField const& field) {
-            return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}};
-        };
-        auto const nonCanonicalMPTPayment = [&]() {
-            return STTx{ttPAYMENT, [&](STObject& tx) {
-                            tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount));
-                        }};
-        };
-
-        doInvariantCheck(
-            makeEnv(defaultAmendments() - fixCleanup3_2_0),
-            {},
-            [](Account const&, Account const&, ApplyContext&) { return true; },
-            XRPAmount{},
-            nonCanonicalMPTPayment(),
-            {tesSUCCESS, tesSUCCESS});
-
-        doInvariantCheck(
-            {{"ledger entry contains non-canonical MPT or XRP amount"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                auto sleNew = std::make_shared(
-                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setAccountID(sfDestination, a2.id());
-                sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        doInvariantCheck(
-            {{"ledger entry contains non-canonical MPT or XRP amount"}},
-            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                auto sleNew = std::make_shared(
-                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
-                sleNew->setAccountID(sfAccount, a1.id());
-                sleNew->setAccountID(sfDestination, a2.id());
-                sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPT OutstandingAmount > MaximumAmount
-        doInvariantCheck(
-            {{"OutstandingAmount overflow"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 110);
-                sleNew->setFieldU64(sfMaximumAmount, 100);
-                ac.view().insert(sleNew);
-                return true;
-            });
-
-        // MPTToken amount doesn't add up to OutstandingAmount
-        doInvariantCheck(
-            {{"invalid OutstandingAmount balance"}},
-            [](Account const& a1, Account const& a2, ApplyContext& ac) {
-                // mptissuance outstanding is negative
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldU64(sfOutstandingAmount, 100);
-                sleNew->setFieldU64(sfMaximumAmount, 100);
-                ac.view().insert(sleNew);
-
-                sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
-                sleNew->setFieldU64(sfMPTAmount, 90);
-                ac.view().insert(sleNew);
-
-                return true;
-            });
-
-        // Overflow/Invalid balance on payment
-        auto testPayment = [&](std::string const& log, auto&& update) {
-            MPTID id;
-            doInvariantCheck(
-                {{log}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    return update(id, ac, a1);
-                },
-                XRPAmount{},
-                STTx{ttPAYMENT, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-                [&](Account const& a1, Account const& a2, Env& env) {
-                    Account const gw("gw");
-                    env.fund(XRP(1'000), gw);
-                    MPTTester const mpt(
-                        {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100});
-                    id = mpt.issuanceID();
-                    return true;
-                });
-        };
-        testPayment(
-            "invalid OutstandingAmount balance",
-            [&](MPTID const& id, ApplyContext& ac, Account const& a1) {
-                auto sle = ac.view().peek(keylet::mptoken(id, a1));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfMPTAmount, 101);
-                ac.view().update(sle);
-                return true;
-            });
-        testPayment(
-            "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
-                auto sle = ac.view().peek(keylet::mptokenIssuance(id));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfOutstandingAmount, 101);
-                ac.view().update(sle);
-                return true;
-            });
-
-        // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback balance change is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100};
-                    }},
-                {tesSUCCESS, tesSUCCESS});
-        }
-
-        // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing.
-        {
-            Env env(*this, defaultAmendments() - featureMPTokensV2);
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback balance change is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tesSUCCESS, tesSUCCESS});
-        }
-
-        // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback balance change is invalid"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-
-                    sleToken->setFieldU64(sfMPTAmount, 80);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 80);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline and MPToken both changed"}},
-                [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleLine =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id()));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleLine || !sleToken || !sleIssuance)
-                        return false;
-
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sleLine->setFieldAmount(sfBalance, balance);
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleLine);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Clawback that modifies a trustline other than the one implied by the
-        // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for
-        // the mismatched line.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            auto const eur = issuer["EUR"];
-            env.trust(eur(100), holder);
-            env(pay(issuer, holder, eur(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback changed the wrong line"}},
-                [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency));
-                    if (!sle)
-                        return false;
-                    STAmount balance{Issue{eur.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Clawback leaving the holder's balance negative.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline or MPT balance is negative"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-                    // Make the holder's balance negative from their perspective.
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
-                    if (holder.id() < issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // IOU-amount clawback while only an MPToken changed: no trustline was
-        // recorded, so iou_.before is empty.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback changed the wrong line"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Valid trustline change but a zero clawback amount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            auto const usd = issuer["USD"];
-            env.trust(usd(100), holder);
-            env(pay(issuer, holder, usd(100)));
-            env.close();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: trustline clawback amount is invalid"}},
-                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto sle =
-                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
-                    if (!sle)
-                        return false;
-                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
-                    if (holder.id() > issuer.id())
-                        balance.negate();
-                    sle->setFieldAmount(sfBalance, balance);
-                    ac.view().update(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback tx missing the Holder field.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback missing holder"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback where the holder's MPToken was deleted (after is empty).
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback token is missing"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    // Keep the issuance consistent after removing the token.
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 0);
-                    ac.view().update(sleIssuance);
-                    ac.view().erase(sleToken);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // MPT clawback that changed a different holder's MPToken.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env,
-                 .issuer = issuer,
-                 .holders = {holder, other},
-                 .pay = 100,
-                 .maxAmt = 200});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback changed the wrong token"}},
-                [id](Account const&, Account const& other, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, other));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 190);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // Valid MPToken change but a zero MPT clawback amount.
-        {
-            Env env(*this, defaultAmendments());
-            Account const issuer{"issuer"};
-            Account const holder{"holder"};
-            Account const other{"other"};
-            env.fund(XRP(1'000), issuer, holder, other);
-            MPTTester const mpt(
-                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
-            auto const id = mpt.issuanceID();
-
-            doInvariantCheck(
-                std::move(env),
-                holder,
-                other,
-                {{"Invariant failed: MPT clawback amount is invalid"}},
-                [id](Account const& holder, Account const&, ApplyContext& ac) {
-                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
-                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
-                    if (!sleToken || !sleIssuance)
-                        return false;
-                    sleToken->setFieldU64(sfMPTAmount, 90);
-                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
-                    ac.view().update(sleToken);
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{
-                    ttCLAWBACK,
-                    [&](STObject& tx) {
-                        tx[sfAccount] = issuer.id();
-                        tx[sfHolder] = holder.id();
-                        tx[sfAmount] = STAmount{MPTIssue{id}, 0};
-                    }},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // More MPTokens created than expected
-        std::array, 4> const tests = {
-            std::make_pair(ttAMM_WITHDRAW, 2),
-            std::make_pair(ttAMM_CLAWBACK, 2),
-            std::make_pair(ttAMM_CREATE, 3),
-            std::make_pair(ttCHECK_CASH, 2)};
-        for (auto const& [tx, nTokens] : tests)
-        {
-            doInvariantCheck(
-                {{std::string("MPToken created for the MPT issuer")}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-
-                    auto seq = sle->getFieldU32(sfSequence);
-                    for (int i = 0; i < nTokens; ++i)
-                    {
-                        MPTIssue const mpt{makeMptID(seq + i, a1)};
-                        auto sleNew =
-                            std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                        ac.view().insert(sleNew);
-
-                        sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
-                        ac.view().insert(sleNew);
-                    }
-
-                    return true;
-                },
-                XRPAmount{},
-                STTx{tx, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
-        }
-
-        // More MPTokens deleted than expected
-        for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK})
-        {
-            MPTID id;
-            Account const a3("A3");
-            doInvariantCheck(
-                {{"MPT authorize  succeeded but created/deleted bad number of mptokens"}},
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    for (auto const& a : {a1, a2, a3})
-                    {
-                        auto sle = ac.view().peek(keylet::mptoken(id, a));
-                        if (!sle)
-                            return false;
-                        ac.view().erase(sle);
-                    }
-                    return true;
-                },
-                XRPAmount{},
-                STTx{tx, [](STObject& tx) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const& a2, Env& env) {
-                    Account const gw("gw");
-                    env.fund(XRP(1'000), gw, a3);
-                    MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}});
-                    id = mpt.issuanceID();
-                    return true;
-                });
-        }
-
-        // sfReferenceHolding can only be set on creation by VaultCreate. A
-        // non-VaultCreate transaction that creates an MPTokenIssuance with
-        // sfReferenceHolding present must trip the invariant.
-        doInvariantCheck(
-            {{"sfReferenceHolding set on a new MPTokenIssuance by a "
-              "non-VaultCreate transaction"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const sleAcct = ac.view().peek(keylet::account(a1.id()));
-                if (!sleAcct)
-                    return false;
-                MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                sleNew->setFieldH256(sfReferenceHolding, uint256{1});
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}});
-
-        // sfReferenceHolding is immutable: changing the field on an
-        // existing MPTokenIssuance must trip the invariant. Set up a real
-        // vault via preclose (so the share issuance carries
-        // sfReferenceHolding), then mutate it in precheck to produce a
-        // before/after pair.
-        {
-            uint256 vaultKey;
-            doInvariantCheck(
-                {{"sfReferenceHolding was modified on an existing "
-                  "MPTokenIssuance"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
-                    if (!sleVault)
-                        return false;
-                    auto sleIssuance =
-                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-                    if (!sleIssuance)
-                        return false;
-                    sleIssuance->setFieldH256(sfReferenceHolding, uint256{2});
-                    ac.view().update(sleIssuance);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const&, Env& env) {
-                    Account const issuer{"issuer"};
-                    env.fund(XRP(10'000), issuer);
-                    env.close();
-                    MPTTester mptt{env, issuer, kMptInitNoFund};
-                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-                    PrettyAsset const asset = mptt.issuanceID();
-                    mptt.authorize({.account = a1});
-                    env.close();
-
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-                    env(tx);
-                    env.close();
-                    vaultKey = keylet.key;
-                    return true;
-                });
-        }
-
-        // A vault pseudo-account's MPToken cannot be deleted by anything
-        // other than a VaultDelete transaction. Set up a vault, then have
-        // an arbitrary tx erase the pseudo's MPToken in precheck.
-        {
-            uint256 vaultKey;
-            doInvariantCheck(
-                {{"vault pseudo-account holding deleted by a "
-                  "non-VaultDelete transaction"}},
-                [&](Account const&, Account const&, ApplyContext& ac) {
-                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
-                    if (!sleVault)
-                        return false;
-                    auto const sleIssuance =
-                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
-                    if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
-                        return false;
-                    auto sleHolding = ac.view().peek(
-                        keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
-                    if (!sleHolding)
-                        return false;
-                    ac.view().erase(sleHolding);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&](Account const& a1, Account const&, Env& env) {
-                    Account const issuer{"issuer"};
-                    env.fund(XRP(10'000), issuer);
-                    env.close();
-                    MPTTester mptt{env, issuer, kMptInitNoFund};
-                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
-                    PrettyAsset const asset = mptt.issuanceID();
-                    mptt.authorize({.account = a1});
-                    env.close();
-
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
-                    env(tx);
-                    env.close();
-                    vaultKey = keylet.key;
-                    return true;
-                });
-        }
-
-        // Invalid transfer
-        std::array, 3> const invalidTransferTests = {
-            std::make_pair(ttAMM_WITHDRAW, false),
-            std::make_pair(ttPAYMENT, false),
-            std::make_pair(ttPAYMENT, true)};
-        for (auto const enabled : {true, false})
-        {
-            for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
-            {
-                for (auto const flag :
-                     {static_cast(lsfMPTLocked),
-                      ~lsfMPTCanTransfer,
-                      ~lsfMPTCanTrade,
-                      0u})
-                {
-                    MPTID id{};
-                    auto const isSuccess = !enabled || flag == 0 ||
-                        (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
-                        (tx == ttAMM_WITHDRAW &&
-                         (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
-                    std::pair const error = isSuccess
-                        ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS))
-                        : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED));
-                    doInvariantCheck(
-                        {{isSuccess ? "" : "invalid MPToken transfer between holders"}},
-                        [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                            auto update = [&](AccountID const& a, std::uint64_t v) {
-                                auto sle = ac.view().peek(keylet::mptoken(id, a));
-                                if (!sle)
-                                    return false;
-                                sle->at(sfMPTAmount) = v;
-                                ac.view().update(sle);
-                                return true;
-                            };
-                            auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id));
-                            if (!issuanceSle)
-                                return false;
-                            auto const flags = issuanceSle->at(sfFlags);
-                            if (flag == lsfMPTLocked)
-                            {
-                                issuanceSle->at(sfFlags) = flags | lsfMPTLocked;
-                            }
-                            else if (flag != 0u)
-                            {
-                                issuanceSle->at(sfFlags) = flags & flag;
-                            }
-                            issuanceSle->at(sfOutstandingAmount) = 200;
-                            ac.view().update(issuanceSle);
-                            return update(a1, 101) && update(a2, 99);
-                        },
-                        XRPAmount{},
-                        STTx{
-                            tx,
-                            [&](STObject& tx) {
-                                if (crossCurrencyPayment)
-                                {
-                                    tx.setFieldAmount(
-                                        sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id}));
-                                }
-                            }},
-                        {error.first, error.second},
-                        [&](Account const& a1, Account const& a2, Env& env) {
-                            Account const gw("gw");
-                            env.fund(XRP(1'000), gw);
-                            MPTTester const usd(
-                                {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100});
-                            id = usd.issuanceID();
-                            if (!enabled)
-                            {
-                                env.disableFeature(featureMPTokensV2);
-                            }
-                            return true;
-                        });
-                }
-            }
-        }
-
-        // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
-        // through sfReferenceHolding to test the vault's underlying asset for
-        // each changed holder.
-        {
-            Account const gw{"gw"};
-            MPTID shareID{};
-
-            // Vault setup: a1 and a2 both deposit IOU and hold vault shares.
-            auto const setupVault = [&](Account const& a1,
-                                        Account const& a2,
-                                        Env& env) -> std::tuple {
-                env.fund(XRP(1'000), gw);
-                env.trust(gw["IOU"](10'000), a1);
-                env.trust(gw["IOU"](10'000), a2);
-                env.close();
-                env(pay(gw, a1, gw["IOU"](500)));
-                env(pay(gw, a2, gw["IOU"](500)));
-                env.close();
-
-                Vault const vault{env};
-                auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]});
-                env(createTx);
-                env.close();
-                env(vault.deposit(
-                    {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
-                env(vault.deposit(
-                    {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
-                env.close();
-
-                return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)};
-            };
-
-            // Simulate a vault-share transfer: a1 sends 10 shares to a2.
-            auto const precheck =
-                [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool {
-                auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id()));
-                auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
-                if (!sle1 || !sle2)
-                    return false;
-                (*sle1)[sfMPTAmount] -= 10;
-                (*sle2)[sfMPTAmount] += 10;
-                ac.view().update(sle1);
-                ac.view().update(sle2);
-                return true;
-            };
-
-            // Case: vault pseudo-account's IOU trustline is frozen.
-            {
-                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-                    auto [sid, vid] = setupVault(a1, a2, env);
-                    shareID = sid;
-                    env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze));
-                    env.close();
-                    return true;
-                };
-
-                doInvariantCheck(
-                    Env{*this, defaultAmendments()},
-                    {{"invalid MPToken transfer between holders"}},
-                    precheck,
-                    XRPAmount{},
-                    STTx{ttPAYMENT, [](STObject&) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    preclose);
-            }
-
-            // Case: receiver's (a2's) IOU trustline is frozen.
-            {
-                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
-                    auto [sid, vid] = setupVault(a1, a2, env);
-                    shareID = sid;
-                    env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
-                    env.close();
-                    return true;
-                };
-
-                doInvariantCheck(
-                    Env{*this, defaultAmendments()},
-                    {{"invalid MPToken transfer between holders"}},
-                    precheck,
-                    XRPAmount{},
-                    STTx{ttPAYMENT, [](STObject&) {}},
-                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                    preclose);
-            }
-        }
-    }
-
-    void
-    testAMM()
-    {
-        testcase << "AMM";
-        using namespace jtx;
-
-        MPTID mptID{};
-        uint256 ammID{};
-        AccountID ammAccountID{};
-        Account const gw{"gw"};
-        Issue lptIssue{};
-        PrettyAsset poolAsset{xrpIssue()};
-
-        auto deleteAMMAccount = [&](ApplyContext& ac, bool) {
-            auto sle = ac.view().peek(keylet::account(ammAccountID));
-            if (!sle)
-                return false;
-            ac.view().erase(sle);
-            return true;
-        };
-
-        auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) {
-            auto sle = ac.view().peek(keylet::amm(ammID));
-            if (!sle)
-                return false;
-            sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount});
-            ac.view().update(sle);
-            return true;
-        };
-        auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) {
-            return updateLPTokensBalance(ac, -1);
-        };
-        auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) {
-            return updateLPTokensBalance(ac, 200'000'000);
-        };
-        auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); };
-
-        auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) {
-            if (isMPT)
-            {
-                auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID));
-                if (!sle)
-                    return false;
-                sle->setFieldU64(sfMPTAmount, 1);
-                ac.view().update(sle);
-                return true;
-            }
-            auto sle = ac.view().peek(keylet::account(ammAccountID));
-            if (!sle)
-                return false;
-            sle->setFieldAmount(sfBalance, XRP(1));
-            ac.view().update(sle);
-            return true;
-        };
-
-        auto test = [&](auto const txType,
-                        auto&& update,
-                        bool isMPT,
-                        TER error = tecINVARIANT_FAILED) {
-            doInvariantCheck(
-                {{"AMM"}},
-                [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); },
-                XRPAmount{},
-                STTx{txType, [&](STObject& tx) {}},
-                {tecINVARIANT_FAILED, error},
-                [&](Account const&, Account const&, Env& env) {
-                    env.fund(XRP(1'000), gw);
-                    poolAsset = [&]() -> PrettyAsset {
-                        if (isMPT)
-                        {
-                            MPT const mpt = MPTTester({.env = env, .issuer = gw});
-                            mptID = mpt.issuanceID;
-                            return mpt;
-                        }
-                        return gw["USD"];
-                    }();
-                    AMM const amm(env, gw, XRP(100), poolAsset(100));
-                    ammAccountID = amm.ammAccount();
-                    ammID = amm.ammID();
-                    lptIssue = amm.lptIssue();
-                    return true;
-                });
-        };
-
-        for (bool const isMPT : {false, true})
-        {
-            auto const error = isMPT ? TER(tecINVARIANT_FAILED) : TER(tefINVARIANT_FAILED);
-            for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW})
-            {
-                test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED);
-                test(txType, updateLPTokensBadAmount, isMPT);
-                test(txType, updateLPTokensBadBalance, isMPT);
-            }
-            for (auto txType : {ttAMM_BID, ttAMM_VOTE})
-            {
-                test(txType, updateAMMPool, isMPT, error);
-                test(txType, updateLPTokensBadAmount, isMPT);
-                test(txType, updateLPTokensBadBalance, isMPT);
-            }
-            for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT})
-            {
-                test(txType, updateAMM, isMPT);
-            }
-        }
-    }
-
-    // Test the invariant overwrite fix for both pre- and post-amendment
-    // behavior. With the fix enabled, |= accumulates violations across
-    // entries so a later valid entry cannot clear an earlier violation.
-    // Without the fix, = assignment means the last-visited entry wins.
-    void
-    testInvariantOverwrite(FeatureBitset features)
-    {
-        using namespace test::jtx;
-        bool const fixEnabled = features[fixCleanup3_1_3];
-        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
-        std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS};
-
-        // Insert two trust line SLEs in hash-sorted order, with the "bad"
-        // entry at the lower-sorting key so it is visited first by
-        // ApplyStateTable::visit(). The configurer callables receive the
-        // SLE and the Issue corresponding to that side's keylet currency.
-        auto const insertOrderedTrustLinePair = [](ApplyContext& ac,
-                                                   Account const& a1,
-                                                   Account const& a2,
-                                                   Account const& a3,
-                                                   auto const& badConfig,
-                                                   auto const& goodConfig) {
-            char const* const c1 = "USD";
-            char const* const c2 = "EUR";
-            auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency);
-            auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency);
-
-            bool const k1First = k1.key < k2.key;
-            auto const& badKey = k1First ? k1 : k2;
-            auto const& goodKey = k1First ? k2 : k1;
-            Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()};
-            Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()};
-
-            auto const sleBad = std::make_shared(badKey);
-            badConfig(*sleBad, badIss);
-            ac.view().insert(sleBad);
-
-            auto const sleGood = std::make_shared(goodKey);
-            goodConfig(*sleGood, goodIss);
-            ac.view().insert(sleGood);
-        };
-
-        // Regression: bad XRP trust line followed by a valid trust line.
-        // With the fix, the invariant catches the violation. Without it,
-        // the valid entry overwrites the flag to false. The keylet
-        // currencies are non-XRP (the invariant inspects sfLowLimit /
-        // sfHighLimit issue, not the keylet currency).
-        testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"an XRP trust line was created"}}
-                       : std::vector{},
-            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Account const a3{"A3"};
-                insertOrderedTrustLinePair(
-                    ac,
-                    a1,
-                    a2,
-                    a3,
-                    [](SLE& sle, Issue const& iss) {
-                        // sfLowLimit has xrpIssue, making isXrp = true
-                        sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                    },
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                    });
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            fixEnabled ? failTers : passTers);
-
-        // Regression: bad deep-freeze trust line followed by a valid one.
-        testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"a trust line with deep freeze flag without "
-                                                   "normal freeze was created"}}
-                       : std::vector{},
-            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Account const a3{"A3"};
-                insertOrderedTrustLinePair(
-                    ac,
-                    a1,
-                    a2,
-                    a3,
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                        sle.setFieldU32(sfFlags, lsfLowDeepFreeze);
-                    },
-                    [](SLE& sle, Issue const& iss) {
-                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
-                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
-                        sle.setFieldU32(sfFlags, 0u);
-                    });
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            fixEnabled ? failTers : passTers);
-
-        // Regression: MPT OutstandingAmount exceeds max, but locked <=
-        // outstanding. Plain assignment would overwrite bad_ = true.
-        // With the fix, NoZeroEscrow catches it.
-        // Without the fix, NoZeroEscrow passes but ValidMPTIssuance
-        // still fires ("a MPT issuance was created").
-        testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : "");
-        doInvariantCheck(
-            makeEnv(features),
-            fixEnabled ? std::vector{{"escrow specifies invalid amount"}}
-                       : std::vector{{"a MPT issuance was created"}},
-            [](Account const& a1, Account const&, ApplyContext& ac) {
-                auto const sle = ac.view().peek(keylet::account(a1.id()));
-                if (!sle)
-                    return false;
-
-                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
-                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
-                // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_
-                sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1);
-                // locked is valid and <= outstanding -> must NOT clear bad_
-                sleNew->setFieldU64(sfLockedAmount, 10);
-                ac.view().insert(sleNew);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttACCOUNT_SET, [](STObject&) {}},
-            failTers);
-    }
-
-    void
-    testVaultComputeCoarsestScale()
-    {
-        using namespace jtx;
-
-        Account const issuer{"issuer"};
-        PrettyAsset const vaultAsset = issuer["IOU"];
-
-        struct TestCase
-        {
-            std::string name;
-            std::int32_t expectedMinScale;
-            std::vector values;
-        };
-
-        for (auto const mantissaScale : MantissaRange::getAllScales())
-        {
-            if (mantissaScale == MantissaRange::MantissaScale::Small)
-                continue;
-            NumberMantissaScaleGuard const g{mantissaScale};
-
-            auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
-                return {.delta = n, .scale = scale(n, vaultAsset.raw())};
-            };
-
-            auto const testCases = std::vector{
-                {
-                    .name = "No values",
-                    .expectedMinScale = 0,
-                    .values = {},
-                },
-                {
-                    .name = "Mixed integer and Number values",
-                    .expectedMinScale = -15,
-                    .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})},
-                },
-                {
-                    .name = "Mixed scales",
-                    .expectedMinScale = -17,
-                    .values =
-                        {makeDelta(Number{1, -2}),
-                         makeDelta(Number{5, -3}),
-                         makeDelta(Number{3, -2})},
-                },
-                {
-                    .name = "Equal scales",
-                    .expectedMinScale = -16,
-                    .values =
-                        {makeDelta(Number{1, -1}),
-                         makeDelta(Number{5, -1}),
-                         makeDelta(Number{1, -1})},
-                },
-                {
-                    .name = "Mixed mantissa sizes",
-                    .expectedMinScale = -12,
-                    .values =
-                        {makeDelta(Number{1}),
-                         makeDelta(Number{1234, -3}),
-                         makeDelta(Number{12345, -6}),
-                         makeDelta(Number{123, 1})},
-                },
-            };
-
-            for (auto const& tc : testCases)
-            {
-                testcase("vault computeCoarsestScale: " + tc.name);
-
-                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
-
-                BEAST_EXPECTS(
-                    actualScale == tc.expectedMinScale,
-                    "expected: " + std::to_string(tc.expectedMinScale) +
-                        ", actual: " + std::to_string(actualScale));
-                for (auto const& num : tc.values)
-                {
-                    // None of these scales are far enough apart that rounding the
-                    // values would lose information, so check that the rounded
-                    // value matches the original.
-                    auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                    BEAST_EXPECTS(
-                        actualRounded == num.delta,
-                        "number " + to_string(num.delta) + " rounded to scale " +
-                            std::to_string(actualScale) + " is " + to_string(actualRounded));
-                }
-            }
-
-            auto const testCases2 = std::vector{
-                {
-                    .name = "False equivalence",
-                    .expectedMinScale = -15,
-                    .values =
-                        {
-                            makeDelta(Number{1234567890123456789, -18}),
-                            makeDelta(Number{12345, -4}),
-                            makeDelta(Number{1}),
-                        },
-                },
-            };
-
-            // Unlike the first set of test cases, the values in these test could
-            // look equivalent if using the wrong scale.
-            for (auto const& tc : testCases2)
-            {
-                testcase("vault computeCoarsestScale: " + tc.name);
-
-                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
-
-                BEAST_EXPECTS(
-                    actualScale == tc.expectedMinScale,
-                    "expected: " + std::to_string(tc.expectedMinScale) +
-                        ", actual: " + std::to_string(actualScale));
-                std::optional first;
-                Number firstRounded;
-                for (auto const& num : tc.values)
-                {
-                    if (!first)
-                    {
-                        first = num.delta;
-                        firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                        continue;
-                    }
-                    auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
-                    BEAST_EXPECTS(
-                        numRounded != firstRounded,
-                        "at a scale of " + std::to_string(actualScale) + " " +
-                            to_string(num.delta) + " == " + to_string(*first));
-                }
-            }
-        }
-    }
-
-    void
-    testSponsorship()
-    {
-        using namespace test::jtx;
-        using namespace std::string_literals;
-        testcase("Sponsorship");
-        {
-            auto const expectMessage =
-                "SponsoredOwnerCount does not equal SponsoringOwnerCount delta.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoringOwnerCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "OwnerCount must be greater than or equal to SponsoredOwnerCount.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfOwnerCount, 0);
-                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
-                    ac.view().update(sle);
-
-                    auto const sle2 = ac.view().peek(keylet::account(a2.id()));
-                    if (!sle2)
-                        return false;
-                    sle2->setFieldU32(sfSponsoringOwnerCount, 1);
-                    ac.view().update(sle2);
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta.";
-            uint256 checkID;
-
-            doInvariantCheck(
-                {{expectMessage}},
-                [&](Account const&, Account const& a2, ApplyContext& ac) {
-                    auto const check = ac.view().peek(keylet::check(checkID));
-                    if (!check)
-                        return false;
-                    check->setAccountID(sfSponsor, a2.id());
-                    ac.view().update(check);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttACCOUNT_SET, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&checkID](Account const& a1, Account const& a2, Env& env) {
-                    checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key;
-                    env(check::create(a1, a2, XRP(1)));
-                    return true;
-                });
-        }
-
-        {
-            auto const expectMessage =
-                "Invariant failed: Net delta of SponsoringAccountCount does "
-                "not match net delta of sfSponsor presence.";
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setFieldU32(sfSponsoringAccountCount, 1);
-                    ac.view().update(sle);
-                    return true;
-                });
-
-            doInvariantCheck(
-                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
-                    auto const sle = ac.view().peek(keylet::account(a1.id()));
-                    if (!sle)
-                        return false;
-                    sle->setAccountID(sfSponsor, a2.id());
-                    ac.view().update(sle);
-                    return true;
-                });
-        }
-    }
-
-    void
-    testObjectHasPseudoAccount()
-    {
-        testcase << "object has pseudo-account";
-        using namespace jtx;
-
-        auto const amendments = defaultAmendments() | fixCleanup3_3_0;
-
-        // Vault: object deleted without its pseudo-account
-        {
-            Keylet vaultKeylet = keylet::amendments();
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted Vault without deleting its pseudo-account"}},
-                [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(vaultKeylet);
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttVAULT_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&vaultKeylet](Account const& a1, Account const&, Env& env) {
-                    Vault const vault{env};
-                    auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
-                    env(tx);
-                    vaultKeylet = keylet;
-                    return true;
-                });
-        }
-
-        // AMM: object deleted without its pseudo-account
-        {
-            uint256 ammID{};
-            Account const gw{"gw"};
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted AMM without deleting its pseudo-account"}},
-                [&ammID](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(keylet::amm(ammID));
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttAMM_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&ammID, &gw](Account const&, Account const&, Env& env) {
-                    env.fund(XRP(1'000), gw);
-                    AMM const amm(env, gw, XRP(100), gw["USD"](100));
-                    ammID = amm.ammID();
-                    return true;
-                });
-        }
-
-        // LoanBroker: object deleted without its pseudo-account
-        {
-            Keylet loanBrokerKeylet = keylet::amendments();
-            doInvariantCheck(
-                Env{*this, amendments},
-                {{"deleted LoanBroker without deleting its pseudo-account"}},
-                [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) {
-                    auto sle = ac.view().peek(loanBrokerKeylet);
-                    if (!sle)
-                        return false;
-                    ac.view().erase(sle);
-                    return true;
-                },
-                XRPAmount{},
-                STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
-                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-                [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) {
-                    PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
-                    loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
-                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
-                });
-        }
-
-        // Deleted object missing sfAccount field (defensive check).
-        // Manually construct the view to place a vault SLE without
-        // sfAccount into the base ledger, then erase it.
-        {
-            Env env{*this, amendments};
-            Account const a1{"A1"};
-            Account const a2{"A2"};
-            env.fund(XRP(1000), a1, a2);
-            env.close();
-
-            OpenView ov{*env.current()};
-
-            auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq()));
-            auto sleVault = std::make_shared(vaultKeylet);
-            sleVault->makeFieldAbsent(sfAccount);
-            ov.rawInsert(sleVault);
-
-            STTx const tx{ttVAULT_DELETE, [](STObject&) {}};
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            ApplyContext ac{
-                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-            auto sle = ac.view().peek(vaultKeylet);
-            if (!BEAST_EXPECT(sle))
-                return;
-            ac.view().erase(sle);
-
-            auto transactor = makeTransactor(ac);
-            if (!BEAST_EXPECT(transactor))
-                return;
-            TER const result = transactor->checkInvariants(
-                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
-            BEAST_EXPECT(result == tecINVARIANT_FAILED);
-            BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
-        }
-    }
-
-    void
-    testTxCheckException()
-    {
-        testcase << "txCheck exception";
-        using namespace jtx;
-
-        // A TxInvariantCheck that throws from the requested hook, so we can
-        // exercise checkInvariantsHelper's catch block via the
-        // transaction-specific layer (as opposed to the protocol layer,
-        // which testObjectHasPseudoAccount's last case already covers via a
-        // real Transactor's finalizeInvariants).
-        enum class ThrowFrom { VisitEntry, Finalize };
-
-        struct ThrowingTxInvariantCheck : TxInvariantCheck
-        {
-            ThrowFrom const throwFrom;
-
-            explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom)
-            {
-            }
-
-            void
-            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
-            {
-                if (throwFrom == ThrowFrom::VisitEntry)
-                    throw std::runtime_error("test-injected visitEntry exception");
-            }
-
-            [[nodiscard]] bool
-            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
-            {
-                if (throwFrom == ThrowFrom::Finalize)
-                    throw std::runtime_error("test-injected finalize exception");
-                return true;
-            }
-        };
-
-        for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize})
-        {
-            Env env{*this};
-            Account const alice{"alice"};
-            env.fund(XRP(1000), alice);
-            env.close();
-
-            OpenView ov{*env.current()};
-            STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
-            test::StreamSink sink{beast::Severity::Warning};
-            beast::Journal const jlog{sink};
-            ApplyContext ac{
-                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-            // visitEntry only runs for entries the transaction touched, so
-            // make a modification for the traversal to report.
-            auto sle = ac.view().peek(keylet::account(alice.id()));
-            if (!BEAST_EXPECT(sle))
-                return;
-            sle->at(sfSequence) = sle->at(sfSequence) + 1;
-            ac.view().update(sle);
-
-            ThrowingTxInvariantCheck throwing{throwFrom};
-            TER terActual = tesSUCCESS;
-            for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
-            {
-                terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing);
-                BEAST_EXPECT(terExpect == terActual);
-                BEAST_EXPECT(sink.messages().str().contains(
-                    "Transaction caused an exception during invariant checks"));
-            }
-        }
-    }
-
-    void
-    testTxCheckFinalizeFalse()
-    {
-        testcase << "txCheck finalize returns false";
-        using namespace jtx;
-
-        // A TxInvariantCheck whose finalize returns false, so we can exercise
-        // the "Transaction has failed one or more transaction invariants"
-        // log path in checkInvariantsHelper independently of any real
-        // transactor. This is the transaction-layer analogue of the
-        // protocol-layer coverage in testObjectHasPseudoAccount / others.
-        struct FailingTxInvariantCheck : TxInvariantCheck
-        {
-            void
-            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
-            {
-            }
-
-            [[nodiscard]] bool
-            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
-            {
-                return false;
-            }
-        };
-
-        Env env{*this};
-        Account const alice{"alice"};
-        env.fund(XRP(1000), alice);
-        env.close();
-
-        OpenView ov{*env.current()};
-        STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
-        test::StreamSink sink{beast::Severity::Warning};
-        beast::Journal const jlog{sink};
-        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
-        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
-
-        FailingTxInvariantCheck failing;
-        TER terActual = tesSUCCESS;
-        for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
-        {
-            terActual = checkInvariants(ac, terActual, XRPAmount{}, failing);
-            BEAST_EXPECT(terExpect == terActual);
-            BEAST_EXPECT(sink.messages().str().contains(
-                "Transaction has failed one or more transaction invariants"));
-            // The protocol-layer log must not appear: only the tx-layer
-            // finalize failed here.
-            BEAST_EXPECT(!sink.messages().str().contains(
-                "Transaction has failed one or more global invariants"));
-        }
-    }
-
-    void
-    testConfidentialMPTTransfer()
-    {
-        using namespace test::jtx;
-        testcase << "ValidConfidentialMPToken";
-
-        MPTID mptID;
-
-        // Generate an MPT with privacy, issue 100 tokens to A2.
-        // Perform a confidential conversion to populate encrypted state.
-        auto const precloseConfidential =
-            [&mptID](Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
-            mptID = mpt.issuanceID();
-
-            mpt.authorize({.account = a2});
-            mpt.pay(a1, a2, 100);
-
-            mpt.generateKeyPair(a1);
-            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
-
-            mpt.generateKeyPair(a2);
-            mpt.convert({
-                .account = a2,
-                .amt = 100,
-                .holderPubKey = mpt.getPubKey(a2),
-            });
-            return true;
-        };
-
-        // badDelete
-        doInvariantCheck(
-            {"MPToken deleted with encrypted fields while COA > 0"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Force an erase of the object while the COA remains 100
-                ac.view().erase(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
-            precloseConfidential);
-
-        // badConsistency
-        doInvariantCheck(
-            {"MPToken encrypted field existence inconsistency"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Remove one of the required encrypted fields to create a mismatch
-                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        doInvariantCheck(
-            {"MPToken encrypted field existence inconsistency"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
-                sleToken->makeFieldAbsent(sfConfidentialBalanceInbox);
-                sleToken->makeFieldAbsent(sfConfidentialBalanceSpending);
-                sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00});
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // requiresPrivacyFlag
-        auto const precloseNoPrivacy = [&mptID](
-                                           Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            // completely omitted the tfMPTCanHoldConfidentialBalance flag here.
-            mpt.create({.flags = tfMPTCanTransfer});
-            mptID = mpt.issuanceID();
-            mpt.authorize({.account = a2});
-            mpt.pay(a1, a2, 100);
-            return true;
-        };
-
-        doInvariantCheck(
-            {"MPToken has encrypted fields but Issuance does not have "
-             "lsfMPTCanHoldConfidentialBalance "
-             "set"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Inject all three encrypted fields consistently (inbox+spending+issuer must be
-                // in sync or badConsistency fires first and masks requiresPrivacyFlag).
-                sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00});
-                sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00});
-                sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00});
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseNoPrivacy);
-
-        // badCOA
-        doInvariantCheck(
-            {"Confidential outstanding amount exceeds total outstanding amount"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-                // Total outstanding is natively 100; bloat the COA over 100
-                sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200);
-                ac.view().update(sleIssuance);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Conservation Violation
-        doInvariantCheck(
-            {"Token conservation violation for MPT"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-
-                sleIssuance->setFieldU64(
-                    sfConfidentialOutstandingAmount,
-                    sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10);
-                ac.view().update(sleIssuance);
-
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0)
-        doInvariantCheck(
-            {"Invariant failed: OutstandingAmount changed "
-             "by confidential transaction that should not "
-             "modify it for MPT"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
-                if (!sleIssuance)
-                    return false;
-                sleIssuance->setFieldU64(
-                    sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1);
-                ac.view().update(sleIssuance);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Send/MergeInbox and zero-COA-delta confidential transactions must not
-        // change public holder MPTAmount.
-        doInvariantCheck(
-            {"Invariant failed: MPTAmount changed by confidential "
-             "transaction that should not modify this field."},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1);
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // badVersion
-        doInvariantCheck(
-            {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending "
-             "changed"},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                Blob const kChangedConfidentialSpending = {0xBA, 0xDD};
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending);
-
-                // DO NOT update sfConfidentialBalanceVersion
-                ac.view().update(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
-            precloseConfidential);
-
-        // Skipping Deleted MPTs (Issuance deleted)
-        auto const precloseOrphan = [&mptID](
-                                        Account const& a1, Account const& a2, Env& env) -> bool {
-            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
-            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
-            mptID = mpt.issuanceID();
-            mpt.authorize({.account = a2});
-
-            // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
-            mpt.generateKeyPair(a1);
-            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
-            mpt.generateKeyPair(a2);
-            mpt.convert({
-                .account = a2,
-                .amt = 0,
-                .holderPubKey = mpt.getPubKey(a2),
-            });
-
-            // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
-            mpt.destroy();
-            return true;
-        };
-
-        doInvariantCheck(
-            {},
-            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
-                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
-                if (!sleToken)
-                    return false;
-                // Safely able to erase the deleted token.
-                ac.view().erase(sleToken);
-                return true;
-            },
-            XRPAmount{},
-            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
-            {tesSUCCESS, tesSUCCESS},
-            precloseOrphan);
-    }
-
-public:
-    void
-    run() override
-    {
-        testXRPNotCreated();
-        testAccountRootsNotRemoved();
-        testAccountRootsDeletedClean();
-        testTypesMatch();
-        testNoXRPTrustLine();
-        testNoDeepFreezeTrustLinesWithoutFreeze();
-        testTransfersNotFrozen();
-        testXRPBalanceCheck();
-        testTransactionFeeCheck();
-        testNoBadOffers();
-        testNoZeroEscrow();
-        testValidNewAccountRoot();
-        testNFTokenPageInvariants();
-        testAMMDeleteInvariants(defaultAmendments());
-        testAMMDeleteInvariants(defaultAmendments() - fixCleanup3_3_0);
-        testPermissionedDomainInvariants(defaultAmendments() | fixCleanup3_1_3);
-        testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
-        testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
-        testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
-        testPermissionedDEXDeletedOfferFallback();
-        testBookDirectoryExchangeRate();
-        testNoModifiedUnmodifiableFields();
-        testValidPseudoAccounts();
-        testValidLoanBroker();
-        testVault();
-        testConfidentialMPTTransfer();
-        testMPT();
-        testInvariantOverwrite(defaultAmendments());
-        testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3);
-        testVaultComputeCoarsestScale();
-        testAMM();
-        testObjectHasPseudoAccount();
-        testSponsorship();
-        testTxCheckException();
-        testTxCheckFinalizeFalse();
-    }
-};
-
-BEAST_DEFINE_TESTSUITE(Invariants, app, xrpl);
-
-}  // namespace xrpl::test
diff --git a/src/test/app/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp
index 3cf9b3a9d9..ece25356fd 100644
--- a/src/test/app/LedgerMaster_test.cpp
+++ b/src/test/app/LedgerMaster_test.cpp
@@ -5,17 +5,21 @@
 #include 
 
 #include 
+#include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -111,6 +115,71 @@ class LedgerMaster_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testCompleteLedgerRange(FeatureBitset features)
+    {
+        // Note that this test is intentionally very similar to
+        // SHAMapStore_test::testLedgerGaps, but has a different
+        // focus.
+
+        testcase("Complete Ledger operations");
+
+        using namespace test::jtx;
+
+        auto const deleteInterval = 8;
+
+        Env env{*this, envconfig(onlineDelete, deleteInterval)};
+
+        auto const alice = Account("alice");
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        auto& lm = env.app().getLedgerMaster();
+        LedgerIndex minSeq = 2;
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        auto& store = env.app().getSHAMapStore();
+        BEAST_EXPECT(store.rendezvous());
+        LedgerIndex lastRotated = store.getLastRotated();
+        BEAST_EXPECTS(maxSeq == 3, to_string(maxSeq));
+        BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers());
+        BEAST_EXPECTS(lastRotated == 3, to_string(lastRotated));
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+        BEAST_EXPECT(minSeq + 1 > maxSeq - 1);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+
+        // Close enough ledgers to rotate a few times
+        for (int i = 0; i < 24; ++i)
+        {
+            for (int t = 0; t < 3; ++t)
+            {
+                env(noop(alice));
+            }
+            env.close();
+            BEAST_EXPECT(store.rendezvous());
+
+            ++maxSeq;
+
+            if (maxSeq == lastRotated + deleteInterval)
+            {
+                minSeq = lastRotated;
+                lastRotated = maxSeq;
+            }
+            BEAST_EXPECTS(
+                env.closed()->header().seq == maxSeq, to_string(env.closed()->header().seq));
+            BEAST_EXPECTS(store.getLastRotated() == lastRotated, to_string(store.getLastRotated()));
+            std::stringstream expectedRange;
+            expectedRange << minSeq << "-" << maxSeq;
+            BEAST_EXPECTS(lm.getCompleteLedgers() == expectedRange.str(), lm.getCompleteLedgers());
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+        }
+    }
+
 public:
     void
     run() override
@@ -124,6 +193,7 @@ public:
     testWithFeats(FeatureBitset features)
     {
         testTxnIdFromIndex(features);
+        testCompleteLedgerRange(features);
     }
 };
 
diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp
index ae1d557bb9..cc54c4feb5 100644
--- a/src/test/app/NFTokenBurn_test.cpp
+++ b/src/test/app/NFTokenBurn_test.cpp
@@ -117,33 +117,30 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 std::cout << "Ledger state is not array!" << std::endl;
                 return;
             }
-            for (json::UInt i = 0; i < state.size(); ++i)
+            for (auto& i : state)
             {
-                if (state[i].isMember(sfNFTokens.jsonName) &&
-                    state[i][sfNFTokens.jsonName].isArray())
+                if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                 {
-                    std::uint32_t const tokenCount = state[i][sfNFTokens.jsonName].size();
-                    std::cout << tokenCount << " NFtokens in page "
-                              << state[i][jss::index].asString() << std::endl;
+                    std::uint32_t const tokenCount = i[sfNFTokens.jsonName].size();
+                    std::cout << tokenCount << " NFtokens in page " << i[jss::index].asString()
+                              << std::endl;
 
                     if (vol == Volume::Noisy)
                     {
-                        std::cout << state[i].toStyledString() << std::endl;
+                        std::cout << i.toStyledString() << std::endl;
                     }
                     else
                     {
                         if (tokenCount > 0)
                         {
-                            std::cout
-                                << "first: " << state[i][sfNFTokens.jsonName][0u].toStyledString()
-                                << std::endl;
+                            std::cout << "first: " << i[sfNFTokens.jsonName][0u].toStyledString()
+                                      << std::endl;
                         }
                         if (tokenCount > 1)
                         {
-                            std::cout
-                                << "last: "
-                                << state[i][sfNFTokens.jsonName][tokenCount - 1].toStyledString()
-                                << std::endl;
+                            std::cout << "last: "
+                                      << i[sfNFTokens.jsonName][tokenCount - 1].toStyledString()
+                                      << std::endl;
                         }
                     }
                 }
@@ -419,12 +416,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
@@ -459,11 +455,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             {
                 json::Value jrr = env.rpc("json", "ledger_data", to_string(jvParams));
 
-                json::Value& state = jrr[jss::result][jss::state];
+                json::Value const& state = jrr[jss::result][jss::state];
 
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto const& i : state)
                 {
-                    BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName));
+                    BEAST_EXPECT(!i.isMember(sfNFTokens.jsonName));
                 }
             }
         };
@@ -757,8 +753,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite
             // We're going to fire an Invariant failure that is difficult to
             // cause.  We do it here because the tools are here.
             //
-            // See Invariants_test.cpp for examples of other invariant tests
-            // that this one is modeled after.
+            // See InvariantsMisc_test.cpp for examples of other invariant
+            // tests that this one is modeled after.
 
             // Generate three closely packed NFTokenPages.
             std::vector nfts = genPackedTokens();
@@ -1076,12 +1072,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                 json::Value& state = jrr[jss::result][jss::state];
 
                 int pageCount = 0;
-                for (json::UInt i = 0; i < state.size(); ++i)
+                for (auto& i : state)
                 {
-                    if (state[i].isMember(sfNFTokens.jsonName) &&
-                        state[i][sfNFTokens.jsonName].isArray())
+                    if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray())
                     {
-                        BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32);
+                        BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32);
                         ++pageCount;
                     }
                 }
diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp
index e262954fdf..80541480e8 100644
--- a/src/test/app/OfferMPT_test.cpp
+++ b/src/test/app/OfferMPT_test.cpp
@@ -24,12 +24,14 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -3679,6 +3681,26 @@ public:
         }
 
         {
+            // Companion to the transfer-rate overflow cases above. The taker
+            // sells TakerPays=MPT(~1.84e18) for TakerGets=XRP(1) against a
+            // same-magnitude poison offer, forcing BookStep::revImp()'s
+            // limitStepOut() to strictly reduce and overflow.
+            //
+            // The taker's own quality is also unrepresentable here
+            // (getRate(TakerGets, TakerPays) == 0: a large MPT numerator over
+            // a small XRP denominator overflows the rate mantissa), but that
+            // no longer short-circuits the transaction -- crossing is
+            // attempted, and only a residual that would REST is stopped. So
+            // this exercises the deeper safety net:
+            // BookStep::forEachOffer's catch(std::overflow_error), which under
+            // featureMPTokensV2 removes the offending offer rather than
+            // propagating.
+            //
+            // Net effect: the poison offer is consumed off the book instead of
+            // being left to poison the next taker, nothing crosses, and the
+            // taker's own offer is not placed because its rate is
+            // unrepresentable -- so tecKILLED, which charges a fee and
+            // advances the sequence.
             Env env{*this, features};
             env.fund(XRP(10'000), issuer, taker);
             env.close();
@@ -3686,18 +3708,9 @@ public:
             MPTTester const token{
                 {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}};
 
-            // Give the taker exactly one MPT. If the old rounding overflow
-            // collapsed the required input to the minimum positive amount, the
-            // taker could afford the bad fill and the balance checks below
-            // would catch the economic gain.
             env(pay(issuer, taker, token(1)));
             env.close();
 
-            // Covers BookStep::revImp() output reduction. The issuer's offer
-            // is fully funded and has no transfer fee, so offer preparation
-            // succeeds. The taker asks for slightly less output, forcing
-            // limitStepOut() to reduce the offer; that strict reduction used
-            // to overflow and leave the poison offer on the book.
             auto const funded = 1'844'674'407'370'955'162LL;
             auto const offerOut = funded + 1;
 
@@ -3711,21 +3724,23 @@ public:
             auto const issuerXRPBefore = env.balance(issuer, XRP);
             auto const takerXRPBefore = env.balance(taker, XRP);
             auto const takerMPTBefore = env.balance(taker, token);
-            auto const fee = env.current()->fees().base;
+            auto const takerSeqBefore = env.seq(taker);
 
-            auto const takerSeq = env.seq(taker);
-            env(offer(taker, token(funded), XRP(1)));
+            auto const takerSeq = takerSeqBefore;
+            auto const fee = env.current()->fees().base;
+            env(offer(taker, token(funded), XRP(1)), Ter(tecKILLED));
             env.close();
 
-            // The former overflow point must not turn into a near-free fill:
-            // the unusable offer is removed, the taker's offer remains, and no
-            // value changes hands beyond the taker's transaction fee.
+            // The overflowing poison offer is removed by BookStep. Nothing
+            // crossed, so no asset changes hands and the taker's offer is not
+            // placed; the fee is burned and the sequence advances.
             BEAST_EXPECT(env.le(poisonKeylet) == nullptr);
             BEAST_EXPECT(
-                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr);
+                env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) == nullptr);
             BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore);
             BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee);
             BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore);
+            BEAST_EXPECT(env.seq(taker) == takerSeqBefore + 1);
         }
 
         {
@@ -5494,6 +5509,215 @@ public:
         }
     }
 
+    void
+    testMPTOfferZeroRate(FeatureBitset features)
+    {
+        // An MPT offer whose quality is not representable must not REST -- on
+        // both the buy and sell sides, with or without a TickSize on the IOU
+        // issuer. Here nothing crosses it, so the whole offer is the remainder
+        // and the result is tecKILLED with nothing placed.
+        //
+        // getRate(TakerGets, TakerPays) returns 0 when the rate overflows: a
+        // large MPT TakerPays (XLS-0082 allows up to 2^63-1) over a small IOU
+        // TakerGets. Such an offer would otherwise (a) rest in the quality-0
+        // book directory, whose index equals getBookBase(), which BookTip's
+        // strict successor scan never returns -- so it can never be crossed yet
+        // still consumes the owner's reserve; and (b) on a TickSize market,
+        // drive the tick-rounding path in applyGuts to divide by a zero rate,
+        // throwing and surfacing as tefEXCEPTION. A normally-priced offer in the
+        // same market is unaffected.
+        //
+        // See testMPTOfferZeroRateCrossable for the other half of the
+        // behavior: an unrepresentable quality that CROSSES is not rejected.
+        testcase("MPT Offer Zero Rate");
+
+        using namespace jtx;
+
+        // Mantissa well above the ~1.84e17 overflow threshold (with an IOU
+        // denominator mantissa of 1e15); still within the XLS-0082 range.
+        auto const kBigMpt = 5'000'000'000'000'000'000LL;
+
+        auto runScenario = [&](bool withTickSize) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            env.fund(XRP(10'000), gw, alice);
+            env.close();
+
+            auto const usd = gw["USD"];
+            env(trust(alice, usd(1'000)));
+            env(pay(gw, alice, usd(100)));
+            env.close();
+
+            if (withTickSize)
+            {
+                auto txn = noop(gw);
+                txn[sfTickSize.fieldName] = 5;
+                env(txn);
+                env.close();
+                BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+            }
+
+            // gw issues a DEX-tradable MPT (CanTrade | CanTransfer by default)
+            // and authorizes alice to hold it.
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice}, .maxAmt = kMaxMpTokenAmount});
+
+            // Buy side: TakerPays = large MPT, TakerGets = small IOU.
+            // getRate() overflows to 0 and nothing crosses -> killed, no
+            // offer placed and no reserve consumed.
+            BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+            env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+
+            // Sell side (tfSell): killed regardless of the flag, since the
+            // rate is computed from the raw amounts either way.
+            BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+            env(offer(alice, mpt(kBigMpt), usd(1), tfSell), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+
+            // Control: a normally-priced offer in the same market still
+            // places (and the tick-size rounding path still works when
+            // withTickSize is set).
+            env(offer(alice, mpt(10'000'000), usd(30)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).size() == 1);
+        };
+
+        // Without a TickSize: previously placed as a dead, never-crossable
+        // quality-0 entry that still consumed reserve.
+        runScenario(/*withTickSize=*/false);
+        // With a TickSize: previously threw and surfaced as tefEXCEPTION. The
+        // rounding is now skipped when the rate is unrepresentable.
+        runScenario(/*withTickSize=*/true);
+    }
+
+    void
+    testZeroRateXrpIouOffer(FeatureBitset features)
+    {
+        // A rate-0 offer is reachable without MPT: for XRP/IOU the "too
+        // good" underflow path makes getRate() return 0 when a tiny IOU
+        // TakerPays is divided by an XRP TakerGets.
+        //
+        // Without featureMPTokensV2 the offer is accepted and placed, but
+        // rests in the quality-0 book directory (whose index == getBookBase),
+        // which BookTip's strict successor scan never returns -- so it can
+        // never be crossed, even by a willing, better-priced counterparty.
+        // With featureMPTokensV2 the same offer crosses nothing and is not
+        // placed, so it is killed.
+        testcase("Zero Rate XRP/IOU Offer");
+
+        using namespace jtx;
+
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        auto const usd = gw["USD"];
+
+        // Smallest-magnitude IOU: mantissa kMinValue, exponent kMinOffset
+        // (= 1e-81). divide(tinyUsd, XRP(1000)) underflows below kMinOffset
+        // and canonicalizes to 0, so getRate() returns 0.
+        auto const tinyUsd = STAmount{usd, UINT64_C(1'000'000'000'000'000), -96};
+
+        auto setup = [&](Env& env) {
+            env.fund(XRP(100'000), gw, alice, bob);
+            env.close();
+            env(trust(alice, usd(1'000)));
+            env(trust(bob, usd(1'000)));
+            env(pay(gw, bob, usd(100)));
+            env.close();
+        };
+
+        // featureMPTokensV2 disabled: legacy behavior -- placed but inert.
+        {
+            Env env{*this, features - featureMPTokensV2};
+            setup(env);
+
+            // TakerPays = tiny IOU, TakerGets = XRP -> rate 0.
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS));
+            env.close();
+
+            auto const aliceOffers = offersOnAccount(env, alice);
+            BEAST_EXPECT(aliceOffers.size() == 1);
+            // Placed in the quality-0 book directory.
+            BEAST_EXPECT(getQuality((*aliceOffers.front())[sfBookDirectory]) == 0);
+
+            // A complementary offer that would cross a usable offer at this
+            // (astronomically good) price does NOT cross it, because the
+            // quality-0 directory is never visited: both offers rest.
+            env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).size() == 1);
+            BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
+        }
+
+        // featureMPTokensV2 disabled, with a TickSize on the IOU issuer: the
+        // tick-rounding path divides by the zero rate and throws, surfacing as
+        // tefEXCEPTION. Legacy behavior, and it must stay that way -- the
+        // guard that skips the rounding is gated on the amendment, since
+        // changing this without a gate would fork a pre-amendment ledger.
+        {
+            Env env{*this, features - featureMPTokensV2};
+            setup(env);
+
+            auto txn = noop(gw);
+            txn[sfTickSize.fieldName] = 5;
+            env(txn);
+            env.close();
+            BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tefEXCEPTION));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+
+        // featureMPTokensV2 enabled: nothing crosses, so the remainder is the
+        // whole offer and it is killed rather than placed.
+        {
+            Env env{*this, features};
+            setup(env);
+
+            BEAST_EXPECT(getRate(XRP(1000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1000)), Ter(tecKILLED));
+            env.close();
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+
+        // featureMPTokensV2 enabled, with a counterparty already on the book:
+        // the same unrepresentable quality now CROSSES. This is the reviewer's
+        // objection with no MPT anywhere in it -- the old preflight check
+        // rejected this outright even though it fills completely and rests
+        // nothing.
+        {
+            Env env{*this, features};
+            setup(env);
+
+            // Bob rests first: he gives usd(10) to receive XRP(1'000).
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1'000), usd(10)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+            // Alice offers up to XRP(1'000) for a dust amount of USD -- rate
+            // 0, at a price bob's offer improves on enormously.
+            BEAST_EXPECT(getRate(XRP(1'000), tinyUsd) == 0);
+            env(offer(alice, tinyUsd, XRP(1'000)), Ter(tesSUCCESS));
+            env.close();
+
+            // Alice asked for dust and got exactly that, so her offer is
+            // fully satisfied and never reaches the book. Bob's offer is
+            // barely touched and stays. The old preflight check rejected this
+            // transaction outright, with no MPT involved anywhere.
+            BEAST_EXPECT(env.balance(alice, usd).value() == tinyUsd);
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        }
+    }
+
     void
     testAutoCreateReserve(FeatureBitset features)
     {
@@ -5589,6 +5813,642 @@ public:
         }
     }
 
+    void
+    testBookOffersMPTFunding(FeatureBitset features)
+    {
+        testcase("book_offers uses MPT issuer capacity, transfer fees, and locks");
+
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const maker{"maker"};
+        Account const buyer{"buyer"};
+
+        // Issuer-owned MPT offers are funded only by remaining issuance
+        // capacity. Once ordinary issuance consumes the cap, book_offers must
+        // report the stale issuer offer as zero-funded.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester musd(
+                {.env = env, .issuer = issuer, .holders = {maker, buyer}, .maxAmt = 100});
+            MPT const usd = musd;
+
+            auto const issuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+
+            musd.pay(issuer, maker, 100);
+
+            auto const issuance = env.le(keylet::mptokenIssuance(usd.mpt()));
+            if (!BEAST_EXPECT(issuance))
+                return;
+            BEAST_EXPECT(issuance->getFieldU64(sfOutstandingAmount) == 100);
+            BEAST_EXPECT(issuance->getFieldU64(sfMaximumAmount) == 100);
+
+            env(offer(maker, XRP(200), usd(100)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() >= 2))
+                return;
+
+            json::Value const& issuerOffer = bookOffers[0u];
+            BEAST_EXPECT(issuerOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(issuerOffer[sfSequence.jsonName] == issuerOfferSeq);
+            BEAST_EXPECT(issuerOffer[jss::owner_funds] == "0");
+            BEAST_EXPECT(issuerOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(issuerOffer[jss::taker_gets_funded][jss::value] == "0");
+            BEAST_EXPECT(issuerOffer.isMember(jss::taker_pays_funded));
+            BEAST_EXPECT(issuerOffer[jss::taker_pays_funded] == "0");
+        }
+
+        // Multiple issuer-owned MPT offers share the same bounded self-issue
+        // capacity. The second offer exercises the cached running balance path
+        // after the first offer has consumed part of the issuer's capacity.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, buyer);
+            env.close();
+
+            MPTTester const musd({.env = env, .issuer = issuer, .holders = {buyer}, .maxAmt = 150});
+            MPT const usd = musd;
+
+            auto const firstIssuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+            auto const secondIssuerOfferSeq = env.seq(issuer);
+            env(offer(issuer, XRP(100), usd(100)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() >= 2))
+                return;
+
+            json::Value const& firstOffer = bookOffers[0u];
+            BEAST_EXPECT(firstOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstIssuerOfferSeq);
+            BEAST_EXPECT(firstOffer[jss::owner_funds] == "150");
+            BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded));
+
+            json::Value const& secondOffer = bookOffers[1u];
+            BEAST_EXPECT(secondOffer[sfAccount.jsonName] == issuer.human());
+            BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondIssuerOfferSeq);
+            BEAST_EXPECT(!secondOffer.isMember(jss::owner_funds));
+            BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
+            BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "50");
+            BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
+            BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "50000000");
+        }
+
+        auto checkTransferFeeBookOffers = [&](std::uint16_t transferFee, auto&& checkOffers) {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester const musd(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {maker, buyer},
+                 .transferFee = transferFee,
+                 .pay = 3'000});
+            MPT const usd = musd;
+            if (transferFee != 0)
+                BEAST_EXPECT(musd.checkTransferFee(transferFee));
+
+            auto const firstOfferSeq = env.seq(maker);
+            env(offer(maker, XRP(1'500), usd(1'500)));
+            auto const secondOfferSeq = env.seq(maker);
+            env(offer(maker, XRP(1'500), usd(1'500)));
+
+            json::Value const jrr = getBookOffers(env, XRP, usd);
+            json::Value const& bookOffers = jrr[jss::offers];
+            BEAST_EXPECT(bookOffers.isArray());
+            if (!BEAST_EXPECT(bookOffers.size() == 2))
+                return;
+
+            checkOffers(bookOffers, firstOfferSeq, secondOfferSeq);
+        };
+
+        // With no MPT transfer fee, two identical maker offers backed by 3000
+        // owner funds are both fully funded for 1500 MPT.
+        checkTransferFeeBookOffers(
+            0,
+            [&](json::Value const& bookOffers,
+                std::uint32_t firstOfferSeq,
+                std::uint32_t secondOfferSeq) {
+                for (auto const i : {0u, 1u})
+                {
+                    json::Value const& offer = bookOffers[i];
+                    BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                    BEAST_EXPECT(
+                        offer[sfSequence.jsonName] == (i == 0u ? firstOfferSeq : secondOfferSeq));
+                    BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
+                    BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
+                }
+                BEAST_EXPECT(bookOffers[0u][jss::owner_funds] == "3000");
+            });
+
+        // With a 50% MPT transfer fee, the first identical maker offer consumes
+        // 2250 owner funds, so the second offer can deliver only 500 MPT.
+        checkTransferFeeBookOffers(
+            50'000,
+            [&](json::Value const& bookOffers,
+                std::uint32_t firstOfferSeq,
+                std::uint32_t secondOfferSeq) {
+                json::Value const& firstOffer = bookOffers[0u];
+                BEAST_EXPECT(firstOffer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(firstOffer[sfSequence.jsonName] == firstOfferSeq);
+                BEAST_EXPECT(firstOffer[jss::owner_funds] == "3000");
+                BEAST_EXPECT(!firstOffer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(!firstOffer.isMember(jss::taker_pays_funded));
+
+                json::Value const& secondOffer = bookOffers[1u];
+                BEAST_EXPECT(secondOffer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(secondOffer[sfSequence.jsonName] == secondOfferSeq);
+                // A 50% MPT transfer fee leaves only 750 owner funds after
+                // the first offer. That can fund 500 MPT delivered to the
+                // taker on the same second offer that was fully funded without
+                // the transfer fee.
+                BEAST_EXPECT(secondOffer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(secondOffer[jss::taker_gets_funded][jss::value] == "500");
+                BEAST_EXPECT(secondOffer.isMember(jss::taker_pays_funded));
+                BEAST_EXPECT(secondOffer[jss::taker_pays_funded] == "500000000");
+            });
+
+        // A large MPT balance used to overflow the fee adjustment. divide()
+        // assumes an IOU mantissa, always normalized into [1e15, 1e16), and
+        // scales the numerator by 1e17. An MPT mantissa is the raw int64
+        // balance, so past ~1.8e17 the scaled quotient leaves uint64 range and
+        // throws -- failing the whole RPC with "internal", so one offer owner
+        // blanked the entire book for every caller.
+        //
+        // The quotient itself always fits, because the branch only runs when
+        // the rate exceeds parity. The cases below pin that at the edges of
+        // the domain rather than leaving it to inspection.
+        auto checkLargeOwnerFunds =
+            [&](std::uint16_t transferFee, std::int64_t funds, char const* expectedFunded) {
+                Env env{*this, features};
+                env.fund(XRP(10'000), issuer, maker, buyer);
+                env.close();
+
+                MPT const usd = MPTTester(
+                    {.env = env,
+                     .issuer = issuer,
+                     .holders = {maker, buyer},
+                     .transferFee = transferFee,
+                     .maxAmt = kMaxMpTokenAmount});
+                env(pay(issuer, maker, usd(funds)));
+                env.close();
+
+                auto const offerSeq = env.seq(maker);
+                env(offer(maker, XRP(100), usd(funds)));
+                env.close();
+
+                json::Value const jrr = getBookOffers(env, XRP, usd);
+                BEAST_EXPECT(!jrr.isMember(jss::error));
+                json::Value const& bookOffers = jrr[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray());
+                if (!BEAST_EXPECT(bookOffers.size() == 1))
+                    return;
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == std::to_string(funds));
+                BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == expectedFunded);
+            };
+
+        // Above the ~2.77e17 boundary at the maximum transfer rate of 1.5:
+        // 3e17 of owner funds covers 2e17 delivered.
+        checkLargeOwnerFunds(kMaxTransferFee, 300'000'000'000'000'000LL, "200000000000000000");
+        // Large balance at the maximum rate. Kept at 6e18 so that 6e18 * 1.5
+        // stays representable: offer crossing's rate-preservation path
+        // overflows above that, which is a separate defect from this one.
+        checkLargeOwnerFunds(kMaxTransferFee, 6'000'000'000'000'000'000LL, "4000000000000000000");
+        // Near-maximum balance at the smallest rate above parity. This is the
+        // largest quotient the branch can produce, and the case the old code
+        // failed earliest on -- its overflow boundary is lowest, ~1.8e17, when
+        // the rate is closest to parity.
+        checkLargeOwnerFunds(1, 9'000'000'000'000'000'000LL, "8999910000899991000");
+
+        // An MPT global lock makes book_offers report the locked MPT book
+        // liquidity as zero-funded instead of funded.
+        {
+            Env env{*this, features};
+
+            env.fund(XRP(10'000), issuer, maker, buyer);
+            env.close();
+
+            MPTTester musd(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {maker, buyer},
+                 .pay = 100,
+                 .flags = kMptDexFlags | tfMPTCanLock});
+            MPT const usd = musd;
+
+            auto const offerSeq = env.seq(maker);
+            env(offer(maker, XRP(100), usd(100)));
+            env.close();
+
+            {
+                json::Value const jrr = getBookOffers(env, XRP, usd);
+                json::Value const& bookOffers = jrr[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray());
+                if (!BEAST_EXPECT(bookOffers.size() == 1))
+                    return;
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount.jsonName] == maker.human());
+                BEAST_EXPECT(offer[sfSequence.jsonName] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == "100");
+                BEAST_EXPECT(!offer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(!offer.isMember(jss::taker_pays_funded));
+            }
+
+            musd.set({.flags = tfMPTLock});
+
+            {
+                // The lock does not remove the offer from the ledger;
+                // book_offers must report it as zero-funded liquidity.
+                auto const bookOffers = getBookOffers(env, XRP, usd)[jss::offers];
+                BEAST_EXPECT(bookOffers.isArray() && bookOffers.size() == 1);
+
+                json::Value const& offer = bookOffers[0u];
+                BEAST_EXPECT(offer[sfAccount] == maker.human());
+                BEAST_EXPECT(offer[sfSequence] == offerSeq);
+                BEAST_EXPECT(offer[jss::owner_funds] == "0");
+                BEAST_EXPECT(offer.isMember(jss::taker_gets_funded));
+                BEAST_EXPECT(offer[jss::taker_gets_funded][jss::value] == "0");
+                BEAST_EXPECT(offer.isMember(jss::taker_pays_funded));
+                BEAST_EXPECT(offer[jss::taker_pays_funded] == "0");
+            }
+        }
+    }
+
+    // getBookBase hashes raw concatenations of fixed-width fields, so the
+    // (Issue,MPT) preimage `currency(20)||mptID(24)||account(20)` and the
+    // (MPT,Issue) preimage `mptID(24)||currency(20)||account(20)` are both
+    // 64 bytes and collide when the bytes align. An attacker picks the IOU
+    // currency, reuses an IOU issuer, and grinds an MPT issuer / sequence;
+    // the per-branch discriminator in getBookBase blocks this.
+    void
+    testBookBaseMixedAssetCollision(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: (Issue,MPT) vs (MPT,Issue) preimage collision");
+
+        // Construction recipe:
+        //   issuerB last 4 bytes == seq_A; mptID_B = BE(5) || issuerB
+        //   currencyA            == mptID_B[0..19] = BE(5) || issuerB[0..15]
+        //   issuerA              == currencyB (both 20-byte all-0xBB)
+        //   sharedIOUIssuer      == acct_A == acct_B
+        AccountID issuerB;
+        AccountID issuerA;
+        Currency currencyB;
+        Currency currencyA;
+        AccountID sharedIOUIssuer;
+        BEAST_EXPECT(issuerB.parseHex("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA00000007"));
+        BEAST_EXPECT(issuerA.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"));
+        BEAST_EXPECT(currencyB.parseHex("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"));
+        BEAST_EXPECT(currencyA.parseHex("00000005AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
+        BEAST_EXPECT(sharedIOUIssuer.parseHex("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"));
+
+        Book const bookA{
+            Asset{Issue{currencyA, sharedIOUIssuer}},
+            Asset{MPTIssue{0x00000007u, issuerA}},
+            std::nullopt};
+        Book const bookB{
+            Asset{MPTIssue{0x00000005u, issuerB}},
+            Asset{Issue{currencyB, sharedIOUIssuer}},
+            std::nullopt};
+
+        BEAST_EXPECT(bookA != bookB);
+        BEAST_EXPECT(getBookBase(bookA) != getBookBase(bookB));
+    }
+
+    // (MPT,MPT) bodies are 48 bytes and can't length-match the 64-byte
+    // mixed branches, but tag them too for symmetry/future-proofing; this
+    // test also pins the directional asymmetry of an (MPT,MPT) book.
+    void
+    testBookBaseMptMptDistinct(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: (MPT,MPT) distinguishes from mixed branches");
+
+        AccountID issuerX;
+        AccountID issuerY;
+        Currency currency;
+        AccountID iouIssuer;
+        BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111"));
+        BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222"));
+        BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333"));
+        BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444"));
+
+        Asset const mptX{MPTIssue{1u, issuerX}};
+        Asset const mptY{MPTIssue{2u, issuerY}};
+        Book const mptBook{mptX, mptY, std::nullopt};
+        Book const mixedBook{mptX, Asset{Issue{currency, iouIssuer}}, std::nullopt};
+        Book const reversedMptBook{mptY, mptX, std::nullopt};
+
+        BEAST_EXPECT(getBookBase(mptBook) != getBookBase(mixedBook));
+        BEAST_EXPECT(getBookBase(mptBook) != getBookBase(reversedMptBook));
+    }
+
+    void
+    testBookBaseDomainMptDistinct(FeatureBitset /*features*/)
+    {
+        testcase("getBookBase: domain does not reopen MPT preimage collisions");
+
+        // The type tag is a front prefix and the domain is a 32-byte suffix, so a
+        // domain'd book must (a) stay distinct from its public counterpart and
+        // (b) preserve the mixed-branch tag distinction that the public case has.
+        AccountID issuerX, issuerY, iouIssuer;
+        Currency currency;
+        BEAST_EXPECT(issuerX.parseHex("1111111111111111111111111111111111111111"));
+        BEAST_EXPECT(issuerY.parseHex("2222222222222222222222222222222222222222"));
+        BEAST_EXPECT(currency.parseHex("3333333333333333333333333333333333333333"));
+        BEAST_EXPECT(iouIssuer.parseHex("4444444444444444444444444444444444444444"));
+
+        uint256 const domainA = uint256::fromVoid(
+            "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD"
+            "\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD\xDD");
+
+        Asset const mptX{MPTIssue{1u, issuerX}};
+        Asset const iou{Issue{currency, iouIssuer}};
+
+        // (a) same pair, public vs domain'd -> distinct directories.
+        Book const publicBook{mptX, iou, std::nullopt};
+        Book const domainBook{mptX, iou, domainA};
+        BEAST_EXPECT(getBookBase(publicBook) != getBookBase(domainBook));
+
+        // (b) mixed-branch tag distinction still holds *with* a domain set:
+        //     (MPT,Issue) vs (Issue,MPT), both domain'd, must not collide.
+        Book const mi{mptX, iou, domainA};
+        Book const im{iou, mptX, domainA};
+        BEAST_EXPECT(mi != im);
+        BEAST_EXPECT(getBookBase(mi) != getBookBase(im));
+    }
+
+    void
+    testMPTOfferZeroRateCrossable(FeatureBitset features)
+    {
+        // An unrepresentable quality does not imply an offer that cannot
+        // function. "Can never be crossed" describes an offer that RESTS:
+        // crossing happens in applyGuts, before any residual is placed in the
+        // book, so an offer whose rate is unrepresentable can still consume a
+        // resting offer in full and never reach the quality-0 directory.
+        //
+        // The two sides of one trade do not have the same rate
+        // representability: getRate(TakerGets, TakerPays) overflows to 0 for
+        // the side paying a large MPT, but not for the side paying XRP. So a
+        // preflight rejection keyed on getRate() == 0 admits the resting half
+        // of a trade and rejects the crossing half.
+        testcase("MPT Offer Zero Rate - crossable quality");
+
+        using namespace jtx;
+
+        // Above the rate-overflow threshold: divide() scales the XRP
+        // denominator up to a 1e15 mantissa and then evaluates
+        // muldiv(mptMantissa, 1e17, denMantissa), which exceeds 2^64 -- so
+        // getRate() takes its catch-all and returns 0.
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        // Both scenarios are the same trade against the same resting offer,
+        // and both execute identically (at bob's price). They differ only in
+        // the price alice quotes, and therefore only in whether the rate on
+        // HER side of the book is representable.
+        auto runScenario = [&](STAmount const& aliceQuote, bool rateRepresentable) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            auto const bob = Account{"bob"};
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+
+            env(pay(gw, bob, mpt(kBigMpt)));
+            env.close();
+
+            // Bob rests the sell side: TakerPays = XRP(1), TakerGets =
+            // kBigMpt. getRate(TakerGets, TakerPays) is representable in this
+            // direction, so preflight admits it and it rests at a normal
+            // quality.
+            BEAST_EXPECT(getRate(mpt(kBigMpt), XRP(1)) != 0);
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+            env.close();
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+            // Alice takes it from the other side: TakerPays = kBigMpt,
+            // TakerGets = her quote.
+            BEAST_EXPECT((getRate(aliceQuote, mpt(kBigMpt)) != 0) == rateRepresentable);
+
+            auto const bobXrpBefore = env.balance(bob).value().xrp();
+            env(offer(alice, mpt(kBigMpt), aliceQuote), Ter(tesSUCCESS));
+            env.close();
+
+            // Alice's offer crosses bob's in full, so it never rests: nothing
+            // ends up in the quality-0 directory, no reserve is stranded, and
+            // the tick-rounding divide is never reached with a zero rate.
+            BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+            BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+            // And it executes at bob's 1 XRP, whatever alice quoted.
+            BEAST_EXPECT(env.balance(bob).value().xrp() == bobXrpBefore + XRP(1).value().xrp());
+        };
+
+        // Alice quotes bob's exact price. getRate() overflows to 0 on her
+        // side, yet the offer crosses in full and never rests.
+        runScenario(XRP(1), /*rateRepresentable=*/false);
+        // Alice quotes a price worse for herself, which halves the rate into
+        // representable range. Same execution as above: she pays 1 XRP for
+        // kBigMpt. The two cases are therefore numerically, not economically,
+        // different.
+        runScenario(XRP(2), /*rateRepresentable=*/true);
+    }
+
+    void
+    testMPTOfferZeroRatePartialCross(FeatureBitset features)
+    {
+        // The case between the two extremes: an unrepresentable quality that
+        // crosses PARTIALLY. The crossed portion must execute -- it never
+        // touches the book -- while the residual must not be placed, since it
+        // would rest in the quality-0 directory holding a reserve it could
+        // never earn back by being crossed.
+        testcase("MPT Offer Zero Rate - partial cross");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        Env env{*this, features};
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        env.fund(XRP(10'000), gw, alice, bob);
+        env.close();
+
+        MPT const mpt = MPTTester(
+            {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+
+        env(pay(gw, bob, mpt(kBigMpt)));
+        env.close();
+
+        // Bob rests a sell of kBigMpt for XRP(1) -- representable on his side.
+        auto const bobSeq = env.seq(bob);
+        env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+        env.close();
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+        // Alice asks for twice what bob has, at the same price. Her rate is
+        // unrepresentable: mantissa ratio 4e17 / 2e15 = 200 > ~184.47.
+        BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0);
+
+        auto const aliceXrpBefore = env.balance(alice).value().xrp();
+        auto const fee = env.current()->fees().base;
+
+        env(offer(alice, mpt(2 * kBigMpt), XRP(2)), Ter(tesSUCCESS));
+        env.close();
+
+        // The half that crossed executed at bob's price...
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+        BEAST_EXPECT(
+            env.balance(alice).value().xrp() == aliceXrpBefore - XRP(1).value().xrp() - fee);
+        // ...and the half that did not is dropped rather than placed, so no
+        // offer rests and no reserve is consumed.
+        BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        BEAST_EXPECT((*env.le(alice))[sfOwnerCount] == 1);  // the MPToken only
+    }
+
+    void
+    testMPTOfferZeroRateTickSizeCross(FeatureBitset features)
+    {
+        // TickSize plus an unrepresentable quality plus a counterparty on the
+        // book. The tick-rounding path is skipped for a zero rate, since it
+        // would divide by that rate and throw, so the offer crosses at its raw
+        // price. Every other TickSize case here faces an empty book, making
+        // this the only coverage that the skip leaves crossing intact --
+        // without it this transaction is tefEXCEPTION.
+        testcase("MPT Offer Zero Rate - tick size with crossing");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 5'000'000'000'000'000'000LL;
+
+        Env env{*this, features};
+        auto const gw = Account{"gateway"};
+        auto const alice = Account{"alice"};
+        auto const bob = Account{"bob"};
+        env.fund(XRP(10'000), gw, alice, bob);
+        env.close();
+
+        auto const usd = gw["USD"];
+        env(trust(alice, usd(1'000)));
+        env(pay(gw, alice, usd(100)));
+        env.close();
+
+        auto txn = noop(gw);
+        txn[sfTickSize.fieldName] = 5;
+        env(txn);
+        env.close();
+        BEAST_EXPECT((*env.le(gw))[sfTickSize] == 5);
+
+        MPT const mpt = MPTTester(
+            {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+        env(pay(gw, bob, mpt(kBigMpt)));
+        env.close();
+
+        // Bob rests the sell side; representable in that direction.
+        BEAST_EXPECT(getRate(mpt(kBigMpt), usd(1)) != 0);
+        auto const bobSeq = env.seq(bob);
+        env(offer(bob, usd(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+        env.close();
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr);
+
+        // Alice takes it from the unrepresentable side, with the tick size in
+        // force on her TakerGets.
+        BEAST_EXPECT(getRate(usd(1), mpt(kBigMpt)) == 0);
+        env(offer(alice, mpt(kBigMpt), usd(1)), Ter(tesSUCCESS));
+        env.close();
+
+        BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+        BEAST_EXPECT(env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) == nullptr);
+        BEAST_EXPECT(offersOnAccount(env, alice).empty());
+    }
+
+    void
+    testMPTOfferZeroRateFlags(FeatureBitset features)
+    {
+        // A zero rate must not change what tfFillOrKill and tfImmediateOrCancel
+        // do. Both are handled above the unrepresentable-quality guard, but the
+        // ordering is not observable and no test can pin it: the guard returns
+        // the same pair either flag would. Immediate-or-cancel matches it by
+        // construction, and fill-or-kill disables partial payment
+        // (OfferCreate.cpp: flowCross is passed !tfFillOrKill), so a
+        // not-fully-fillable offer leaves crossed == false and both paths give
+        // {tecKILLED, false}. What this does cover is flags combined with an
+        // unrepresentable quality, which nothing else exercises.
+        testcase("MPT Offer Zero Rate - IOC and FoK");
+
+        using namespace jtx;
+
+        auto const kBigMpt = 200'000'000'000'000'000LL;
+
+        auto const runScenario = [&](std::uint32_t flags, TER expected) {
+            Env env{*this, features};
+            auto const gw = Account{"gateway"};
+            auto const alice = Account{"alice"};
+            auto const bob = Account{"bob"};
+            env.fund(XRP(10'000), gw, alice, bob);
+            env.close();
+
+            MPT const mpt = MPTTester(
+                {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount});
+            env(pay(gw, bob, mpt(kBigMpt)));
+            env.close();
+
+            auto const bobSeq = env.seq(bob);
+            env(offer(bob, XRP(1), mpt(kBigMpt)), Ter(tesSUCCESS));
+            env.close();
+
+            // Asking for twice what bob has forces a partial cross, so the
+            // flag handling -- not the fully-crossed early return -- decides.
+            BEAST_EXPECT(getRate(XRP(2), mpt(2 * kBigMpt)) == 0);
+            env(offer(alice, mpt(2 * kBigMpt), XRP(2), flags), Ter(expected));
+            env.close();
+
+            auto const bobOfferLive =
+                env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq))) != nullptr;
+            if (isTesSuccess(expected))
+            {
+                // Immediate-or-cancel: the crossed part is kept, the rest is
+                // cancelled -- the same shape the guard would produce.
+                BEAST_EXPECT(env.balance(alice, mpt) == mpt(kBigMpt));
+                BEAST_EXPECT(!bobOfferLive);
+            }
+            else
+            {
+                // Fill-or-kill: the offer is not fully fillable, so nothing
+                // crosses at all and bob's offer survives untouched.
+                BEAST_EXPECT(env.balance(alice, mpt) == mpt(0));
+                BEAST_EXPECT(bobOfferLive);
+            }
+            BEAST_EXPECT(offersOnAccount(env, alice).empty());
+        };
+
+        runScenario(tfImmediateOrCancel, tesSUCCESS);
+        runScenario(tfFillOrKill, tecKILLED);
+    }
+
     void
     testAll(FeatureBitset features)
     {
@@ -5649,7 +6509,17 @@ public:
         testPartiallyFundedMPTInputOfferZeroInput(features);
         testFillOrKill(features);
         testTickSize(features);
+        testMPTOfferZeroRate(features);
+        testMPTOfferZeroRateCrossable(features);
+        testMPTOfferZeroRatePartialCross(features);
+        testMPTOfferZeroRateTickSizeCross(features);
+        testMPTOfferZeroRateFlags(features);
+        testZeroRateXrpIouOffer(features);
+        testBookOffersMPTFunding(features);
         testAutoCreateReserve(features);
+        testBookBaseMixedAssetCollision(features);
+        testBookBaseMptMptDistinct(features);
+        testBookBaseDomainMptDistinct(features);
     }
 
     void
diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp
index ff4a024cb8..87da13087f 100644
--- a/src/test/app/PathMPT_test.cpp
+++ b/src/test/app/PathMPT_test.cpp
@@ -14,6 +14,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 
@@ -25,6 +27,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +37,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -231,6 +236,102 @@ public:
         env.require(Balance("bob", usd(24)));
     }
 
+    void
+    sourceCurrencyWithSendMax()
+    {
+        testcase("source currency with send_max");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gateway");
+        env.fund(XRP(10'000), alice, bob, gw);
+
+        MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}});
+        env(pay(gw, alice, usd(25)));
+        env.close();
+
+        // MPT source_currencies entries do not carry an issuer. A matching
+        // send_max identifies the same issuance, so the request should not run
+        // the IOU issuer reconciliation path.
+        auto const result = findPathsRequest(
+            env,
+            alice,
+            bob,
+            usd(-1),
+            std::optional(usd(10).value()),
+            std::optional(usd.mpt()));
+        BEAST_EXPECTS(!result.isMember(jss::error), result.toStyledString());
+
+        auto const& alternatives = result[jss::alternatives];
+        if (BEAST_EXPECT(alternatives.size() == 1))
+        {
+            auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]);
+            auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]);
+            BEAST_EXPECTS(equal(sa, usd(10)), sa.getFullText());
+            BEAST_EXPECTS(equal(da, usd(10)), da.getFullText());
+        }
+    }
+
+    void
+    maxedOutMPTPathfinding()
+    {
+        testcase("maxed-out MPT pathfinding");
+        using namespace jtx;
+
+        auto hasMPT = [](auto const& assets, MPT const& mpt) {
+            return std::ranges::any_of(assets, [&](auto const& asset) {
+                return asset.template holds() && asset.template get() == mpt.mpt();
+            });
+        };
+
+        Env env = pathTestEnv();
+        auto const gw = Account("gateway");
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const carol = Account("carol");
+
+        env.fund(XRP(10'000), gw, alice, bob, carol);
+        env.close();
+
+        MPT const usd =
+            MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}, .maxAmt = 100});
+        env(pay(gw, alice, usd(90)));
+        env(pay(gw, bob, usd(10)));
+        env.close();
+
+        auto const cache =
+            std::make_shared(env.current(), env.app().getJournal("AssetCache"));
+
+        BEAST_EXPECT(hasMPT(accountSourceAssets(alice.id(), cache, false), usd));
+        BEAST_EXPECT(hasMPT(accountDestAssets(bob.id(), cache, false), usd));
+        BEAST_EXPECT(hasMPT(accountDestAssets(carol.id(), cache, false), usd));
+
+        // A fully minted issuance should not be advertised as issuer-side
+        // mintable source liquidity.
+        BEAST_EXPECT(!hasMPT(accountSourceAssets(gw.id(), cache, false), usd));
+
+        auto [st, sa, da] = findPaths(env, alice, bob, usd(5));
+        BEAST_EXPECT(st.empty());
+        BEAST_EXPECT(equal(sa, usd(5)));
+        BEAST_EXPECT(equal(da, usd(5)));
+
+        env(offer(carol, usd(5), XRP(5)));
+        env.close();
+
+        std::tie(st, sa, da) = findPaths(env, alice, bob, drops(-1), usd(100).value());
+        BEAST_EXPECT(sa == usd(5));
+        BEAST_EXPECT(equal(da, XRP(5)));
+        if (BEAST_EXPECT(st.size() == 1 && st[0].size() == 1))
+        {
+            auto const& pathElem = st[0][0];
+            BEAST_EXPECT(
+                pathElem.isOffer() && pathElem.getIssuerID() == xrpAccount() &&
+                pathElem.getCurrency() == xrpCurrency());
+        }
+    }
+
     void
     pathFind(bool const domainEnabled)
     {
@@ -441,6 +542,124 @@ public:
         }
     }
 
+    // Regression test: the Pathfinder constructor must honor the
+    // caller-supplied srcAmount (= the user's send_max from PathRequest)
+    // when ranking candidate paths in convert_all mode.
+    //
+    // Background. The MPT-DEX refactor of `Pathfinder::Pathfinder`
+    // (src/xrpld/rpc/detail/Pathfinder.cpp) replaced the original
+    // `mSrcAmount(srcAmount.value_or(...))` initializer with an
+    // unconditional `amountFromPathAsset(...)` call. The latter always
+    // returns the negative "no limit" STAmount sentinel, so the
+    // `srcAmount` constructor parameter became dead code:
+    // `getPathLiquidity` and `computePathRanks` ran `rippleCalculate`
+    // with `saMaxAmountReq` = sentinel and recorded each path's
+    // saturated capacity instead of the capacity reachable inside
+    // send_max.
+    //
+    // In convert_all_ mode (the only mode that allows send_max),
+    // `Pathfinder::rankPaths` ignores quality and orders purely by
+    // liquidity, then `Pathfinder::getBestPaths` only fills the last
+    // (kMaxPaths-th = 4th) slot when `pathRank.liquidity >= remaining`.
+    // For convert_all_ `remaining = largestAmount(dstAmount_)`, so the
+    // last slot effectively never fills and the cut keeps the top 3
+    // ranked paths. With the wrong (unbounded-budget) ranking, a
+    // low-capacity / high-rate path that would actually deliver the
+    // most under the user's send_max can be excluded entirely.
+    //
+    // Topology. Four candidate paths from alice's XRP to bob's USD-MPT,
+    // each via a distinct IOU intermediary issued by a different market
+    // maker:
+    //
+    //   charlie: XRP(1000) -> AUD(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   dave:    XRP(1000) -> EUR(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   eve:     XRP(1000) -> GBP(1000) -> USD(500)    cap 1000 XRP, rate 0.5
+    //   frank:   XRP(50)   -> JPY(50)   -> USD(75)     cap   50 XRP, rate 1.5
+    //
+    // Alice queries findPaths with destination = USD-MPT(-1) (convert_all)
+    // and send_max = XRP(100).
+    //
+    // Bug-free ranking (post-fix), with srcAmount = XRP(100):
+    //   charlie/dave/eve liquidity = min(100, 1000) * 0.5 = 50 USD each
+    //   frank   liquidity         = min(100, 50)   * 1.5 = 75 USD
+    // -> frank ranks first; the flow uses frank's 50 XRP at 1.5 (=75 USD)
+    //    plus 50 XRP via a 0.5-rate path (=25 USD), delivering USD(100).
+    //
+    // Pre-fix ranking, with srcAmount silently replaced by the sentinel:
+    //   charlie/dave/eve liquidity = 1000 * 0.5 = 500 USD each
+    //   frank   liquidity         =   50 * 1.5 = 75 USD
+    // -> frank ranks 4th; the last-slot rule excludes it from the
+    //    surviving path set, the cut keeps the three 0.5-rate paths,
+    //    and the flow delivers only 100 * 0.5 = USD(50).
+    //
+    // This test asserts the post-fix outcome (USD(100)). On the pre-fix
+    // tree the assertion fails with USD(50).
+    void
+    convertAllSendMaxRanking()
+    {
+        testcase("convert_all + send_max: srcAmount governs path ranking");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gw = Account("gateway");
+        auto const charlie = Account("charlie");
+        auto const dave = Account("dave");
+        auto const eve = Account("eve");
+        auto const frank = Account("frank");
+
+        env.fund(XRP(10'000), alice, bob, gw, charlie, dave, eve, frank);
+        env.close();
+
+        // USD MPT issued by gw; the four market makers and bob are holders.
+        // alice is not a holder because she only pays XRP; USD only ever
+        // flows from gw / market-maker offers to bob.
+        MPT const usd =
+            MPTTester({.env = env, .issuer = gw, .holders = {charlie, dave, eve, frank, bob}});
+
+        // Capitalize each market maker with the USD-MPT they will sell.
+        env(pay(gw, charlie, usd(500)));
+        env(pay(gw, dave, usd(500)));
+        env(pay(gw, eve, usd(500)));
+        env(pay(gw, frank, usd(75)));
+        env.close();
+
+        // Each market maker issues their own intermediate IOU.
+        auto const aud = charlie["AUD"];
+        auto const eur = dave["EUR"];
+        auto const gbp = eve["GBP"];
+        auto const jpy = frank["JPY"];
+
+        // Three high-capacity, low-rate paths (1 XRP -> 0.5 USD-MPT,
+        // capacity 1000 XRP each).
+        env(offer(charlie, XRP(1'000), aud(1'000)));
+        env(offer(charlie, aud(1'000), usd(500)));
+        env(offer(dave, XRP(1'000), eur(1'000)));
+        env(offer(dave, eur(1'000), usd(500)));
+        env(offer(eve, XRP(1'000), gbp(1'000)));
+        env(offer(eve, gbp(1'000), usd(500)));
+
+        // One low-capacity, high-rate path (1 XRP -> 1.5 USD-MPT,
+        // capacity 50 XRP).
+        env(offer(frank, XRP(50), jpy(50)));
+        env(offer(frank, jpy(50), usd(75)));
+        env.close();
+
+        // ripple_path_find with convert_all (USD(-1)) and send_max XRP(100).
+        STPathSet st;
+        STAmount sa;
+        STAmount da;
+        std::tie(st, sa, da) =
+            findPaths(env, alice, bob, usd(-1), std::optional(XRP(100).value()));
+
+        // Post-fix: frank's high-rate path is included in the surviving
+        // path set, so the flow uses 50 XRP at 1.5 plus 50 XRP at 0.5,
+        // delivering exactly USD(100) on alice's 100-XRP budget.
+        BEAST_EXPECT(sa == XRP(100));
+        BEAST_EXPECT(equal(da, usd(100)));
+    }
+
     void
     run() override
     {
@@ -448,6 +667,9 @@ public:
         noDirectPathNoIntermediaryNoAlternatives();
         directPathNoIntermediary();
         paymentAutoPathFind();
+        sourceCurrencyWithSendMax();
+        maxedOutMPTPathfinding();
+        convertAllSendMaxRanking();
         for (auto const domainEnabled : {false, true})
         {
             pathFind(domainEnabled);
diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp
index 29b4a5b048..cd61668b03 100644
--- a/src/test/app/Path_test.cpp
+++ b/src/test/app/Path_test.cpp
@@ -147,7 +147,8 @@ public:
         STAmount const& saDstAmount,
         std::optional const& saSendMax = std::nullopt,
         std::optional const& saSrcCurrency = std::nullopt,
-        std::optional const& domain = std::nullopt)
+        std::optional const& domain = std::nullopt,
+        std::optional const& saSrcIssuer = std::nullopt)
     {
         using namespace jtx;
 
@@ -181,6 +182,10 @@ public:
             auto& sc = params[jss::source_currencies] = json::ValueType::Array;
             json::Value j = json::ValueType::Object;
             j[jss::currency] = to_string(saSrcCurrency.value());
+            // Optional issuer for tests that need to exercise
+            // source_currencies entries more precisely than currency alone.
+            if (saSrcIssuer)
+                j[jss::issuer] = toBase58(*saSrcIssuer);
             sc.append(j);
         }
         if (domain)
@@ -209,10 +214,11 @@ public:
         STAmount const& saDstAmount,
         std::optional const& saSendMax = std::nullopt,
         std::optional const& saSrcCurrency = std::nullopt,
-        std::optional const& domain = std::nullopt)
+        std::optional const& domain = std::nullopt,
+        std::optional const& saSrcIssuer = std::nullopt)
     {
-        json::Value result =
-            findPathsRequest(env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain);
+        json::Value result = findPathsRequest(
+            env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain, saSrcIssuer);
         BEAST_EXPECT(!result.isMember(jss::error));
 
         STAmount da;
@@ -325,6 +331,53 @@ public:
         BEAST_EXPECT(result.isMember(jss::error));
     }
 
+    void
+    sourceCurrencyIssuerSelection()
+    {
+        testcase("source currency issuer selection");
+        using namespace jtx;
+
+        Env env = pathTestEnv();
+        auto const alice = Account("alice");
+        auto const bob = Account("bob");
+        auto const gateway = Account("gateway");
+
+        env.fund(XRP(10000), alice, bob, gateway);
+        env.close();
+
+        auto const usd = gateway["USD"];
+        env.trust(usd(600), alice);
+        env.trust(usd(700), bob);
+        env.trust(alice["USD"](700), bob);
+        env(pay(gateway, alice, usd(70)));
+        env(pay(gateway, bob, usd(50)));
+        env.close();
+
+        // Ask for USD from an explicit source issuer while send_max is
+        // Alice-issued USD. The parser should choose gateway-issued USD
+        // because gateway is the issuer in source_currencies.
+        //
+        // The Alice/Bob trust line is intentional: if Alice-issued USD is also
+        // considered as a source asset, pathfinding can produce an additional
+        // alternative. The single expected alternative below verifies that only
+        // the explicit issuer is selected.
+        auto const result = findPathsRequest(
+            env,
+            alice,
+            bob,
+            bob["USD"](-1),
+            alice["USD"](100).value(),
+            usd.currency,
+            std::nullopt,
+            gateway.id());
+        auto const& alternatives = result[jss::alternatives];
+        BEAST_EXPECT(alternatives.size() == 1);
+        auto const sa = amountFromJson(sfGeneric, alternatives[0u][jss::source_amount]);
+        auto const da = amountFromJson(sfGeneric, alternatives[0u][jss::destination_amount]);
+        BEAST_EXPECTS(equal(sa, usd(100)), sa.getFullText());
+        BEAST_EXPECTS(equal(da, bob["USD"](100)), da.getFullText());
+    }
+
     void
     noDirectPathNoIntermediaryNoAlternatives()
     {
@@ -1968,6 +2021,7 @@ public:
     run() override
     {
         sourceCurrenciesLimit();
+        sourceCurrencyIssuerSelection();
         noDirectPathNoIntermediaryNoAlternatives();
         directPathNoIntermediary();
         paymentAutoPathFind();
diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp
index 82019affba..0a8c51c56a 100644
--- a/src/test/app/SHAMapStore_test.cpp
+++ b/src/test/app/SHAMapStore_test.cpp
@@ -1,7 +1,9 @@
 #include 
 #include 
 #include 
+#include 
 
+#include 
 #include 
 #include 
 #include 
@@ -22,16 +24,21 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 
 namespace xrpl::test {
 
@@ -42,9 +49,8 @@ class SHAMapStore_test : public beast::unit_test::Suite
     static auto
     onlineDelete(std::unique_ptr cfg)
     {
-        cfg->ledgerHistory = kDeleteInterval;
-        auto& section = cfg->section(Sections::kNodeDatabase);
-        section.set(Keys::kOnlineDelete, std::to_string(kDeleteInterval));
+        cfg = jtx::onlineDelete(std::move(cfg), kDeleteInterval);
+        cfg->section(Sections::kNodeDatabase).set(Keys::kRecoveryWaitSeconds, "1");
         return cfg;
     }
 
@@ -143,11 +149,11 @@ class SHAMapStore_test : public beast::unit_test::Suite
         auto& store = env.app().getSHAMapStore();
 
         int ledgerSeq = 3;
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
         BEAST_EXPECT(!store.getLastRotated());
 
         env.close();
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         auto ledger = env.rpc("ledger", "validated");
         BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++)));
@@ -227,7 +233,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(kDeleteInterval + 4)));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + 3);
         lastRotated = store.getLastRotated();
@@ -254,7 +260,7 @@ public:
                 !getHash(ledgers[i]).empty());
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + lastRotated);
 
@@ -292,7 +298,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         // The database will always have back to ledger 2,
         // regardless of lastRotated.
@@ -307,7 +313,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
         BEAST_EXPECT(lastRotated != store.getLastRotated());
@@ -323,7 +329,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, kDeleteInterval + 1, lastRotated);
         BEAST_EXPECT(lastRotated != store.getLastRotated());
@@ -362,7 +368,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - 2, 2);
         BEAST_EXPECT(lastRotated == store.getLastRotated());
@@ -372,7 +378,7 @@ public:
         BEAST_EXPECT(!rpc::containsError(canDelete[jss::result]));
         BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == ledgerSeq + (kDeleteInterval / 2));
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - 2, 2);
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
@@ -385,7 +391,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -401,7 +407,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -413,7 +419,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - firstBatch, firstBatch);
 
@@ -435,7 +441,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -447,7 +453,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -468,7 +474,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         BEAST_EXPECT(store.getLastRotated() == lastRotated);
 
@@ -480,7 +486,7 @@ public:
             BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true));
         }
 
-        store.rendezvous();
+        BEAST_EXPECT(store.rendezvous());
 
         ledgerCheck(env, ledgerSeq - lastRotated, lastRotated);
 
@@ -603,6 +609,302 @@ public:
         BEAST_EXPECT(dbr->getName() == "3");
     }
 
+    void
+    testLedgerGaps()
+    {
+        // Note that this test is intentionally very similar to
+        // LedgerMaster_test::testCompleteLedgerRange, but has a different
+        // focus.
+
+        testcase("Wait for ledger gaps to fill in");
+
+        using namespace test::jtx;
+
+        Env env{*this, envconfig(onlineDelete)};
+
+        auto failureMessage = [&](char const* label, auto expected, auto actual) {
+            std::stringstream ss;
+            ss << label << ": Expected: " << expected << ", Got: " << actual;
+            return ss.str();
+        };
+
+        auto const alice = Account("alice");
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        auto& lm = env.app().getLedgerMaster();
+        LedgerIndex minSeq = 2;
+        LedgerIndex maxSeq = env.closed()->header().seq;
+        auto& store = env.app().getSHAMapStore();
+        LedgerIndex lastRotated = store.getLastRotated();
+        auto& netOPs = env.app().getOPs();
+        while (lastRotated != 3)
+        {
+            BEAST_EXPECT(store.rendezvous());
+            lastRotated = store.getLastRotated();
+        }
+        BEAST_EXPECTS(maxSeq == 3, std::to_string(maxSeq));
+        BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers());
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0);
+        BEAST_EXPECT(minSeq + 1 > maxSeq - 1);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2);
+        BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2);
+
+        auto expectedRange =
+            [](LedgerIndex minSeq, std::vector const& deleteSeqs, LedgerIndex maxSeq) {
+                std::stringstream expectedRange;
+                expectedRange << minSeq;
+                auto lastDelete = minSeq - 1;
+                for (auto deleteSeq : deleteSeqs)
+                {
+                    if (deleteSeq <= lastDelete)
+                        continue;
+                    expectedRange << "-" << (deleteSeq - 1);
+                    if (deleteSeq + 1 <= maxSeq)
+                        expectedRange << "," << (deleteSeq + 1);
+                    lastDelete = deleteSeq;
+                }
+                if (lastDelete + 1 < maxSeq)
+                {
+                    expectedRange << "-" << maxSeq;
+                }
+                return expectedRange.str();
+            };
+
+        auto deleteLedgerSeq =
+            [&lm, &store, &netOPs, &minSeq, &lastRotated, &expectedRange, &failureMessage, this](
+                Env& env,
+                LedgerIndex& maxSeq,
+                std::vector& deleteSeqs) -> LedgerIndex {
+            using namespace std::chrono_literals;
+
+            // The next ledger will trigger a rotation. Delete the
+            // current ledger from LedgerMaster.
+
+            netOPs.setMode(OperatingMode::CONNECTED);
+
+            LedgerIndex const deleteSeq = maxSeq;
+            std::size_t iterations = 30;
+            while (!lm.haveLedger(deleteSeq) && --iterations > 0)
+            {
+                std::this_thread::sleep_for(10ms);
+            }
+            // Even the slowest machines should be able to finalize deleteSeq within 10
+            // loops (100ms). If this test ever actually fails feel free to lower this
+            // cutoff. The intent of this test is to flag if the loop takes a very long
+            // time, but still allow the rest of this function to finish.
+            BEAST_EXPECTS(iterations > 20, std::to_string(iterations));
+            if (!BEAST_EXPECT(lm.haveLedger(deleteSeq)))
+                return 0;
+
+            // This test may be timing sensitive, because it's messing with server internals in ways
+            // that they can't be messed with normally. Sleep a little bit to give the server time
+            // to finish any internal work before we delete the ledger.
+            std::this_thread::sleep_for(250ms);
+
+            lm.clearLedger(deleteSeq);
+            deleteSeqs.push_back(deleteSeq);
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+
+            BEAST_EXPECTS(
+                lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                failureMessage(
+                    "Complete ledgers",
+                    expectedRange(minSeq, deleteSeqs, maxSeq),
+                    lm.getCompleteLedgers()));
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size());
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            // Close another ledger, which will trigger a rotation, but the
+            // rotation will be stuck until the missing ledger is filled in.
+            env.close();
+            // Do not call rendezvous() here without a timeout; it will block until the missing
+            // ledger is backfilled. That will not happen automatically. It's a manual step that
+            // is done later in this test.
+            ++maxSeq;
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            netOPs.setMode(OperatingMode::FULL);
+
+            if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                return 0;
+            BEAST_EXPECT(!store.rendezvous(10ms));
+            BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::FULL);
+
+            // Nothing has changed
+            BEAST_EXPECTS(
+                store.getLastRotated() == lastRotated,
+                failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+            BEAST_EXPECTS(
+                lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                failureMessage(
+                    "Complete ledgers",
+                    expectedRange(minSeq, deleteSeqs, maxSeq),
+                    lm.getCompleteLedgers()));
+
+            return deleteSeq;
+        };
+
+        std::vector deleteSeqs;
+
+        // Close enough ledgers to rotate a few times
+        while (maxSeq < 40)
+        {
+            for (int t = 0; t < 3; ++t)
+            {
+                env(noop(alice));
+            }
+            env.close();
+            BEAST_EXPECT(store.rendezvous());
+
+            ++maxSeq;
+
+            if (maxSeq + 1 == lastRotated + kDeleteInterval)
+            {
+                using namespace std::chrono_literals;
+
+                {
+                    // Trigger the circuit breaker in SHAMapStoreImp::healthWait() to ensure it
+                    // doesn't block forever.
+                    LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs);
+                    if (!BEAST_EXPECT(deleteSeq > 0))
+                        return;
+                    if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                        return;
+
+                    // Close 7 more ledgers, waiting a little bit in between to
+                    // simulate the ledger making progress while online delete waits
+                    // for the missing ledger to be filled in.
+                    // After the 7th ledger, the circuit breaker will trigger and abort the attempt.
+                    while (maxSeq < lastRotated + (kDeleteInterval * 2) - 2)
+                    {
+                        env.close();
+                        ++maxSeq;
+                        // Nothing has changed
+                        BEAST_EXPECTS(
+                            store.getLastRotated() == lastRotated,
+                            failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                        BEAST_EXPECTS(
+                            lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                            failureMessage(
+                                "Complete Ledgers",
+                                expectedRange(minSeq, deleteSeqs, maxSeq),
+                                lm.getCompleteLedgers()));
+                        // The Store is "stuck" in healthWait() and won't finish the run() loop
+                        // until it's backfilled
+                        if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                            return;
+                    }
+
+                    // Close one more ledger, which will NOT trigger the circuit breaker. Wait for
+                    // the full 1 second recovery wait timeout to ensure the circuit breaker is not
+                    // triggered.
+                    env.close();
+                    ++maxSeq;
+                    // The Store is "stuck" in healthWait() and won't finish the run() loop
+                    // until it's backfilled
+                    BEAST_EXPECT(!store.rendezvous(1s));
+
+                    // Close one more ledger, which will trigger the circuit breaker and abort the
+                    // attempt to rotate.
+                    env.close();
+                    ++maxSeq;
+                    // Nothing has changed
+                    BEAST_EXPECTS(
+                        store.getLastRotated() == lastRotated,
+                        failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                    BEAST_EXPECTS(
+                        lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                        failureMessage(
+                            "Complete Ledgers",
+                            expectedRange(minSeq, deleteSeqs, maxSeq),
+                            lm.getCompleteLedgers()));
+
+                    // The circuit breaker has been triggered.
+                    BEAST_EXPECT(store.rendezvous());
+                }
+                {
+                    // Recover before the circuit breaker triggers, so the test can continue.
+                    LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs);
+                    if (!BEAST_EXPECT(deleteSeq > 0))
+                        return;
+                    if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                        return;
+
+                    // Close 5 more ledgers, waiting a little bit in between to
+                    // simulate the ledger making progress while online delete waits
+                    // for the missing ledger to be filled in.
+                    // This ensures the healthWait check has time to run and
+                    // detect the gap.
+                    for (int l = 0; l < 5; ++l)
+                    {
+                        env.close();
+                        ++maxSeq;
+                        // Nothing has changed
+                        BEAST_EXPECTS(
+                            store.getLastRotated() == lastRotated,
+                            failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+                        BEAST_EXPECTS(
+                            lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq),
+                            failureMessage(
+                                "Complete Ledgers",
+                                expectedRange(minSeq, deleteSeqs, maxSeq),
+                                lm.getCompleteLedgers()));
+                        if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq)))
+                            return;
+                    }
+
+                    // The Store is "stuck" in healthWait() and won't finish the run() loop
+                    // until it's backfilled
+                    // Wait for the full 1 second recovery wait timeout to ensure the circuit
+                    // breaker is not triggered, and this isn't some other timing fluke.
+                    BEAST_EXPECT(!store.rendezvous(1s));
+
+                    // Put the missing ledger back in LedgerMaster
+                    lm.setLedgerRangePresent(deleteSeq, deleteSeq);
+                    BEAST_EXPECT(deleteSeqs.back() == deleteSeq);
+                    deleteSeqs.pop_back();
+
+                    // Wait for the rotation to finish
+                    BEAST_EXPECT(store.rendezvous());
+
+                    minSeq = lastRotated;
+                    while (deleteSeqs.front() < minSeq)
+                    {
+                        deleteSeqs.erase(deleteSeqs.begin());
+                    }
+                    lastRotated = deleteSeq + 1;
+                }
+            }
+            BEAST_EXPECT(maxSeq != lastRotated + kDeleteInterval);
+            BEAST_EXPECTS(
+                env.closed()->header().seq == maxSeq,
+                failureMessage("maxSeq", maxSeq, env.closed()->header().seq));
+            BEAST_EXPECTS(
+                store.getLastRotated() == lastRotated,
+                failureMessage("lastRotated", lastRotated, store.getLastRotated()));
+            {
+                auto const expected = expectedRange(minSeq, deleteSeqs, maxSeq);
+                BEAST_EXPECTS(
+                    lm.getCompleteLedgers() == expected,
+                    failureMessage("CompleteLedgers", expected, lm.getCompleteLedgers()));
+            }
+            BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size());
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == deleteSeqs.size());
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == deleteSeqs.size() + 2);
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == deleteSeqs.size() + 2);
+            BEAST_EXPECT(
+                lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == deleteSeqs.size() + 2);
+        }
+    }
+
     void
     run() override
     {
@@ -610,6 +912,7 @@ public:
         testAutomatic();
         testCanDelete();
         testRotate();
+        testLedgerGaps();
     }
 };
 
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index a1a9f80a11..e58d8c9f8f 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -1877,7 +1877,11 @@ public:
 
             PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
             Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+            // accepts closed-ended vaults; build one and advance past
+            // SubscriptionDate before creating a loan.
+            auto [vaultTx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = alice, .asset = xrpAsset});
             env(vaultTx);
             env.close();
 
@@ -1885,6 +1889,8 @@ public:
                 {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(loan_broker::set(alice, vaultKeylet.key),
diff --git a/src/test/app/invariants/InvariantsAMM_test.cpp b/src/test/app/invariants/InvariantsAMM_test.cpp
new file mode 100644
index 0000000000..498c35c653
--- /dev/null
+++ b/src/test/app/invariants/InvariantsAMM_test.cpp
@@ -0,0 +1,249 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsAMM_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testAMMDeleteInvariants(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const enforceAMMDelete = features[fixCleanup3_3_0];
+        testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : "");
+
+        Env env(*this, features);
+        Account const issuer{"issuer"};
+        Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()};
+        STAmount const zeroLP{lptIssue, 0};
+        STAmount const nonZeroLP{lptIssue, 1};
+
+        auto const makeAMM = [](STAmount const& lptBalance) {
+            auto sleAMM = std::make_shared(keylet::amm(uint256(1)));
+            sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance);
+            return sleAMM;
+        };
+
+        auto const checkInvariant = [&](TxType txType,
+                                        TER result,
+                                        std::optional const& deletedLPBalance,
+                                        bool expected,
+                                        std::string const& expectedLog) {
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ValidAMM invariant;
+
+            if (deletedLPBalance)
+                invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr);
+
+            bool const actual = invariant.finalize(
+                STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog);
+
+            BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result");
+            auto const messages = sink.messages().str();
+            auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : "";
+            if (!expectedLogWhenEnforced.empty())
+            {
+                BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced);
+            }
+            else
+            {
+                BEAST_EXPECTS(messages.empty(), messages);
+            }
+        };
+
+        checkInvariant(
+            ttPAYMENT,
+            tesSUCCESS,
+            nonZeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMM failed, unexpected AMM deletion by");
+        checkInvariant(
+            ttAMM_DELETE,
+            tesSUCCESS,
+            std::nullopt,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS");
+        checkInvariant(
+            ttAMM_DELETE,
+            tesSUCCESS,
+            nonZeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance");
+        checkInvariant(
+            ttAMM_DELETE,
+            tecINCOMPLETE,
+            zeroLP,
+            !enforceAMMDelete,
+            "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS");
+
+        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, "");
+        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, "");
+
+        checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, "");
+        checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, "");
+        checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, "");
+    }
+
+    void
+    testAMM()
+    {
+        testcase << "AMM";
+        using namespace jtx;
+
+        MPTID mptID{};
+        uint256 ammID{};
+        AccountID ammAccountID{};
+        Account const gw{"gw"};
+        Issue lptIssue{};
+        PrettyAsset poolAsset{xrpIssue()};
+
+        auto deleteAMMAccount = [&](ApplyContext& ac, bool) {
+            auto sle = ac.view().peek(keylet::account(ammAccountID));
+            if (!sle)
+                return false;
+            ac.view().erase(sle);
+            return true;
+        };
+
+        auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) {
+            auto sle = ac.view().peek(keylet::amm(ammID));
+            if (!sle)
+                return false;
+            sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount});
+            ac.view().update(sle);
+            return true;
+        };
+        auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) {
+            return updateLPTokensBalance(ac, -1);
+        };
+        auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) {
+            return updateLPTokensBalance(ac, 200'000'000);
+        };
+        auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); };
+
+        auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) {
+            if (isMPT)
+            {
+                auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfMPTAmount, 1);
+                ac.view().update(sle);
+                return true;
+            }
+            auto sle = ac.view().peek(keylet::account(ammAccountID));
+            if (!sle)
+                return false;
+            sle->setFieldAmount(sfBalance, XRP(1));
+            ac.view().update(sle);
+            return true;
+        };
+
+        auto test = [&](auto const txType,
+                        auto&& update,
+                        bool isMPT,
+                        TER error = tecINVARIANT_FAILED) {
+            doInvariantCheck(
+                {{"AMM"}},
+                [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); },
+                XRPAmount{},
+                STTx{txType, [&](STObject& tx) {}},
+                {tecINVARIANT_FAILED, error},
+                [&](Account const&, Account const&, Env& env) {
+                    env.fund(XRP(1'000), gw);
+                    poolAsset = [&]() -> PrettyAsset {
+                        if (isMPT)
+                        {
+                            MPT const mpt = MPTTester({.env = env, .issuer = gw});
+                            mptID = mpt.issuanceID;
+                            return mpt;
+                        }
+                        return gw["USD"];
+                    }();
+                    AMM const amm(env, gw, XRP(100), poolAsset(100));
+                    ammAccountID = amm.ammAccount();
+                    ammID = amm.ammID();
+                    lptIssue = amm.lptIssue();
+                    return true;
+                });
+        };
+
+        for (bool const isMPT : {false, true})
+        {
+            // Under fixCleanup3_4_0 the MPT balance invariants also fire on the
+            // second pass, so both IOU and MPT pools now escalate to tef.
+            auto const error = TER(tefINVARIANT_FAILED);
+            for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW})
+            {
+                test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED);
+                test(txType, updateLPTokensBadAmount, isMPT);
+                test(txType, updateLPTokensBadBalance, isMPT);
+            }
+            for (auto txType : {ttAMM_BID, ttAMM_VOTE})
+            {
+                test(txType, updateAMMPool, isMPT, error);
+                test(txType, updateLPTokensBadAmount, isMPT);
+                test(txType, updateLPTokensBadBalance, isMPT);
+            }
+            for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT})
+            {
+                test(txType, updateAMM, isMPT);
+            }
+        }
+    }
+
+    // Test the invariant overwrite fix for both pre- and post-amendment
+    // behavior. With the fix enabled, |= accumulates violations across
+    // entries so a later valid entry cannot clear an earlier violation.
+    // Without the fix, = assignment means the last-visited entry wins.
+
+    void
+    run() override
+    {
+        testAMMDeleteInvariants(all_);
+        testAMMDeleteInvariants(all_ - fixCleanup3_3_0);
+        testAMM();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsAMM, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp
new file mode 100644
index 0000000000..a573cc45ea
--- /dev/null
+++ b/src/test/app/invariants/InvariantsBase.cpp
@@ -0,0 +1,209 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+test::jtx::Env
+InvariantsBase::makeEnv(FeatureBitset features)
+{
+    return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled};
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    Preclose const& preclose,
+    TxAccount setTxAccount,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    doInvariantCheck(
+        makeEnv(test::jtx::testableAmendments()),
+        expectLogs,
+        precheck,
+        fee,
+        tx,
+        ters,
+        preclose,
+        setTxAccount,
+        loc,
+        initialResult);
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    test::jtx::Env&& env,
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    Preclose const& preclose,
+    TxAccount setTxAccount,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    using namespace test::jtx;
+
+    Account const a1{"A1"};
+    Account const a2{"A2"};
+    env.fund(XRP(1000), a1, a2);
+    if (preclose)
+        BEAST_EXPECT(preclose(a1, a2, env));
+    env.close();
+
+    if (setTxAccount != TxAccount::None)
+        tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
+
+    doInvariantCheck(
+        std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult);
+}
+
+void
+InvariantsBase::doInvariantCheck(
+    // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
+    test::jtx::Env&& env,
+    test::jtx::Account const& a1,
+    test::jtx::Account const& a2,
+    std::vector const& expectLogs,
+    Precheck const& precheck,
+    XRPAmount fee,
+    STTx tx,
+    std::initializer_list ters,
+    std::source_location const& loc,
+    TER initialResult)
+{
+    using namespace test::jtx;
+
+    OpenView ov{*env.current()};
+    test::StreamSink sink{beast::Severity::Warning};
+    beast::Journal const jlog{sink};
+    ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+
+    // Invariants normally run in the Transaction's "apply" (operator()) context, and can always
+    // access global Rules.
+    CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+    BEAST_EXPECT(precheck(a1, a2, ac));
+
+    auto transactor = makeTransactor(ac);
+    if (!BEAST_EXPECT(transactor))
+        return;
+
+    // Invoke the check twice to cover the tec and tef cases. Both passes run
+    // against the same view -- production would discard it in between -- so
+    // the second sees the same violation and escalates tec -> tef. A
+    // {tec, tef} pair therefore means "enforced whatever the incoming
+    // result", not that the transaction ends in tef on ledger.
+    if (!BEAST_EXPECT(ters.size() == 2))
+        return;
+
+    TER terActual = initialResult;
+    for (TER const& terExpect : ters)
+    {
+        TER const terInput = terActual;
+        terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
+        expect(
+            terExpect == terActual,
+            "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
+            loc.file_name(),
+            loc.line());
+        auto const messages = sink.messages().str();
+
+        // checkInvariants returns its input unchanged unless something
+        // fires, so a changed result means an invariant fired, and a firing
+        // invariant must log.
+        if (terActual != terInput)
+        {
+            expect(
+                messages.starts_with("Invariant failed:") ||
+                    messages.starts_with("Transaction caused an exception"),
+                messages,
+                loc.file_name(),
+                loc.line());
+        }
+
+        // std::cerr << messages << '\n';
+        for (auto const& m : expectLogs)
+        {
+            expect(messages.contains(m), m, loc.file_name(), loc.line());
+        }
+    }
+}
+
+Keylet
+InvariantsBase::createLoanBroker(
+    jtx::Account const& a,
+    jtx::Env& env,
+    jtx::PrettyAsset const& asset)
+{
+    using namespace jtx;
+
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+    // accepts closed-ended vaults. Build one with a comfortable
+    // subscription window; LoanBrokerSet itself is not phase-gated,
+    // so leaving the vault in the Subscription phase is fine here.
+    uint256 vaultID;
+    Vault const vault{env};
+    auto [tx, vKeylet, _] = vault.createClosedEnded(
+        {.owner = a,
+         .asset = asset,
+         .subscriptionOffset = std::chrono::seconds{60},
+         .investmentWindow = std::chrono::seconds{kMinInvestmentPeriod + 1'000'000u}});
+    env(tx);
+    BEAST_EXPECT(env.le(vKeylet));
+
+    vaultID = vKeylet.key;
+
+    // Create Loan Broker
+    using namespace loan_broker;
+
+    auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a)));
+    // Create a Loan Broker with all default values.
+    env(set(a, vaultID), Fee(kIncrement));
+
+    return loanBrokerKeylet;
+}
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsBase.h b/src/test/app/invariants/InvariantsBase.h
new file mode 100644
index 0000000000..73319d0ef8
--- /dev/null
+++ b/src/test/app/invariants/InvariantsBase.h
@@ -0,0 +1,122 @@
+#pragma once
+
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+class Transactor;
+
+// Test-only factory — not part of the public API.
+// The returned Transactor holds a raw reference to ctx; the caller must ensure
+// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp
+std::unique_ptr
+makeTransactor(ApplyContext& ctx);
+
+}  // namespace xrpl
+
+namespace xrpl::test {
+
+class InvariantsBase : public beast::unit_test::Suite
+{
+protected:
+    // The optional Preclose function is used to process additional transactions
+    // on the ledger after creating two accounts, but before closing it, and
+    // before the Precheck function. These should only be valid functions, and
+    // not direct manipulations. Preclose is not commonly used.
+    using Preclose = std::function<
+        bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>;
+
+    // this is common setup/method for running a failing invariant check. The
+    // precheck function is used to manipulate the ApplyContext with view
+    // changes that will cause the check to fail.
+    using Precheck = std::function<
+        bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>;
+
+    enum class TxAccount : int { None = 0, A1, A2 };
+
+    test::jtx::Env
+    makeEnv(FeatureBitset features);
+
+    /**
+     * Run a specific test case to put the ledger into a state that will be
+     * detected by an invariant. Simulates the actions of a transaction that
+     * would violate an invariant.
+     *
+     * @param expectLogs One or more messages related to the failing invariant
+     *  that should be in the log output
+     * @param precheck See "Precheck" above
+     * @param fee If provided, the fee amount paid by the simulated transaction.
+     * @param tx A mock transaction that took the actions to trigger the
+     *  invariant. In most cases, only the type matters.
+     * @param ters The TER results expected on the two passes of the invariant
+     *  checker.
+     * @param preclose See "Preclose" above. Note that @preclose runs *before*
+     *  @precheck, but is the last parameter for historical reasons
+     * @param setTxAccount optionally set to add sfAccount to tx (either A1 or A2)
+     */
+    void
+    doInvariantCheck(
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        Preclose const& preclose = {},
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        // Result fed to the invariant checker on the first pass. Set it to a
+        // tec to exercise result-dependent invariants; the harness runs no
+        // transactor, so one never arises on its own.
+        TER initialResult = tesSUCCESS);
+
+    void
+    doInvariantCheck(
+        test::jtx::Env&& env,
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        Preclose const& preclose = {},
+        TxAccount setTxAccount = TxAccount::None,
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS);
+
+    void
+    doInvariantCheck(
+        // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved)
+        test::jtx::Env&& env,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::vector const& expectLogs,
+        Precheck const& precheck,
+        XRPAmount fee = XRPAmount{},
+        STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
+        std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+        std::source_location const& loc = std::source_location::current(),
+        TER initialResult = tesSUCCESS);
+
+    Keylet
+    createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset);
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsEscrowNFT_test.cpp b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp
new file mode 100644
index 0000000000..f0afa2377c
--- /dev/null
+++ b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp
@@ -0,0 +1,352 @@
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsEscrowNFT_test : public InvariantsBase
+{
+    void
+    testNoZeroEscrow()
+    {
+        using namespace test::jtx;
+        testcase << "no zero escrow";
+
+        doInvariantCheck(
+            {{"XRP net change of -1000000 doesn't match fee 0"},
+             {"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with negative amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+                sleNew->setFieldAmount(sfAmount, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"XRP net change was positive: 100000000000000001"},
+             {"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-large amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+                // Use `drops(1)` to bypass a call to STAmount::canonicalize
+                // with an invalid value
+                sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // IOU < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-little iou
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
+                STAmount const amt(usd, -1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // IOU bad currency
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with bad iou currency
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                Issue const bad{badCurrency(), AccountID(0x4985601)};
+                STAmount const amt(bad, 1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // escrow with too-little mpt
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                STAmount const amt(mpt, -1);
+                sleNew->setFieldAmount(sfAmount, amt);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT LockedAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance locked is less than locked
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount < LockedAmount
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is less than locked
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 1);
+                sleNew->setFieldU64(sfLockedAmount, 10);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT MPTAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptoken amount is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
+                sleNew->setFieldU64(sfMPTAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT LockedAmount < 0
+        doInvariantCheck(
+            {{"escrow specifies invalid amount"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptoken locked amount is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1));
+                sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max());
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testNFTokenPageInvariants()
+    {
+        using namespace test::jtx;
+        testcase << "NFTokenPage";
+
+        // lambda that returns an STArray of NFTokenIDs.
+        uint256 const firstNFTID(
+            "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000");
+        auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) {
+            SOTemplate const* nfTokenTemplate =
+                InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken);
+
+            uint256 nftID(firstNFTID);
+            STArray ret;
+            for (int i = 0; i < nftCount; ++i)
+            {
+                STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) {
+                    object.setFieldH256(sfNFTokenID, nftID);
+                });
+                ret.pushBack(std::move(newNFToken));
+                ++nftID;
+            }
+            return ret;
+        };
+
+        doInvariantCheck(
+            {{"NFT page has invalid size"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0));
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page has invalid size"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33));
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFTs on page are not sorted"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(2);
+                std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1);
+
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT contains empty URI"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(1);
+                nfTokens[0].setFieldVL(sfURI, Blob{});
+
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                auto nftPage = std::make_shared(keylet::nftokenPageMax(a1));
+                nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1));
+                nftPage->setFieldH256(sfNextPageMin, nftPage->key());
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT page is improperly linked"}},
+            [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(1);
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID))));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+                nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"NFT found in incorrect page"}},
+            [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) {
+                STArray nfTokens = makeNFTokenIDs(2);
+                auto nftPage = std::make_shared(keylet::nftokenPage(
+                    keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID))));
+                nftPage->setFieldArray(sfNFTokens, nfTokens);
+
+                ac.view().insert(nftPage);
+                return true;
+            });
+    }
+
+    void
+    run() override
+    {
+        testNoZeroEscrow();
+        testNFTokenPageInvariants();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsEscrowNFT, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp
new file mode 100644
index 0000000000..4692463baa
--- /dev/null
+++ b/src/test/app/invariants/InvariantsMPT_test.cpp
@@ -0,0 +1,1577 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsMPT_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testMPT()
+    {
+        using namespace test::jtx;
+        testcase << "MPT";
+
+        MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))};
+        auto const nonCanonicalMPTAmount = [&](SField const& field) {
+            return STAmount{
+                field,
+                nonCanonicalMPTIssue,
+                kMaxMpTokenAmount + std::uint64_t{1},
+                0,
+                false,
+                STAmount::Unchecked{}};
+        };
+        auto const negativeMPTAmount = [&](SField const& field) {
+            return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}};
+        };
+        auto const nonCanonicalMPTPayment = [&]() {
+            return STTx{ttPAYMENT, [&](STObject& tx) {
+                            tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount));
+                        }};
+        };
+
+        doInvariantCheck(
+            makeEnv(all_ - fixCleanup3_2_0),
+            {},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{},
+            nonCanonicalMPTPayment(),
+            {tesSUCCESS, tesSUCCESS});
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(
+                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"ledger entry contains non-canonical MPT or XRP amount"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                auto sleNew = std::make_shared(
+                    keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setAccountID(sfDestination, a2.id());
+                sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPT OutstandingAmount > MaximumAmount
+        doInvariantCheck(
+            {{"OutstandingAmount overflow"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 110);
+                sleNew->setFieldU64(sfMaximumAmount, 100);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        // MPTToken amount doesn't add up to OutstandingAmount
+        doInvariantCheck(
+            {{"invalid OutstandingAmount balance"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // mptissuance outstanding is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldU64(sfOutstandingAmount, 100);
+                sleNew->setFieldU64(sfMaximumAmount, 100);
+                ac.view().insert(sleNew);
+
+                sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
+                sleNew->setFieldU64(sfMPTAmount, 90);
+                ac.view().insert(sleNew);
+
+                return true;
+            });
+
+        // Overflow/Invalid balance on payment
+        auto testPayment = [&](std::string const& log, auto&& update) {
+            MPTID id;
+            doInvariantCheck(
+                {{log}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    return update(id, ac, a1);
+                },
+                XRPAmount{},
+                STTx{ttPAYMENT, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const& a2, Env& env) {
+                    Account const gw("gw");
+                    env.fund(XRP(1'000), gw);
+                    MPTTester const mpt(
+                        {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100});
+                    id = mpt.issuanceID();
+                    return true;
+                });
+        };
+        testPayment(
+            "invalid OutstandingAmount balance",
+            [&](MPTID const& id, ApplyContext& ac, Account const& a1) {
+                auto sle = ac.view().peek(keylet::mptoken(id, a1));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfMPTAmount, 101);
+                ac.view().update(sle);
+                return true;
+            });
+        testPayment(
+            "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
+                auto sle = ac.view().peek(keylet::mptokenIssuance(id));
+                if (!sle)
+                    return false;
+                sle->setFieldU64(sfOutstandingAmount, 101);
+                ac.view().update(sle);
+                return true;
+            });
+
+        // The on-failure MPT checks (OutstandingAmount balance / transfer) apply
+        // to every non-tesSUCCESS result, with no per-result exemption: on a tec
+        // the transactor discards the view and re-applies only offer, trust
+        // line, NFT offer and credential deletions, so an MPT change reaching
+        // the invariant is a bug whatever the code. Seeded via initialResult.
+        {
+            MPTID id;
+            // preclose: gw issues an MPT held by A1 and A2.
+            auto const setup = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt(
+                    {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000});
+                id = mpt.issuanceID();
+                return true;
+            };
+
+            // Consistent mint: OutstandingAmount and A1's balance both grow by
+            // 10, so conservation holds and only the on-failure check fires.
+            Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleIss || !sleTok)
+                    return false;
+                (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10;
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleTok);
+                return true;
+            };
+
+            // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is
+            // unchanged, and CanTransfer keeps the ordinary transfer check
+            // quiet, so only the on-failure check fires.
+            Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
+                auto sleA = ac.view().peek(keylet::mptoken(id, a1.id()));
+                auto sleB = ac.view().peek(keylet::mptoken(id, a2.id()));
+                if (!sleIss || !sleA || !sleB)
+                    return false;
+                (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer;
+                (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10;
+                (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10;
+                ac.view().update(sleIss);
+                ac.view().update(sleA);
+                ac.view().update(sleB);
+                return true;
+            };
+
+            STTx const payment{ttPAYMENT, [](STObject&) {}};
+
+            // Negative controls: nothing fires on tesSUCCESS. Without these, the
+            // cases below would still pass if the result guard were dropped.
+            doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+
+            // tecKILLED and tecINCOMPLETE are not special: an MPT change paired
+            // with either fires, as with any other failure.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecINCOMPLETE);
+            // The same change under a third failure result: the check keys off
+            // "not tesSUCCESS", nothing finer.
+            doInvariantCheck(
+                {{"OutstandingAmount balance changed on failure"}},
+                mint,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                transfer,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A lock moves value within one holder, so it is not a two-sided
+            // transfer and the `senders || receivers` form is what catches it.
+            // OutstandingAmount and the holder total are unchanged, so the
+            // balance check stays quiet.
+            Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] < 10)
+                    return false;
+                // A fresh MPToken has no locked amount, so set it directly.
+                (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10;
+                sleTok->setFieldU64(sfLockedAmount, 10);
+                ac.view().update(sleTok);
+                return true;
+            };
+            // Negative control: a lock is legitimate on tesSUCCESS.
+            doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecKILLED);
+            // The lock is caught under any failure result.
+            doInvariantCheck(
+                {{"MPToken balance changed on failure"}},
+                lock,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setup,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+
+            // A deleted MPToken has no amtAfter, so the sender/receiver counts
+            // skip it and only the deletedAuthorized_ term can catch it. That
+            // needs holders authorized but never paid, so the MPToken can be
+            // erased with a zero balance and OutstandingAmount untouched --
+            // otherwise the holder would register as a sender instead.
+            MPTID emptyId;
+            auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) {
+                Account const gw("gw");
+                env.fund(XRP(1'000), gw);
+                MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100});
+                emptyId = mpt.issuanceID();
+                return true;
+            };
+            Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id()));
+                if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                    return false;
+                ac.view().erase(sleTok);
+                return true;
+            };
+            // ValidMPTIssuance also reports the deletion, so assert on
+            // ValidMPTTransfer's message, which only the new check can produce.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                eraseToken,
+                XRPAmount{},
+                payment,
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupEmpty,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
+        // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback balance change is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100};
+                    }},
+                {tesSUCCESS, tesSUCCESS});
+        }
+
+        // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing.
+        {
+            Env env(*this, all_ - featureMPTokensV2);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback balance change is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tesSUCCESS, tesSUCCESS});
+        }
+
+        // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback balance change is invalid"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+
+                    sleToken->setFieldU64(sfMPTAmount, 80);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 80);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline and MPToken both changed"}},
+                [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleLine =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id()));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleLine || !sleToken || !sleIssuance)
+                        return false;
+
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sleLine->setFieldAmount(sfBalance, balance);
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleLine);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Clawback that modifies a trustline other than the one implied by the
+        // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for
+        // the mismatched line.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            auto const eur = issuer["EUR"];
+            env.trust(eur(100), holder);
+            env(pay(issuer, holder, eur(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback changed the wrong line"}},
+                [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency));
+                    if (!sle)
+                        return false;
+                    STAmount balance{Issue{eur.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Clawback leaving the holder's balance negative.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline or MPT balance is negative"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+                    // Make the holder's balance negative from their perspective.
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 80};
+                    if (holder.id() < issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // IOU-amount clawback while only an MPToken changed: no trustline was
+        // recorded, so iou_.before is empty.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback changed the wrong line"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Valid trustline change but a zero clawback amount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            auto const usd = issuer["USD"];
+            env.trust(usd(100), holder);
+            env(pay(issuer, holder, usd(100)));
+            env.close();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: trustline clawback amount is invalid"}},
+                [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto sle =
+                        ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
+                    if (!sle)
+                        return false;
+                    STAmount balance{Issue{usd.currency, issuer.id()}, 90};
+                    if (holder.id() > issuer.id())
+                        balance.negate();
+                    sle->setFieldAmount(sfBalance, balance);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback tx missing the Holder field.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback missing holder"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback where the holder's MPToken was deleted (after is empty).
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback token is missing"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    // Keep the issuance consistent after removing the token.
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 0);
+                    ac.view().update(sleIssuance);
+                    ac.view().erase(sleToken);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // MPT clawback that changed a different holder's MPToken.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env,
+                 .issuer = issuer,
+                 .holders = {holder, other},
+                 .pay = 100,
+                 .maxAmt = 200});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback changed the wrong token"}},
+                [id](Account const&, Account const& other, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, other));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 190);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 10};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // Valid MPToken change but a zero MPT clawback amount.
+        {
+            Env env(*this, all_);
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            Account const other{"other"};
+            env.fund(XRP(1'000), issuer, holder, other);
+            MPTTester const mpt(
+                {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
+            auto const id = mpt.issuanceID();
+
+            doInvariantCheck(
+                std::move(env),
+                holder,
+                other,
+                {{"Invariant failed: MPT clawback amount is invalid"}},
+                [id](Account const& holder, Account const&, ApplyContext& ac) {
+                    auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
+                    auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
+                    if (!sleToken || !sleIssuance)
+                        return false;
+                    sleToken->setFieldU64(sfMPTAmount, 90);
+                    sleIssuance->setFieldU64(sfOutstandingAmount, 90);
+                    ac.view().update(sleToken);
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttCLAWBACK,
+                    [&](STObject& tx) {
+                        tx[sfAccount] = issuer.id();
+                        tx[sfHolder] = holder.id();
+                        tx[sfAmount] = STAmount{MPTIssue{id}, 0};
+                    }},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // More MPTokens created than expected
+        std::array, 4> const tests = {
+            std::make_pair(ttAMM_WITHDRAW, 2),
+            std::make_pair(ttAMM_CLAWBACK, 2),
+            std::make_pair(ttAMM_CREATE, 3),
+            std::make_pair(ttCHECK_CASH, 2)};
+        for (auto const& [tx, nTokens] : tests)
+        {
+            doInvariantCheck(
+                {{std::string("MPToken created for the MPT issuer")}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+
+                    auto seq = sle->getFieldU32(sfSequence);
+                    for (int i = 0; i < nTokens; ++i)
+                    {
+                        MPTIssue const mpt{makeMptID(seq + i, a1)};
+                        auto sleNew =
+                            std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                        ac.view().insert(sleNew);
+
+                        sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2));
+                        ac.view().insert(sleNew);
+                    }
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{tx, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+        }
+
+        // More MPTokens deleted than expected
+        for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK})
+        {
+            MPTID id;
+            Account const a3("A3");
+            doInvariantCheck(
+                {{"MPT authorize  succeeded but created/deleted bad number of mptokens"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    for (auto const& a : {a1, a2, a3})
+                    {
+                        auto sle = ac.view().peek(keylet::mptoken(id, a));
+                        if (!sle)
+                            return false;
+                        ac.view().erase(sle);
+                    }
+                    return true;
+                },
+                XRPAmount{},
+                STTx{tx, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const& a2, Env& env) {
+                    Account const gw("gw");
+                    env.fund(XRP(1'000), gw, a3);
+                    MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}});
+                    id = mpt.issuanceID();
+                    return true;
+                });
+        }
+
+        // sfReferenceHolding can only be set on creation by VaultCreate. A
+        // non-VaultCreate transaction that creates an MPTokenIssuance with
+        // sfReferenceHolding present must trip the invariant.
+        doInvariantCheck(
+            {{"sfReferenceHolding set on a new MPTokenIssuance by a "
+              "non-VaultCreate transaction"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const sleAcct = ac.view().peek(keylet::account(a1.id()));
+                if (!sleAcct)
+                    return false;
+                MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                sleNew->setFieldH256(sfReferenceHolding, uint256{1});
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}});
+
+        // sfReferenceHolding is immutable: changing the field on an
+        // existing MPTokenIssuance must trip the invariant. Set up a real
+        // vault via preclose (so the share issuance carries
+        // sfReferenceHolding), then mutate it in precheck to produce a
+        // before/after pair.
+        {
+            uint256 vaultKey;
+            doInvariantCheck(
+                {{"sfReferenceHolding was modified on an existing "
+                  "MPTokenIssuance"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
+                    if (!sleVault)
+                        return false;
+                    auto sleIssuance =
+                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+                    if (!sleIssuance)
+                        return false;
+                    sleIssuance->setFieldH256(sfReferenceHolding, uint256{2});
+                    ac.view().update(sleIssuance);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const&, Env& env) {
+                    Account const issuer{"issuer"};
+                    env.fund(XRP(10'000), issuer);
+                    env.close();
+                    MPTTester mptt{env, issuer, kMptInitNoFund};
+                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+                    PrettyAsset const asset = mptt.issuanceID();
+                    mptt.authorize({.account = a1});
+                    env.close();
+
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+                    env(tx);
+                    env.close();
+                    vaultKey = keylet.key;
+                    return true;
+                });
+        }
+
+        // A vault pseudo-account's MPToken cannot be deleted by anything
+        // other than a VaultDelete transaction. Set up a vault, then have
+        // an arbitrary tx erase the pseudo's MPToken in precheck.
+        {
+            uint256 vaultKey;
+            doInvariantCheck(
+                {{"vault pseudo-account holding deleted by a "
+                  "non-VaultDelete transaction"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
+                    if (!sleVault)
+                        return false;
+                    auto const sleIssuance =
+                        ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+                    if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
+                        return false;
+                    auto sleHolding = ac.view().peek(
+                        keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
+                    if (!sleHolding)
+                        return false;
+                    ac.view().erase(sleHolding);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&](Account const& a1, Account const&, Env& env) {
+                    Account const issuer{"issuer"};
+                    env.fund(XRP(10'000), issuer);
+                    env.close();
+                    MPTTester mptt{env, issuer, kMptInitNoFund};
+                    mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
+                    PrettyAsset const asset = mptt.issuanceID();
+                    mptt.authorize({.account = a1});
+                    env.close();
+
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+                    env(tx);
+                    env.close();
+                    vaultKey = keylet.key;
+                    return true;
+                });
+        }
+
+        // Invalid transfer
+        std::array, 3> const invalidTransferTests = {
+            std::make_pair(ttAMM_WITHDRAW, false),
+            std::make_pair(ttPAYMENT, false),
+            std::make_pair(ttPAYMENT, true)};
+        // The two amendments that gate enforcement, in all four combinations.
+        FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0};
+        for (auto const gates :
+             {gatesEnabled,
+              gatesEnabled - featureMPTokensV2,
+              gatesEnabled - fixCleanup3_4_0,
+              FeatureBitset{}})
+        {
+            for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
+            {
+                for (auto const flag :
+                     {static_cast(lsfMPTLocked),
+                      ~lsfMPTCanTransfer,
+                      ~lsfMPTCanTrade,
+                      0u})
+                {
+                    MPTID id{};
+                    auto const isSuccess = !gates.any() || flag == 0 ||
+                        (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
+                        (tx == ttAMM_WITHDRAW &&
+                         (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
+                    std::pair const error = isSuccess
+                        ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS))
+                        : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED));
+                    doInvariantCheck(
+                        {{isSuccess ? "" : "invalid MPToken transfer between holders"}},
+                        [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                            auto update = [&](AccountID const& a, std::uint64_t v) {
+                                auto sle = ac.view().peek(keylet::mptoken(id, a));
+                                if (!sle)
+                                    return false;
+                                sle->at(sfMPTAmount) = v;
+                                ac.view().update(sle);
+                                return true;
+                            };
+                            auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id));
+                            if (!issuanceSle)
+                                return false;
+                            auto const flags = issuanceSle->at(sfFlags);
+                            if (flag == lsfMPTLocked)
+                            {
+                                issuanceSle->at(sfFlags) = flags | lsfMPTLocked;
+                            }
+                            else if (flag != 0u)
+                            {
+                                issuanceSle->at(sfFlags) = flags & flag;
+                            }
+                            issuanceSle->at(sfOutstandingAmount) = 200;
+                            ac.view().update(issuanceSle);
+                            return update(a1, 101) && update(a2, 99);
+                        },
+                        XRPAmount{},
+                        STTx{
+                            tx,
+                            [&](STObject& tx) {
+                                if (crossCurrencyPayment)
+                                {
+                                    tx.setFieldAmount(
+                                        sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id}));
+                                }
+                            }},
+                        {error.first, error.second},
+                        [&](Account const& a1, Account const& a2, Env& env) {
+                            Account const gw("gw");
+                            env.fund(XRP(1'000), gw);
+                            MPTTester const usd(
+                                {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100});
+                            id = usd.issuanceID();
+                            // Either gate enforces, so both must be off to stay
+                            // advisory. Disable after setting up the MPT; the
+                            // next env.close() is what makes it take effect.
+                            if (!gates[featureMPTokensV2])
+                                env.disableFeature(featureMPTokensV2);
+                            if (!gates[fixCleanup3_4_0])
+                                env.disableFeature(fixCleanup3_4_0);
+                            return true;
+                        });
+                }
+            }
+        }
+
+        // An orphan has a zero balance, so only deletion is legitimate (see
+        // "Skipping Deleted MPTs" in testConfidentialMPTTransfer).
+        {
+            MPTID orphanID;
+            auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) {
+                MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+                mpt.create({.flags = tfMPTCanTransfer});
+                orphanID = mpt.issuanceID();
+                // A2 is authorized but never paid, so its balance is zero and
+                // the issuance can be destroyed while its MPToken lives on.
+                mpt.authorize({.account = a2});
+                mpt.destroy();
+                return true;
+            };
+            // ValidMPTBalanceChanges also reports this, so assert on the
+            // orphan message, which only the missing-issuance branch produces.
+            doInvariantCheck(
+                {{"orphaned MPToken balance changed"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
+                        return false;
+                    (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
+                    ac.view().update(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan);
+            // Negative control: erasing the orphan is how it gets cleaned up.
+            doInvariantCheck(
+                {},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tesSUCCESS, tesSUCCESS},
+                setupOrphan);
+            // The same erase on a failure. The orphan branch continues, so only
+            // the pre-loop deletion check can report this one.
+            doInvariantCheck(
+                {{"MPToken deleted on failure"}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
+                    if (!sleTok)
+                        return false;
+                    ac.view().erase(sleTok);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                setupOrphan,
+                TxAccount::None,
+                std::source_location::current(),
+                tecEXPIRED);
+        }
+
+        // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
+        // through sfReferenceHolding to test the vault's underlying asset for
+        // each changed holder.
+        {
+            Account const gw{"gw"};
+            MPTID shareID{};
+
+            // Vault setup: a1 and a2 both deposit IOU and hold vault shares.
+            auto const setupVault = [&](Account const& a1,
+                                        Account const& a2,
+                                        Env& env) -> std::tuple {
+                env.fund(XRP(1'000), gw);
+                env.trust(gw["IOU"](10'000), a1);
+                env.trust(gw["IOU"](10'000), a2);
+                env.close();
+                env(pay(gw, a1, gw["IOU"](500)));
+                env(pay(gw, a2, gw["IOU"](500)));
+                env.close();
+
+                Vault const vault{env};
+                auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]});
+                env(createTx);
+                env.close();
+                env(vault.deposit(
+                    {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
+                env(vault.deposit(
+                    {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
+                env.close();
+
+                return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)};
+            };
+
+            // Simulate a vault-share transfer: a1 sends 10 shares to a2.
+            auto const precheck =
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool {
+                auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id()));
+                auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
+                if (!sle1 || !sle2)
+                    return false;
+                (*sle1)[sfMPTAmount] -= 10;
+                (*sle2)[sfMPTAmount] += 10;
+                ac.view().update(sle1);
+                ac.view().update(sle2);
+                return true;
+            };
+
+            // Case: vault pseudo-account's IOU trustline is frozen.
+            {
+                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                    auto [sid, vid] = setupVault(a1, a2, env);
+                    shareID = sid;
+                    env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze));
+                    env.close();
+                    return true;
+                };
+
+                doInvariantCheck(
+                    Env{*this, all_},
+                    {{"invalid MPToken transfer between holders"}},
+                    precheck,
+                    XRPAmount{},
+                    STTx{ttPAYMENT, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    preclose);
+            }
+
+            // Case: receiver's (a2's) IOU trustline is frozen.
+            {
+                auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+                    auto [sid, vid] = setupVault(a1, a2, env);
+                    shareID = sid;
+                    env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
+                    env.close();
+                    return true;
+                };
+
+                doInvariantCheck(
+                    Env{*this, all_},
+                    {{"invalid MPToken transfer between holders"}},
+                    precheck,
+                    XRPAmount{},
+                    STTx{ttPAYMENT, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    preclose);
+            }
+        }
+    }
+
+    void
+    testConfidentialMPTTransfer()
+    {
+        using namespace test::jtx;
+        testcase << "ValidConfidentialMPToken";
+
+        MPTID mptID;
+
+        // Generate an MPT with privacy, issue 100 tokens to A2.
+        // Perform a confidential conversion to populate encrypted state.
+        auto const precloseConfidential =
+            [&mptID](Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
+            mptID = mpt.issuanceID();
+
+            mpt.authorize({.account = a2});
+            mpt.pay(a1, a2, 100);
+
+            mpt.generateKeyPair(a1);
+            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
+
+            mpt.generateKeyPair(a2);
+            mpt.convert({
+                .account = a2,
+                .amt = 100,
+                .holderPubKey = mpt.getPubKey(a2),
+            });
+            return true;
+        };
+
+        // badDelete
+        doInvariantCheck(
+            {"MPToken deleted with encrypted fields while COA > 0"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Force an erase of the object while the COA remains 100
+                ac.view().erase(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseConfidential);
+
+        // badConsistency
+        doInvariantCheck(
+            {"MPToken encrypted field existence inconsistency"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Remove one of the required encrypted fields to create a mismatch
+                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        doInvariantCheck(
+            {"MPToken encrypted field existence inconsistency"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
+                sleToken->makeFieldAbsent(sfConfidentialBalanceInbox);
+                sleToken->makeFieldAbsent(sfConfidentialBalanceSpending);
+                sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00});
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // requiresPrivacyFlag
+        auto const precloseNoPrivacy = [&mptID](
+                                           Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            // completely omitted the tfMPTCanHoldConfidentialBalance flag here.
+            mpt.create({.flags = tfMPTCanTransfer});
+            mptID = mpt.issuanceID();
+            mpt.authorize({.account = a2});
+            mpt.pay(a1, a2, 100);
+            return true;
+        };
+
+        doInvariantCheck(
+            {"MPToken has encrypted fields but Issuance does not have "
+             "lsfMPTCanHoldConfidentialBalance "
+             "set"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Inject all three encrypted fields consistently (inbox+spending+issuer must be
+                // in sync or badConsistency fires first and masks requiresPrivacyFlag).
+                sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00});
+                sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00});
+                sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00});
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseNoPrivacy);
+
+        // badCOA
+        doInvariantCheck(
+            {"Confidential outstanding amount exceeds total outstanding amount"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+                // Total outstanding is natively 100; bloat the COA over 100
+                sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200);
+                ac.view().update(sleIssuance);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Conservation Violation
+        doInvariantCheck(
+            {"Token conservation violation for MPT"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+
+                sleIssuance->setFieldU64(
+                    sfConfidentialOutstandingAmount,
+                    sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10);
+                ac.view().update(sleIssuance);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0)
+        doInvariantCheck(
+            {"Invariant failed: OutstandingAmount changed "
+             "by confidential transaction that should not "
+             "modify it for MPT"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
+                if (!sleIssuance)
+                    return false;
+                sleIssuance->setFieldU64(
+                    sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1);
+                ac.view().update(sleIssuance);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Send/MergeInbox and zero-COA-delta confidential transactions must not
+        // change public holder MPTAmount.
+        doInvariantCheck(
+            {"Invariant failed: MPTAmount changed by confidential "
+             "transaction that should not modify this field."},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1);
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
+            // Second pass is tef: the bumped MPTAmount also trips
+            // ValidMPTTransfer's on-failure check, which escalates the tec.
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseConfidential);
+
+        // badVersion
+        doInvariantCheck(
+            {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending "
+             "changed"},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Blob const kChangedConfidentialSpending = {0xBA, 0xDD};
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending);
+
+                // DO NOT update sfConfidentialBalanceVersion
+                ac.view().update(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseConfidential);
+
+        // Skipping Deleted MPTs (Issuance deleted)
+        auto const precloseOrphan = [&mptID](
+                                        Account const& a1, Account const& a2, Env& env) -> bool {
+            MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
+            mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
+            mptID = mpt.issuanceID();
+            mpt.authorize({.account = a2});
+
+            // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
+            mpt.generateKeyPair(a1);
+            mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
+            mpt.generateKeyPair(a2);
+            mpt.convert({
+                .account = a2,
+                .amt = 0,
+                .holderPubKey = mpt.getPubKey(a2),
+            });
+
+            // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
+            mpt.destroy();
+            return true;
+        };
+
+        doInvariantCheck(
+            {},
+            [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
+                if (!sleToken)
+                    return false;
+                // Safely able to erase the deleted token.
+                ac.view().erase(sleToken);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
+            {tesSUCCESS, tesSUCCESS},
+            precloseOrphan);
+    }
+
+public:
+    void
+    run() override
+    {
+        testConfidentialMPTTransfer();
+        testMPT();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsMPT, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsMisc_test.cpp b/src/test/app/invariants/InvariantsMisc_test.cpp
new file mode 100644
index 0000000000..b0b6c02f5c
--- /dev/null
+++ b/src/test/app/invariants/InvariantsMisc_test.cpp
@@ -0,0 +1,1333 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsMisc_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testXRPNotCreated()
+    {
+        using namespace test::jtx;
+        testcase << "XRP created";
+        doInvariantCheck(
+            {{"XRP net change was positive: 500"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // put a single account in the view and "manufacture" some XRP
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto amt = sle->getFieldAmount(sfBalance);
+                sle->setFieldAmount(sfBalance, amt + STAmount{500});
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testAccountRootsNotRemoved()
+    {
+        using namespace test::jtx;
+        testcase << "account root removed";
+
+        // An account was deleted, but not by an AccountDelete transaction.
+        doInvariantCheck(
+            {{"an account root was deleted"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // remove an account from the view
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                ac.view().erase(sle);
+                return true;
+            });
+
+        // Successful AccountDelete transaction that didn't delete an account.
+        //
+        // Note that this is a case where a second invocation of the invariant
+        // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED.
+        // After a discussion with the team, we believe that's okay.
+        doInvariantCheck(
+            {{"account deletion succeeded without deleting an account"}},
+            [](Account const&, Account const&, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // Successful AccountDelete that deleted more than one account.
+        doInvariantCheck(
+            {{"account deletion succeeded but deleted multiple accounts"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // remove two accounts from the view
+                auto sleA1 = ac.view().peek(keylet::account(a1.id()));
+                auto sleA2 = ac.view().peek(keylet::account(a2.id()));
+                if (!sleA1 || !sleA2)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA2->at(sfBalance) = beast::kZero;
+                ac.view().erase(sleA1);
+                ac.view().erase(sleA2);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+    }
+
+    void
+    testAccountRootsDeletedClean()
+    {
+        using namespace test::jtx;
+        testcase << "account root deletion left artifact";
+
+        doInvariantCheck(
+            {{"account deletion left behind a non-zero balance"}},
+            // NOLINTNEXTLINE(readability-identifier-naming)
+            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                // A1 has a balance. Delete A1
+                auto const a1 = A1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1));
+                if (!sleA1)
+                    return false;
+                if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero))
+                    return false;
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a non-zero owner count"}},
+            // NOLINTNEXTLINE(readability-identifier-naming)
+            [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                // Increment A1's owner count, then delete A1
+                auto const a1 = A1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1));
+                if (!sleA1)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sleA1->at(sfBalance) = beast::kZero;
+                BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0);
+                increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoredOwnerCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoringOwnerCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const a1Id = a1.id();
+                auto const sleA1 = ac.view().peek(keylet::account(a1Id));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setFieldU32(sfSponsoringAccountCount, 1);
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setAccountID(sfSponsor, a2.id());
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            Env{*this, FeatureBitset{featureSponsor}},
+            {{"account deletion left behind a sponsorship field"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleA1 = ac.view().peek(keylet::account(a1.id()));
+                if (!sleA1)
+                    return false;
+                sleA1->at(sfBalance) = beast::kZero;
+                sleA1->setAccountID(sfSponsor, a2.id());
+
+                ac.view().erase(sleA1);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+
+        for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets)
+        {
+            if (!includeInTests)
+                continue;
+
+            using namespace std::string_literals;
+
+            doInvariantCheck(
+                {{"account deletion left behind a "s + type.cStr() + " object"}},
+                // NOLINTNEXTLINE(readability-identifier-naming)
+                [&](Account const& A1, Account const& A2, ApplyContext& ac) {
+                    // Add an object to the ledger for account A1, then delete
+                    // A1
+                    auto const a1 = A1.id();
+                    auto sleA1 = ac.view().peek(keylet::account(a1));
+                    if (!sleA1)
+                        return false;
+
+                    auto const key = std::invoke(keyletfunc, a1);
+                    auto const newSLE = std::make_shared(key);
+                    ac.view().insert(newSLE);
+                    // Clear the balance so the "account deletion left behind a
+                    // non-zero balance" check doesn't trip earlier than the
+                    // desired check.
+                    sleA1->at(sfBalance) = beast::kZero;
+                    ac.view().erase(sleA1);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
+        }
+
+        // NFT special case
+        doInvariantCheck(
+            {{"account deletion left behind a NFTokenPage object"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                // remove an account from the view
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const&, Env& env) {
+                // Preclose callback to mint the NFT which will be deleted in
+                // the Precheck callback above.
+                env(token::mint(a1));
+
+                return true;
+            });
+
+        // AMM special cases
+        AccountID ammAcctID;
+        uint256 ammKey;
+        Issue ammIssue;
+        doInvariantCheck(
+            {{"account deletion left behind a DirectoryNode object"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // Delete the AMM account without cleaning up the directory or
+                // deleting the AMM object
+                auto sle = ac.view().peek(keylet::account(ammAcctID));
+                if (!sle)
+                    return false;
+
+                BEAST_EXPECT(sle->at(~sfAMMID));
+                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
+
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                // Preclose callback to create the AMM which will be partially
+                // deleted in the Precheck callback above.
+                AMM const amm(env, a1, XRP(100), a1["USD"](50));
+                ammAcctID = amm.ammAccount();
+                ammKey = amm.ammID();
+                ammIssue = amm.lptIssue();
+                return true;
+            });
+        doInvariantCheck(
+            {{"account deletion left behind a AMM object"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // Delete all the AMM's trust lines, remove the AMM from the AMM
+                // account's directory (this deletes the directory), and delete
+                // the AMM account. Do not delete the AMM object.
+                auto sle = ac.view().peek(keylet::account(ammAcctID));
+                if (!sle)
+                    return false;
+
+                BEAST_EXPECT(sle->at(~sfAMMID));
+                BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);
+
+                for (auto const& trustKeylet :
+                     {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)})
+                {
+                    auto const line = ac.view().peek(trustKeylet);
+                    if (!line)
+                    {
+                        return false;
+                    }
+
+                    STAmount const lowLimit = line->at(sfLowLimit);
+                    STAmount const highLimit = line->at(sfHighLimit);
+                    BEAST_EXPECT(
+                        trustDelete(
+                            ac.view(),
+                            line,
+                            lowLimit.getIssuer(),
+                            highLimit.getIssuer(),
+                            ac.journal) == tesSUCCESS);
+                }
+
+                auto const ammSle = ac.view().peek(keylet::amm(ammKey));
+                if (!BEAST_EXPECT(ammSle))
+                    return false;
+                auto const ownerDirKeylet = keylet::ownerDir(ammAcctID);
+
+                BEAST_EXPECT(
+                    ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false));
+                BEAST_EXPECT(
+                    !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet));
+
+                // Clear the balance so the "account deletion left behind a
+                // non-zero balance" check doesn't trip earlier than the desired
+                // check.
+                sle->at(sfBalance) = beast::kZero;
+                sle->at(sfOwnerCount) = 0;
+                ac.view().erase(sle);
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                // Preclose callback to create the AMM which will be partially
+                // deleted in the Precheck callback above.
+                AMM const amm(env, a1, XRP(100), a1["USD"](50));
+                ammAcctID = amm.ammAccount();
+                ammKey = amm.ammID();
+                ammIssue = amm.lptIssue();
+                return true;
+            });
+    }
+
+    void
+    testTypesMatch()
+    {
+        using namespace test::jtx;
+        testcase << "ledger entry types don't match";
+        doInvariantCheck(
+            {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // replace an entry in the table with an SLE of a different type
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto const sleNew = std::make_shared(ltTICKET, sle->key());
+                ac.rawView().rawReplace(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"invalid ledger entry type added"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                // add an entry in the table with an SLE of an invalid type
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                // make a dummy escrow ledger entry, then change the type to an
+                // unsupported value so that the valid type invariant check
+                // will fail.
+                auto const sleNew = std::make_shared(
+                    keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2)));
+
+                // We don't use ltNICKNAME directly since it's marked deprecated
+                // to prevent accidental use elsewhere.
+                sleNew->type_ = static_cast('n');
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testXRPBalanceCheck()
+    {
+        using namespace test::jtx;
+        testcase << "XRP balance checks";
+
+        doInvariantCheck(
+            {{"Cannot return non-native STAmount as XRPAmount"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // non-native balance
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                STAmount const nonNative(a2["USD"](51));
+                sle->setFieldAmount(sfBalance, nonNative);
+                ac.view().update(sle);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}},
+            [this](Account const& a1, Account const&, ApplyContext& ac) {
+                // balance exceeds genesis amount
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                // Use `drops(1)` to bypass a call to STAmount::canonicalize
+                // with an invalid value
+                sle->setFieldAmount(sfBalance, kInitialXrp + drops(1));
+                BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative());
+                ac.view().update(sle);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"incorrect account XRP balance"},
+             {"XRP net change of -1000000001 doesn't match fee 0"}},
+            [this](Account const& a1, Account const&, ApplyContext& ac) {
+                // balance is negative
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                sle->setFieldAmount(sfBalance, STAmount{1, true});
+                BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative());
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testTransactionFeeCheck()
+    {
+        using namespace test::jtx;
+        using namespace std::string_literals;
+        testcase << "Transaction fee checks";
+
+        doInvariantCheck(
+            {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{-1});
+
+        doInvariantCheck(
+            {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)},
+             {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{kInitialXrp});
+
+        doInvariantCheck(
+            {{"fee paid is 20 exceeds fee specified in transaction."},
+             {"XRP net change of 0 doesn't match fee 20"}},
+            [](Account const&, Account const&, ApplyContext&) { return true; },
+            XRPAmount{20},
+            STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }});
+    }
+
+    void
+    testNoBadOffers()
+    {
+        using namespace test::jtx;
+        testcase << "no bad offers";
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer with negative takerpays
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer with negative takergets
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleNew->setFieldAmount(sfTakerGets, XRP(-1));
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) {
+                // offer XRP to XRP
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                auto sleNew = std::make_shared(
+                    keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
+                sleNew->setAccountID(sfAccount, a1.id());
+                sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]);
+                sleNew->setFieldAmount(sfTakerPays, XRP(10));
+                sleNew->setFieldAmount(sfTakerGets, XRP(11));
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testValidNewAccountRoot()
+    {
+        using namespace test::jtx;
+        testcase << "valid new account root";
+
+        doInvariantCheck(
+            {{"account root created illegally"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert a new account root created by a non-payment into
+                // the view.
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"multiple accounts created in a single transaction"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert two new account roots into the view.
+                {
+                    Account const a3{"A3"};
+                    Keylet const acctKeylet = keylet::account(a3);
+                    auto const sleA3 = std::make_shared(acctKeylet);
+                    ac.view().insert(sleA3);
+                }
+                {
+                    Account const a4{"A4"};
+                    Keylet const acctKeylet = keylet::account(a4);
+                    auto const sleA4 = std::make_shared(acctKeylet);
+                    ac.view().insert(sleA4);
+                }
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"account created with wrong starting sequence number"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                // Insert a new account root with the wrong starting sequence.
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, ac.view().seq() + 1);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created by a wrong transaction type"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"account created with wrong starting sequence number"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, ac.view().seq());
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_CREATE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created with wrong flags"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject& tx) {}});
+
+        doInvariantCheck(
+            {{"pseudo-account created with wrong flags"}},
+            [](Account const&, Account const&, ApplyContext& ac) {
+                Account const a3{"A3"};
+                Keylet const acctKeylet = keylet::account(a3);
+                auto const sleNew = std::make_shared(acctKeylet);
+                sleNew->setFieldU32(sfSequence, 0);
+                sleNew->setFieldH256(sfAMMID, uint256(1));
+                sleNew->setFieldU32(
+                    sfFlags,
+                    lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttAMM_CREATE, [](STObject& tx) {}});
+    }
+
+    void
+    testNoModifiedUnmodifiableFields()
+    {
+        testcase("no modified unmodifiable fields");
+        using namespace jtx;
+
+        // Initialize with a placeholder value because there's no default ctor
+        Keylet loanBrokerKeylet = keylet::amendments();
+        Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) {
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+            loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset);
+            return BEAST_EXPECT(env.le(loanBrokerKeylet));
+        };
+
+        {
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfSequence) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); },
+                [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); },
+                [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); },
+                [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const& a1, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(loanBrokerKeylet);
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createLoanBroker);
+            }
+        }
+
+        // TODO: Loan Object
+
+        // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
+        // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
+        Keylet closedEndedVaultKeylet = keylet::amendments();
+        Preclose const createClosedEndedVault = [&, this](
+                                                    Account const& a, Account const&, Env& env) {
+            auto const sub = env.now().time_since_epoch().count() + 60;
+            auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create(
+                {.owner = a,
+                 .asset = xrpIssue(),
+                 .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+                 .subscriptionDate = sub,
+                 .redemptionDate = red});
+            env(tx);
+            closedEndedVaultKeylet = keylet;
+            return BEAST_EXPECT(env.le(closedEndedVaultKeylet));
+        };
+
+        {
+            // Each mutation must keep the vault otherwise valid so that only the immutability check
+            // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind
+            // stays within the recognised range.
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(closedEndedVaultKeylet);
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttACCOUNT_SET, [](STObject&) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createClosedEndedVault);
+            }
+        }
+
+        {
+            auto const mods = std::to_array>({
+                [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
+                [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); },
+            });
+
+            for (auto const& mod : mods)
+            {
+                doInvariantCheck(
+                    {{"changed an unchangeable field"}},
+                    [&](Account const& a1, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(keylet::account(a1.id()));
+                        if (!sle)
+                            return false;
+                        mod(sle);
+                        ac.view().update(sle);
+                        return true;
+                    });
+            }
+        }
+    }
+
+    void
+    testInvariantOverwrite(FeatureBitset features)
+    {
+        using namespace test::jtx;
+        bool const fixEnabled = features[fixCleanup3_1_3];
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+        std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS};
+
+        // Insert two trust line SLEs in hash-sorted order, with the "bad"
+        // entry at the lower-sorting key so it is visited first by
+        // ApplyStateTable::visit(). The configurer callables receive the
+        // SLE and the Issue corresponding to that side's keylet currency.
+        auto const insertOrderedTrustLinePair = [](ApplyContext& ac,
+                                                   Account const& a1,
+                                                   Account const& a2,
+                                                   Account const& a3,
+                                                   auto const& badConfig,
+                                                   auto const& goodConfig) {
+            char const* const c1 = "USD";
+            char const* const c2 = "EUR";
+            auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency);
+            auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency);
+
+            bool const k1First = k1.key < k2.key;
+            auto const& badKey = k1First ? k1 : k2;
+            auto const& goodKey = k1First ? k2 : k1;
+            Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()};
+            Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()};
+
+            auto const sleBad = std::make_shared(badKey);
+            badConfig(*sleBad, badIss);
+            ac.view().insert(sleBad);
+
+            auto const sleGood = std::make_shared(goodKey);
+            goodConfig(*sleGood, goodIss);
+            ac.view().insert(sleGood);
+        };
+
+        // Regression: bad XRP trust line followed by a valid trust line.
+        // With the fix, the invariant catches the violation. Without it,
+        // the valid entry overwrites the flag to false. The keylet
+        // currencies are non-XRP (the invariant inspects sfLowLimit /
+        // sfHighLimit issue, not the keylet currency).
+        testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"an XRP trust line was created"}}
+                       : std::vector{},
+            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Account const a3{"A3"};
+                insertOrderedTrustLinePair(
+                    ac,
+                    a1,
+                    a2,
+                    a3,
+                    [](SLE& sle, Issue const& iss) {
+                        // sfLowLimit has xrpIssue, making isXrp = true
+                        sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                    },
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                    });
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            fixEnabled ? failTers : passTers);
+
+        // Regression: bad deep-freeze trust line followed by a valid one.
+        testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"a trust line with deep freeze flag without "
+                                                   "normal freeze was created"}}
+                       : std::vector{},
+            [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Account const a3{"A3"};
+                insertOrderedTrustLinePair(
+                    ac,
+                    a1,
+                    a2,
+                    a3,
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                        sle.setFieldU32(sfFlags, lsfLowDeepFreeze);
+                    },
+                    [](SLE& sle, Issue const& iss) {
+                        sle.setFieldAmount(sfLowLimit, STAmount{iss, 0});
+                        sle.setFieldAmount(sfHighLimit, STAmount{iss, 0});
+                        sle.setFieldU32(sfFlags, 0u);
+                    });
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            fixEnabled ? failTers : passTers);
+
+        // Regression: MPT OutstandingAmount exceeds max, but locked <=
+        // outstanding. Plain assignment would overwrite bad_ = true.
+        // With the fix, NoZeroEscrow catches it.
+        // Without the fix, NoZeroEscrow passes but ValidMPTIssuance
+        // still fires ("a MPT issuance was created").
+        testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : "");
+        doInvariantCheck(
+            makeEnv(features),
+            fixEnabled ? std::vector{{"escrow specifies invalid amount"}}
+                       : std::vector{{"a MPT issuance was created"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+
+                MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))};
+                auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID()));
+                // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_
+                sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1);
+                // locked is valid and <= outstanding -> must NOT clear bad_
+                sleNew->setFieldU64(sfLockedAmount, 10);
+                ac.view().insert(sleNew);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttACCOUNT_SET, [](STObject&) {}},
+            failTers);
+    }
+
+    void
+    testSponsorship()
+    {
+        using namespace test::jtx;
+        using namespace std::string_literals;
+        testcase("Sponsorship");
+        {
+            auto const expectMessage =
+                "SponsoredOwnerCount does not equal SponsoringOwnerCount delta.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoringOwnerCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "OwnerCount must be greater than or equal to SponsoredOwnerCount.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfOwnerCount, 0);
+                    sle->setFieldU32(sfSponsoredOwnerCount, 1);
+                    ac.view().update(sle);
+
+                    auto const sle2 = ac.view().peek(keylet::account(a2.id()));
+                    if (!sle2)
+                        return false;
+                    sle2->setFieldU32(sfSponsoringOwnerCount, 1);
+                    ac.view().update(sle2);
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta.";
+            uint256 checkID;
+
+            doInvariantCheck(
+                {{expectMessage}},
+                [&](Account const&, Account const& a2, ApplyContext& ac) {
+                    auto const check = ac.view().peek(keylet::check(checkID));
+                    if (!check)
+                        return false;
+                    check->setAccountID(sfSponsor, a2.id());
+                    ac.view().update(check);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&checkID](Account const& a1, Account const& a2, Env& env) {
+                    checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key;
+                    env(check::create(a1, a2, XRP(1)));
+                    return true;
+                });
+        }
+
+        {
+            auto const expectMessage =
+                "Invariant failed: Net delta of SponsoringAccountCount does "
+                "not match net delta of sfSponsor presence.";
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setFieldU32(sfSponsoringAccountCount, 1);
+                    ac.view().update(sle);
+                    return true;
+                });
+
+            doInvariantCheck(
+                {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto const sle = ac.view().peek(keylet::account(a1.id()));
+                    if (!sle)
+                        return false;
+                    sle->setAccountID(sfSponsor, a2.id());
+                    ac.view().update(sle);
+                    return true;
+                });
+        }
+    }
+
+    void
+    testObjectHasPseudoAccount()
+    {
+        testcase << "object has pseudo-account";
+        using namespace jtx;
+
+        auto const amendments = all_ | fixCleanup3_3_0;
+
+        // Vault: object deleted without its pseudo-account
+        {
+            Keylet vaultKeylet = keylet::amendments();
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted Vault without deleting its pseudo-account"}},
+                [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(vaultKeylet);
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttVAULT_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&vaultKeylet](Account const& a1, Account const&, Env& env) {
+                    Vault const vault{env};
+                    auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                    env(tx);
+                    vaultKeylet = keylet;
+                    return true;
+                });
+        }
+
+        // AMM: object deleted without its pseudo-account
+        {
+            uint256 ammID{};
+            Account const gw{"gw"};
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted AMM without deleting its pseudo-account"}},
+                [&ammID](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::amm(ammID));
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttAMM_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&ammID, &gw](Account const&, Account const&, Env& env) {
+                    env.fund(XRP(1'000), gw);
+                    AMM const amm(env, gw, XRP(100), gw["USD"](100));
+                    ammID = amm.ammID();
+                    return true;
+                });
+        }
+
+        // LoanBroker: object deleted without its pseudo-account
+        {
+            Keylet loanBrokerKeylet = keylet::amendments();
+            doInvariantCheck(
+                Env{*this, amendments},
+                {{"deleted LoanBroker without deleting its pseudo-account"}},
+                [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(loanBrokerKeylet);
+                    if (!sle)
+                        return false;
+                    ac.view().erase(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) {
+                    PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+                    loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
+                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
+                });
+        }
+
+        // Deleted object missing sfAccount field (defensive check).
+        // Manually construct the view to place a vault SLE without
+        // sfAccount into the base ledger, then erase it.
+        {
+            Env env{*this, amendments};
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            OpenView ov{*env.current()};
+
+            auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq()));
+            auto sleVault = std::make_shared(vaultKeylet);
+            sleVault->makeFieldAbsent(sfAccount);
+            ov.rawInsert(sleVault);
+
+            STTx const tx{ttVAULT_DELETE, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            auto sle = ac.view().peek(vaultKeylet);
+            if (!BEAST_EXPECT(sle))
+                return;
+            ac.view().erase(sle);
+
+            auto transactor = makeTransactor(ac);
+            if (!BEAST_EXPECT(transactor))
+                return;
+            TER const result = transactor->checkInvariants(
+                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
+            BEAST_EXPECT(result == tecINVARIANT_FAILED);
+            BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
+        }
+    }
+
+    void
+    testTxCheckException()
+    {
+        testcase << "txCheck exception";
+        using namespace jtx;
+
+        // A TxInvariantCheck that throws from the requested hook, so we can
+        // exercise checkInvariantsHelper's catch block via the
+        // transaction-specific layer (as opposed to the protocol layer,
+        // which testObjectHasPseudoAccount's last case already covers via a
+        // real Transactor's finalizeInvariants).
+        enum class ThrowFrom { VisitEntry, Finalize };
+
+        struct ThrowingTxInvariantCheck : TxInvariantCheck
+        {
+            ThrowFrom const throwFrom;
+
+            explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom)
+            {
+            }
+
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+                if (throwFrom == ThrowFrom::VisitEntry)
+                    throw std::runtime_error("test-injected visitEntry exception");
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                if (throwFrom == ThrowFrom::Finalize)
+                    throw std::runtime_error("test-injected finalize exception");
+                return true;
+            }
+        };
+
+        for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize})
+        {
+            Env env{*this};
+            Account const alice{"alice"};
+            env.fund(XRP(1000), alice);
+            env.close();
+
+            OpenView ov{*env.current()};
+            STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            ApplyContext ac{
+                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+            // visitEntry only runs for entries the transaction touched, so
+            // make a modification for the traversal to report.
+            auto sle = ac.view().peek(keylet::account(alice.id()));
+            if (!BEAST_EXPECT(sle))
+                return;
+            sle->at(sfSequence) = sle->at(sfSequence) + 1;
+            ac.view().update(sle);
+
+            ThrowingTxInvariantCheck throwing{throwFrom};
+            TER terActual = tesSUCCESS;
+            for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+            {
+                terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing);
+                BEAST_EXPECT(terExpect == terActual);
+                BEAST_EXPECT(sink.messages().str().contains(
+                    "Transaction caused an exception during invariant checks"));
+            }
+        }
+    }
+
+    void
+    testTxCheckFinalizeFalse()
+    {
+        testcase << "txCheck finalize returns false";
+        using namespace jtx;
+
+        // A TxInvariantCheck whose finalize returns false, so we can exercise
+        // the "Transaction has failed one or more transaction invariants"
+        // log path in checkInvariantsHelper independently of any real
+        // transactor. This is the transaction-layer analogue of the
+        // protocol-layer coverage in testObjectHasPseudoAccount / others.
+        struct FailingTxInvariantCheck : TxInvariantCheck
+        {
+            void
+            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
+            {
+            }
+
+            [[nodiscard]] bool
+            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
+            {
+                return false;
+            }
+        };
+
+        Env env{*this};
+        Account const alice{"alice"};
+        env.fund(XRP(1000), alice);
+        env.close();
+
+        OpenView ov{*env.current()};
+        STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
+        test::StreamSink sink{beast::Severity::Warning};
+        beast::Journal const jlog{sink};
+        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
+        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
+
+        FailingTxInvariantCheck failing;
+        TER terActual = tesSUCCESS;
+        for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
+        {
+            terActual = checkInvariants(ac, terActual, XRPAmount{}, failing);
+            BEAST_EXPECT(terExpect == terActual);
+            BEAST_EXPECT(sink.messages().str().contains(
+                "Transaction has failed one or more transaction invariants"));
+            // The protocol-layer log must not appear: only the tx-layer
+            // finalize failed here.
+            BEAST_EXPECT(!sink.messages().str().contains(
+                "Transaction has failed one or more global invariants"));
+        }
+    }
+
+    void
+    run() override
+    {
+        testXRPNotCreated();
+        testAccountRootsNotRemoved();
+        testAccountRootsDeletedClean();
+        testTypesMatch();
+        testXRPBalanceCheck();
+        testTransactionFeeCheck();
+        testNoBadOffers();
+        testValidNewAccountRoot();
+        testNoModifiedUnmodifiableFields();
+        testInvariantOverwrite(all_);
+        testInvariantOverwrite(all_ - fixCleanup3_1_3);
+        testObjectHasPseudoAccount();
+        testSponsorship();
+        testTxCheckException();
+        testTxCheckFinalizeFalse();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsMisc, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsPermissioned_test.cpp b/src/test/app/invariants/InvariantsPermissioned_test.cpp
new file mode 100644
index 0000000000..87349fb9e1
--- /dev/null
+++ b/src/test/app/invariants/InvariantsPermissioned_test.cpp
@@ -0,0 +1,957 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsPermissioned_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testPermissionedDomainInvariants(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const fixEnabled = features[fixCleanup3_1_3];
+        std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED};
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+
+        testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : "");
+
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain with no rules."}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                return createPermissionedDomain(ac, a1, a2, 0).get();
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 2";
+
+        static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1;
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                return !!createPermissionedDomain(ac, a1, a2, kTooBig);
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 3";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't sorted"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
+
+                STArray credentials(sfAcceptedCredentials, 2);
+                for (std::size_t n = 0; n < 2; ++n)
+                {
+                    auto cred = STObject::makeInnerObject(sfCredential);
+                    cred.setAccountID(sfIssuer, a2);
+                    auto credType = std::string("cred_type") + std::to_string(9 - n);
+                    cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                    credentials.pushBack(std::move(cred));
+                }
+                slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                ac.view().update(slePd);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain 4";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't unique"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto slePd = createPermissionedDomain(ac, a1, a2, 0);
+
+                STArray credentials(sfAcceptedCredentials, 2);
+                for (std::size_t n = 0; n < 2; ++n)
+                {
+                    auto cred = STObject::makeInnerObject(sfCredential);
+                    cred.setAccountID(sfIssuer, a2);
+                    cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
+                    credentials.pushBack(std::move(cred));
+                }
+                slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                ac.view().update(slePd);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 1";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain with no rules."}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD with empty rules
+                {
+                    STArray const credentials(sfAcceptedCredentials, 2);
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 2";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, kTooBig);
+
+                    for (std::size_t n = 0; n < kTooBig; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        auto credType = "cred_type2" + std::to_string(n);
+                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                        credentials.pushBack(std::move(cred));
+                    }
+
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 3";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't sorted"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, 2);
+                    for (std::size_t n = 0; n < 2; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        auto credType = std::string("cred_type2") + std::to_string(9 - n);
+                        cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                        credentials.pushBack(std::move(cred));
+                    }
+
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        testcase << "PermissionedDomain Set 4";
+        doInvariantCheck(
+            makeEnv(features),
+            {{"permissioned domain credentials aren't unique"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create PD
+                auto slePd = createPermissionedDomain(ac, a1, a2);
+
+                // update PD
+                {
+                    STArray credentials(sfAcceptedCredentials, 2);
+                    for (std::size_t n = 0; n < 2; ++n)
+                    {
+                        auto cred = STObject::makeInnerObject(sfCredential);
+                        cred.setAccountID(sfIssuer, a2);
+                        cred.setFieldVL(sfCredentialType, Slice("cred_type", 9));
+                        credentials.pushBack(std::move(cred));
+                    }
+                    slePd->setFieldArray(sfAcceptedCredentials, credentials);
+                    ac.view().update(slePd);
+                }
+
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+            fixEnabled ? failTers : badTers);
+
+        std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS};
+
+        std::vector const badMoreThan1{
+            {"transaction affected more than 1 permissioned domain entry."}};
+        std::vector const emptyV;
+        std::vector const badNoDomains{{"no domain objects affected by"}};
+        std::vector const badNotDeleted{
+            {"domain object modified, but not deleted by "}};
+        std::vector const badDeleted{{"domain object deleted by"}};
+        std::vector const badTx{
+            {"domain object(s) affected by an unauthorized transaction."}};
+
+        {
+            testcase << "PermissionedDomain set 2 domains ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badMoreThan1 : emptyV,
+                [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    createPermissionedDomain(ac, a1, a2, 2, 11);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del 2 domains";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badMoreThan1 : emptyV,
+                [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
+                    auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2});
+                    ac.view().erase(sle1);
+                    ac.view().erase(sle2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain set 0 domains ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badNoDomains : emptyV,
+                [](Account const&, Account const&, ApplyContext&) { return true; },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? badTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del 0 domains";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badNoDomains : emptyV,
+                [](Account const&, Account const&, ApplyContext&) { return true; },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? badTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain set, delete domain";
+
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? badDeleted : emptyV,
+                [&pd1](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1});
+                    ac.view().erase(sle1);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain del, create domain ";
+            doInvariantCheck(
+                makeEnv(features),
+                fixEnabled ? badNotDeleted : emptyV,
+                [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}},
+                fixEnabled ? failTers : goodTers);
+        }
+
+        {
+            testcase << "PermissionedDomain invalid tx";
+
+            doInvariantCheck(
+                fixEnabled ? badTx : emptyV,
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    createPermissionedDomain(ac, a1, a2);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttPAYMENT, [](STObject&) {}},
+                failTers);
+        }
+    }
+
+    void
+    testPermissionedDEX(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const fixEnabled = features[fixCleanup3_1_3];
+
+        testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : "");
+
+        doInvariantCheck(
+            makeEnv(features),
+            {{"domain doesn't exist"}},
+            [](Account const& a1, Account const&, ApplyContext& ac) {
+                Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10));
+                auto sleOffer = std::make_shared(offerKey);
+                sleOffer->setAccountID(sfAccount, a1);
+                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                ac.view().insert(sleOffer);
+                return true;
+            },
+            XRPAmount{},
+            STTx{
+                ttOFFER_CREATE,
+                [](STObject& tx) {
+                    tx.setFieldH256(
+                        sfDomainID,
+                        uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33"
+                                "70F3649CE134E5"});
+                    Account const a1{"A1"};
+                    tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                    tx.setFieldAmount(sfTakerGets, XRP(1));
+                }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // missing domain ID in offer object
+        doInvariantCheck(
+            makeEnv(features),
+            {{"hybrid offer is malformed"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                auto sleOffer = std::make_shared(offerKey);
+                sleOffer->setAccountID(sfAccount, a2);
+                sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                sleOffer->setFlag(lsfHybrid);
+
+                STArray bookArr;
+                bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                ac.view().insert(sleOffer);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttOFFER_CREATE, [&](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        // more than one entry in sfAdditionalBooks
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"hybrid offer is malformed"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+
+                    STArray bookArr;
+                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                    bookArr.pushBack(STObject::makeInnerObject(sfBook));
+                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        // empty sfAdditionalBooks (size 0)
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                fixEnabled ? std::vector{{"hybrid offer is malformed"}}
+                           : std::vector{},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+
+                    STArray const bookArr;  // empty array, size 0
+                    sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED}
+                           : std::initializer_list{tesSUCCESS, tesSUCCESS});
+        }
+
+        // hybrid offer missing sfAdditionalBooks
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"hybrid offer is malformed"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFlag(lsfHybrid);
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttOFFER_CREATE, [&](STObject&) {}},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"transaction consumed wrong domains"}},
+                [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    sleOffer->setFieldH256(sfDomainID, pd1);
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttOFFER_CREATE,
+                    [&pd2, &a1](STObject& tx) {
+                        tx.setFieldH256(sfDomainID, pd2);
+                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                        tx.setFieldAmount(sfTakerGets, XRP(1));
+                    }},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+
+        {
+            Env env1(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env1.fund(XRP(1000), a1, a2);
+            env1.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2);
+            env1.close();
+
+            doInvariantCheck(
+                std::move(env1),
+                a1,
+                a2,
+                {{"domain transaction affected regular offers"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10));
+                    auto sleOffer = std::make_shared(offerKey);
+                    sleOffer->setAccountID(sfAccount, a2);
+                    sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+                    sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+                    ac.view().insert(sleOffer);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{
+                    ttOFFER_CREATE,
+                    [&](STObject& tx) {
+                        Account const a1{"A1"};
+                        tx.setFieldH256(sfDomainID, pd1);
+                        tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                        tx.setFieldAmount(sfTakerGets, XRP(1));
+                    }},
+                {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+        }
+    }
+
+    void
+    testPermissionedDEXDeletedOfferFallback()
+    {
+        using namespace test::jtx;
+
+        testcase << "PermissionedDEX null after";
+
+        // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that
+        // domain lands in the set finalize consults. after == null is never
+        // tracked (pre-340: after-only; post-340: early return) — same result,
+        // both sides are coverage/regression that we do not fall back to before.
+        auto const check = [this](
+                               FeatureBitset features,
+                               bool const afterIsNull,
+                               bool const isDelete,
+                               bool const expectInvariantFailure) {
+            Env env(*this, features);
+
+            Account const a1{"A1"};
+            Account const a2{"A2"};
+            env.fund(XRP(1000), a1, a2);
+            env.close();
+
+            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2);
+            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2);
+            env.close();
+
+            auto sleOffer =
+                std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10)));
+            sleOffer->setAccountID(sfAccount, a2);
+            sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
+            sleOffer->setFieldAmount(sfTakerGets, XRP(1));
+            sleOffer->setFieldH256(sfDomainID, pd1);
+
+            CurrentTransactionRulesGuard const rulesGuard(env.current()->rules());
+
+            ValidPermissionedDEX invariant;
+            if (afterIsNull)
+            {
+                // Defensive path: after is null. Must not fall back to before.
+                invariant.visitEntry(isDelete, sleOffer, nullptr);
+            }
+            else
+            {
+                // Normal / real-erase path: after is the offer on pd1.
+                invariant.visitEntry(isDelete, nullptr, sleOffer);
+            }
+
+            STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) {
+                              tx.setFieldH256(sfDomainID, pd2);
+                              tx.setFieldAmount(sfTakerPays, a1["USD"](10));
+                              tx.setFieldAmount(sfTakerGets, XRP(1));
+                          }};
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            bool const passed =
+                invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog);
+            BEAST_EXPECT(passed != expectInvariantFailure);
+            if (expectInvariantFailure)
+            {
+                BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains"));
+            }
+            else
+            {
+                BEAST_EXPECT(sink.messages().str().empty());
+            }
+        };
+
+        auto const pre = all_ - fixCleanup3_4_0;
+        auto const post = all_;
+
+        // after == null: not tracked
+        check(pre, true, true, false);
+        check(post, true, true, false);
+
+        // after == offer on pd1
+        // pre-340: domainsOld_ (delete still inserted) → fail
+        check(pre, false, true, true);
+        // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail
+        check(post, false, true, false);
+        check(post, false, false, true);
+    }
+
+    void
+    testBookDirectoryExchangeRate()
+    {
+        using namespace test::jtx;
+        testcase << "book directory exchange rate";
+
+        auto const getBookRootKey = [](Account const& account, std::uint64_t quality) {
+            Book const book{xrpIssue(), account["USD"], std::nullopt};
+            return keylet::quality(keylet::book(book), quality);
+        };
+
+        // Root book-directory pages carry exchange-rate metadata that must
+        // match the quality encoded in the directory key.
+        auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) {
+            auto sleDir = std::make_shared(dir);
+            sleDir->setFieldH256(sfRootIndex, dir.key);
+            STVector256 indexes;
+            indexes.pushBack(uint256{1});
+            sleDir->setFieldV256(sfIndexes, indexes);
+            sleDir->setFieldU64(sfExchangeRate, exchangeRate);
+            return sleDir;
+        };
+
+        // Child pages do not carry quality metadata; they only point back to
+        // the root directory.
+        auto const makeChildPage = [](Keylet const& rootDir) {
+            auto sleDir = std::make_shared(keylet::page(rootDir, 1));
+            sleDir->setFieldH256(sfRootIndex, rootDir.key);
+            STVector256 indexes;
+            indexes.pushBack(uint256{2});
+            sleDir->setFieldV256(sfIndexes, indexes);
+            return sleDir;
+        };
+
+        auto const makeOfferCreateTx = [] {
+            return STTx{ttOFFER_CREATE, [](STObject& tx) {
+                            Account const account{"A1"};
+                            tx.setFieldAmount(sfTakerPays, XRP(1));
+                            tx.setFieldAmount(sfTakerGets, account["USD"](1));
+                        }};
+        };
+        std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED};
+
+        // Creating a root book directory with mismatched exchange-rate
+        // metadata violates the invariant.
+        doInvariantCheck(
+            {{"book directory exchange rate does not match directory quality"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const directoryQuality = STAmount::kURateOne;
+                auto const dir = getBookRootKey(a1, directoryQuality);
+                ac.view().insert(makeRootPage(dir, directoryQuality + 1));
+                return true;
+            },
+            XRPAmount{},
+            makeOfferCreateTx(),
+            failTers);
+
+        // A new child page must point to an existing root page.
+        doInvariantCheck(
+            {{"book directory root missing"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto const directoryQuality = STAmount::kURateOne;
+                auto const rootDir = getBookRootKey(a1, directoryQuality);
+                // Insert only the child page.  It points at rootDir, but the
+                // corresponding root page is intentionally missing.
+                ac.view().insert(makeChildPage(rootDir));
+                return true;
+            },
+            XRPAmount{},
+            makeOfferCreateTx(),
+            failTers);
+
+        // Legacy bad-root tolerance:
+        // - The view contains a pre-existing root page with bad sfExchangeRate
+        //   metadata.
+        // - The simulated transaction only creates a child page pointing to
+        //   that root.
+        // - The invariant must pass because this transaction did not create
+        //   the bad root, only adding a child page.
+        {
+            Env env{*this, all_};
+            Account const a1{"A1"};
+            env.fund(XRP(1000), a1);
+            env.close();
+
+            OpenView view{*env.current()};
+            auto const directoryQuality = STAmount::kURateOne;
+            auto const rootDir = getBookRootKey(a1, directoryQuality);
+            view.rawInsert(makeRootPage(rootDir, directoryQuality + 1));
+
+            ValidBookDirectory invariant;
+            invariant.visitEntry(false, nullptr, makeChildPage(rootDir));
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+            BEAST_EXPECT(
+                invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+        }
+
+        // A bad root is rejected when added, ignored when a legacy bad root is
+        // modified without changing sfRootIndex or deleted, and checked when a
+        // modified directory changes sfRootIndex.
+        {
+            Env env{*this, all_};
+            Account const a1{"A1"};
+            env.fund(XRP(1000), a1);
+            env.close();
+
+            OpenView view{*env.current()};
+            auto const directoryQuality = STAmount::kURateOne;
+            auto const rootDir = getBookRootKey(a1, directoryQuality);
+            auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1);
+            auto const badRoot = makeRootPage(rootDir, directoryQuality + 1);
+            view.rawInsert(badRoot);
+
+            test::StreamSink sink{beast::Severity::Warning};
+            beast::Journal const jlog{sink};
+
+            {
+                // add
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, nullptr, badRoot);
+
+                BEAST_EXPECT(
+                    !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+            {
+                // modify (without changing the sfRootIndex)
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, badRoot, badRoot);
+
+                BEAST_EXPECT(
+                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+            {
+                // modify (changing sfRootIndex to a missing root)
+                auto const childBefore = makeChildPage(rootDir);
+                auto const childAfter = std::make_shared(*childBefore, childBefore->key());
+                childAfter->setFieldH256(sfRootIndex, missingRootDir.key);
+
+                ValidBookDirectory invariant;
+                invariant.visitEntry(false, childBefore, childAfter);
+
+                test::StreamSink missingRootSink{beast::Severity::Warning};
+                beast::Journal const missingRootJlog{missingRootSink};
+                BEAST_EXPECT(!invariant.finalize(
+                    makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog));
+                BEAST_EXPECT(
+                    missingRootSink.messages().str().contains("book directory root missing"));
+            }
+            {
+                // delete
+                view.rawErase(badRoot);
+                BEAST_EXPECT(!view.exists(rootDir));
+
+                ValidBookDirectory invariant;
+                invariant.visitEntry(true, badRoot, badRoot);
+                BEAST_EXPECT(
+                    invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog));
+            }
+        }
+    }
+
+    static SLE::pointer
+    createPermissionedDomain(
+        ApplyContext& ac,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::uint32_t numCreds = 2,
+        std::uint32_t seq = 10)
+    {
+        Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq));
+        auto sle = std::make_shared(pdKeylet);
+
+        sle->setAccountID(sfOwner, a1);
+        sle->setFieldU32(sfSequence, seq);
+
+        if (numCreds != 0u)
+        {
+            // This array is sorted naturally, but if you are going to change
+            // this behavior, don't forget to use credentials::makeSorted
+            STArray credentials(sfAcceptedCredentials, numCreds);
+            for (std::size_t n = 0; n < numCreds; ++n)
+            {
+                auto cred = STObject::makeInnerObject(sfCredential);
+                cred.setAccountID(sfIssuer, a2);
+                auto credType = "cred_type" + std::to_string(n);
+                cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size()));
+                credentials.pushBack(std::move(cred));
+            }
+            sle->setFieldArray(sfAcceptedCredentials, credentials);
+        }
+
+        ac.view().insert(sle);
+        return sle;
+    }
+
+    static std::pair
+    createPermissionedDomainEnv(
+        test::jtx::Env& env,
+        test::jtx::Account const& a1,
+        test::jtx::Account const& a2,
+        std::uint32_t numCreds = 2)
+    {
+        using namespace test::jtx;
+
+        pdomain::Credentials credentials;
+
+        for (std::size_t n = 0; n < numCreds; ++n)
+        {
+            auto credType = "cred_type" + std::to_string(n);
+            credentials.push_back({.issuer = a2, .credType = credType});
+        }
+
+        std::uint32_t const seq = env.seq(a1);
+        env(pdomain::setTx(a1, credentials));
+        uint256 const key = pdomain::getNewDomain(env.meta());
+
+        return {seq, key};
+    }
+
+    void
+    run() override
+    {
+        testPermissionedDomainInvariants(all_);
+        testPermissionedDomainInvariants(all_ - fixCleanup3_1_3);
+        testPermissionedDEX(all_);
+        testPermissionedDEX(all_ - fixCleanup3_1_3);
+        testPermissionedDEXDeletedOfferFallback();
+        testBookDirectoryExchangeRate();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsPermissioned, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp
new file mode 100644
index 0000000000..c43e73aca8
--- /dev/null
+++ b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp
@@ -0,0 +1,461 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsPseudoAccount_test : public InvariantsBase
+{
+    void
+    testValidPseudoAccounts()
+    {
+        testcase << "valid pseudo accounts";
+
+        using namespace jtx;
+
+        AccountID pseudoAccountID;
+        Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) {
+            PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+
+            // Create vault
+            Vault const vault{env};
+            auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset});
+            env(tx);
+            env.close();
+            if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle))
+            {
+                pseudoAccountID = vSle->at(sfAccount);
+            }
+
+            return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID)));
+        };
+
+        /* Cases to check
+            "pseudo-account has 0 pseudo-account fields set"
+            "pseudo-account has 2 pseudo-account fields set"
+            "pseudo-account sequence changed"
+            "pseudo-account flags are not set"
+            "pseudo-account has a regular key"
+            "pseudo-account has a sponsorship field"
+        */
+        struct Mod
+        {
+            std::string expectedFailure;
+            std::function func;
+        };
+        auto const mods = std::to_array({
+            {
+                .expectedFailure = "pseudo-account has 0 pseudo-account fields set",
+                .func =
+                    [this](SLE::pointer& sle) {
+                        BEAST_EXPECT(sle->at(~sfVaultID));
+                        sle->at(~sfVaultID) = std::nullopt;
+                    },
+            },
+            {
+                .expectedFailure = "pseudo-account sequence changed",
+                .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; },
+            },
+            {
+                .expectedFailure = "pseudo-account flags are not set",
+                .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a regular key",
+                .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; },
+            },
+            {
+                .expectedFailure = "pseudo-account has a sponsorship field",
+                .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); },
+            },
+        });
+
+        for (auto const& mod : mods)
+        {
+            doInvariantCheck(
+                {{mod.expectedFailure}},
+                [&](Account const& a1, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
+                    if (!sle)
+                        return false;
+                    mod.func(sle);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createPseudo);
+        }
+        for (auto const pField : getPseudoAccountFields())
+        {
+            // createPseudo creates a vault, so sfVaultID will be set, and
+            // setting it again will not cause an error
+            if (pField == &sfVaultID)
+                continue;
+            doInvariantCheck(
+                {{"pseudo-account has 2 pseudo-account fields set"}},
+                [&](Account const& a1, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(keylet::account(pseudoAccountID));
+                    if (!sle)
+                        return false;
+
+                    auto const vaultID = ~sle->at(~sfVaultID);
+                    BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField));
+                    sle->setFieldH256(*pField, *vaultID);
+
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttACCOUNT_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createPseudo);
+        }
+
+        // Take one of the regular accounts and set the sequence to 0, which
+        // will make it look like a pseudo-account
+        doInvariantCheck(
+            {{"pseudo-account has 0 pseudo-account fields set"},
+             {"pseudo-account sequence changed"},
+             {"pseudo-account flags are not set"}},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                auto sle = ac.view().peek(keylet::account(a1.id()));
+                if (!sle)
+                    return false;
+                sle->at(sfSequence) = 0;
+                ac.view().update(sle);
+                return true;
+            });
+    }
+
+    void
+    testValidLoanBroker()
+    {
+        testcase << "valid loan broker";
+
+        using namespace jtx;
+
+        enum class Asset { XRP, IOU, MPT };
+        auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT});
+
+        for (auto const assetType : assetTypes)
+        {
+            // Initialize with a placeholder value because there's no default
+            // ctor
+            auto const setupAsset =
+                [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset {
+                switch (assetType)
+                {
+                    case Asset::IOU: {
+                        PrettyAsset const iouAsset = issuer["IOU"];
+                        env(trust(alice, iouAsset(1000)));
+                        env(pay(issuer, alice, iouAsset(1000)));
+                        env.close();
+                        return iouAsset;
+                    }
+                    case Asset::MPT: {
+                        MPTTester mptt{env, issuer, kMptInitNoFund};
+                        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
+                        PrettyAsset const mptAsset = mptt.issuanceID();
+                        mptt.authorize({.account = alice});
+                        env(pay(issuer, alice, mptAsset(1000)));
+                        env.close();
+                        return mptAsset;
+                    }
+                    case Asset::XRP:
+                    default:
+                        return PrettyAsset{xrpIssue(), 1'000'000};
+                }
+            };
+
+            Keylet loanBrokerKeylet = keylet::amendments();
+            Preclose const createLoanBroker =
+                [&, this](Account const& alice, Account const& issuer, Env& env) {
+                    auto const asset = setupAsset(alice, issuer, env);
+                    loanBrokerKeylet = this->createLoanBroker(alice, env, asset);
+                    return BEAST_EXPECT(env.le(loanBrokerKeylet));
+                };
+
+            // Ensure the test scenarios are set up completely. The test cases
+            // will need to recompute any of these values it needs for itself
+            // rather than trying to return a bunch of items
+            auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac)
+                -> std::optional> {
+                if (loanBrokerKeylet.type != ltLOAN_BROKER)
+                    return {};
+                auto sleBroker = ac.view().peek(loanBrokerKeylet);
+                if (!sleBroker)
+                    return {};
+                if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0))
+                    return {};
+                // Need to touch sleBroker so that it is included in the
+                // modified entries for the invariant to find
+                ac.view().update(sleBroker);
+
+                // The pseudo-account holds the directory, so get it
+                auto const pseudoAccountID = sleBroker->at(sfAccount);
+                auto const pseudoAccountKeylet = keylet::account(pseudoAccountID);
+                // Strictly speaking, we don't need to load the
+                // ACCOUNT_ROOT, but check anyway
+                auto slePseudo = ac.view().peek(pseudoAccountKeylet);
+                if (!BEAST_EXPECT(slePseudo))
+                    return {};
+                // Make sure the directory doesn't already exist
+                auto const dirKeylet = keylet::ownerDir(pseudoAccountID);
+                auto sleDir = ac.view().peek(dirKeylet);
+                auto const describe = describeOwnerDir(pseudoAccountID);
+                if (!sleDir)
+                {
+                    // Create the directory
+                    BEAST_EXPECT(
+                        ::xrpl::directory::createRoot(
+                            ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0);
+
+                    sleDir = ac.view().peek(dirKeylet);
+                }
+
+                return std::make_pair(slePseudo, sleDir);
+            };
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has multiple directory "
+                  "pages"}},
+                [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
+
+                    BEAST_EXPECT(
+                        ::xrpl::directory::insertPage(
+                            ac.view(),
+                            0,
+                            sleDir,
+                            0,
+                            sleDir,
+                            slePseudo->key(),
+                            keylet::page(sleDir->key(), 0),
+                            describe) == 1);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has multiple indexes in "
+                  "the Directory root"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto indexes = sleDir->getFieldV256(sfIndexes);
+
+                    // Put some extra garbage into the directory
+                    for (auto const& key : {slePseudo->key(), sleDir->key()})
+                    {
+                        ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
+                    }
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker directory corrupt"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    auto const describe = describeOwnerDir(slePseudo->at(sfAccount));
+                    // Empty vector will overwrite the existing entry for the
+                    // holding, if any, avoiding the "has multiple indexes"
+                    // failure.
+                    STVector256 indexes;
+
+                    // Put one meaningless key into the directory
+                    auto const key = keylet::account(Account("random").id()).key;
+                    ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key);
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker with zero OwnerCount has an unexpected entry in "
+                  "the directory"}},
+                [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    auto test = setupTest(a1, a2, ac);
+                    if (!test || !test->first || !test->second)
+                        return false;
+
+                    auto slePseudo = test->first;
+                    auto sleDir = test->second;
+                    // Empty vector will overwrite the existing entry for the
+                    // holding, if any, avoiding the "has multiple indexes"
+                    // failure.
+                    STVector256 indexes;
+
+                    ::xrpl::directory::insertKey(
+                        ac.view(), sleDir, 0, false, indexes, slePseudo->key());
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            doInvariantCheck(
+                {{"Loan Broker sequence number decreased"}},
+                [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                    if (loanBrokerKeylet.type != ltLOAN_BROKER)
+                        return false;
+                    auto sleBroker = ac.view().peek(loanBrokerKeylet);
+                    if (!sleBroker)
+                        return false;
+                    if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0))
+                        return false;
+                    // Need to touch sleBroker so that it is included in the
+                    // modified entries for the invariant to find
+                    ac.view().update(sleBroker);
+
+                    sleBroker->at(sfLoanSequence) -= 1;
+
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+
+            // Test: cover available less than pseudo-account asset balance
+            {
+                Keylet brokerKeylet = keylet::amendments();
+                Preclose const createBrokerWithCover =
+                    [&, this](Account const& alice, Account const& issuer, Env& env) {
+                        auto const asset = setupAsset(alice, issuer, env);
+                        brokerKeylet = this->createLoanBroker(alice, env, asset);
+                        if (!BEAST_EXPECT(env.le(brokerKeylet)))
+                            return false;
+                        env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
+                        env.close();
+                        return BEAST_EXPECT(env.le(brokerKeylet));
+                    };
+
+                doInvariantCheck(
+                    {{"Loan Broker cover available is less than pseudo-account asset balance"}},
+                    [&](Account const&, Account const&, ApplyContext& ac) {
+                        auto sle = ac.view().peek(brokerKeylet);
+                        if (!BEAST_EXPECT(sle))
+                            return false;
+                        // Pseudo-account holds 10 units, set cover to 5
+                        sle->at(sfCoverAvailable) = Number(5);
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                    {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                    createBrokerWithCover);
+            }
+
+            // Test: cover available greater than pseudo-account asset balance
+            // (requires fixCleanup3_1_3)
+            doInvariantCheck(
+                {{"Loan Broker cover available is greater than pseudo-account asset balance"}},
+                [&](Account const&, Account const&, ApplyContext& ac) {
+                    auto sle = ac.view().peek(loanBrokerKeylet);
+                    if (!BEAST_EXPECT(sle))
+                        return false;
+                    // Pseudo-account has no cover deposited; set cover
+                    // higher than any incidental balance
+                    sle->at(sfCoverAvailable) = Number(1'000'000);
+                    ac.view().update(sle);
+                    return true;
+                },
+                XRPAmount{},
+                STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
+                {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+                createLoanBroker);
+        }
+    }
+
+    void
+    run() override
+    {
+        testValidPseudoAccounts();
+        testValidLoanBroker();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsPseudoAccount, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsTrustLine_test.cpp b/src/test/app/invariants/InvariantsTrustLine_test.cpp
new file mode 100644
index 0000000000..e0995fc431
--- /dev/null
+++ b/src/test/app/invariants/InvariantsTrustLine_test.cpp
@@ -0,0 +1,237 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsTrustLine_test : public InvariantsBase
+{
+    void
+    testNoXRPTrustLine()
+    {
+        using namespace test::jtx;
+        testcase << "trust lines with XRP not allowed";
+        doInvariantCheck(
+            {{"an XRP trust line was created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // create simple trust SLE with xrp currency
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency));
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testNoDeepFreezeTrustLinesWithoutFreeze()
+    {
+        using namespace test::jtx;
+        testcase << "trust lines with deep freeze flag without freeze "
+                    "not allowed";
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowDeepFreeze | lsfHighFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+
+        doInvariantCheck(
+            {{"a trust line with deep freeze flag without normal freeze was "
+              "created"}},
+            [](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sleNew =
+                    std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency));
+                sleNew->setFieldAmount(sfLowLimit, a1["USD"](0));
+                sleNew->setFieldAmount(sfHighLimit, a1["USD"](0));
+                std::uint32_t uFlags = 0u;
+                uFlags |= lsfLowFreeze | lsfHighDeepFreeze;
+                sleNew->setFieldU32(sfFlags, uFlags);
+                ac.view().insert(sleNew);
+                return true;
+            });
+    }
+
+    void
+    testTransfersNotFrozen()
+    {
+        using namespace test::jtx;
+        testcase << "transfers when frozen";
+
+        Account const g1{"G1"};
+        // Helper function to establish the trustlines
+        auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) {
+            // Preclose callback to establish trust lines with gateway
+            env.fund(XRP(1000), g1);
+
+            env.trust(g1["USD"](10000), a1);
+            env.trust(g1["USD"](10000), a2);
+            env.close();
+
+            env(pay(g1, a1, g1["USD"](1000)));
+            env(pay(g1, a2, g1["USD"](1000)));
+            env.close();
+
+            return true;
+        };
+
+        auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
+            createTrustlines(a1, a2, env);
+            env(trust(g1, a1["USD"](10000), tfSetFreeze));
+            env.close();
+
+            return true;
+        };
+
+        auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) {
+            a1FrozenByIssuer(a1, a2, env);
+            env(trust(g1, a1["USD"](10000), tfSetDeepFreeze));
+            env.close();
+
+            return true;
+        };
+
+        auto const changeBalances = [&](Account const& a1,
+                                        Account const& a2,
+                                        ApplyContext& ac,
+                                        int a1Balance,
+                                        int a2Balance) {
+            auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"]));
+            auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"]));
+
+            sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance));
+            sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance));
+
+            ac.view().update(sleA1);
+            ac.view().update(sleA2);
+        };
+
+        // test: imitating frozen A1 making a payment to A2.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -900, -1100);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1FrozenByIssuer);
+
+        // test: imitating deep frozen A1 making a payment to A2.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -900, -1100);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1DeepFrozenByIssuer);
+
+        // test: imitating A2 making a payment to deep frozen A1.
+        doInvariantCheck(
+            {{"Attempting to move frozen funds"}},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                changeBalances(a1, a2, ac, -1100, -900);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            a1DeepFrozenByIssuer);
+    }
+
+    void
+    run() override
+    {
+        testNoXRPTrustLine();
+        testNoDeepFreezeTrustLinesWithoutFreeze();
+        testTransfersNotFrozen();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsTrustLine, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
new file mode 100644
index 0000000000..4b6002580b
--- /dev/null
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -0,0 +1,2293 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+class InvariantsVault_test : public InvariantsBase
+{
+    FeatureBitset const all_{test::jtx::testableAmendments()};
+
+    void
+    testVault()  // NOLINT(readability-function-size)
+    {
+        using namespace test::jtx;
+
+        struct AccountAmount
+        {
+            AccountID account;
+            int amount;
+        };
+        struct Adjustments
+        {
+            // NOLINTBEGIN(readability-redundant-member-init)
+            std::optional assetsTotal = std::nullopt;
+            std::optional assetsAvailable = std::nullopt;
+            std::optional lossUnrealized = std::nullopt;
+            std::optional assetsMaximum = std::nullopt;
+            std::optional sharesTotal = std::nullopt;
+            std::optional vaultAssets = std::nullopt;
+            std::optional accountAssets = std::nullopt;
+            std::optional accountShares = std::nullopt;
+            // NOLINTEND(readability-redundant-member-init)
+        };
+        constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
+            // Avoid uint64 + negative-int wrap (flagged by UBSan
+            // unsigned-integer-overflow) when adjusting UINT64 fields.
+            auto const addSigned = [](std::uint64_t current, int adj) -> std::uint64_t {
+                return adj >= 0  //
+                    ? current + static_cast(adj)
+                    : current - static_cast(-adj);
+            };
+            auto sleVault = ac.peek(keylet);
+            if (!sleVault)
+                return false;
+
+            auto const mptIssuanceID = (*sleVault)[sfShareMPTID];
+            auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID));
+            if (!sleShares)
+                return false;
+
+            // These two fields are adjusted in absolute terms
+            if (args.lossUnrealized)
+                (*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
+            if (args.assetsMaximum)
+                (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
+
+            // Remaining fields are adjusted in terms of difference
+            if (args.assetsTotal)
+                (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal;
+            if (args.assetsAvailable)
+            {
+                (*sleVault)[sfAssetsAvailable] =
+                    *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable;
+            }
+            ac.update(sleVault);
+
+            if (args.sharesTotal)
+            {
+                (*sleShares)[sfOutstandingAmount] =
+                    addSigned(*(*sleShares)[sfOutstandingAmount], *args.sharesTotal);
+                ac.update(sleShares);
+            }
+
+            auto const assets = *(*sleVault)[sfAsset];
+            auto const pseudoId = *(*sleVault)[sfAccount];
+            if (args.vaultAssets)
+            {
+                if (assets.native())
+                {
+                    auto slePseudoAccount = ac.peek(keylet::account(pseudoId));
+                    if (!slePseudoAccount)
+                        return false;
+                    (*slePseudoAccount)[sfBalance] =
+                        *(*slePseudoAccount)[sfBalance] + *args.vaultAssets;
+                    ac.update(slePseudoAccount);
+                }
+                else if (assets.holds())
+                {
+                    auto const mptId = assets.get().getMptID();
+                    auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId));
+                    if (!sleMPToken)
+                        return false;
+                    (*sleMPToken)[sfMPTAmount] =
+                        addSigned(*(*sleMPToken)[sfMPTAmount], *args.vaultAssets);
+                    ac.update(sleMPToken);
+                }
+                else
+                {
+                    return false;  // Not supporting testing with IOU
+                }
+            }
+
+            if (args.accountAssets)
+            {
+                auto const& pair = *args.accountAssets;
+                if (assets.native())
+                {
+                    auto sleAccount = ac.peek(keylet::account(pair.account));
+                    if (!sleAccount)
+                        return false;
+                    (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount;
+                    ac.update(sleAccount);
+                }
+                else if (assets.holds())
+                {
+                    auto const mptID = assets.get().getMptID();
+                    auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account));
+                    if (!sleMPToken)
+                        return false;
+                    (*sleMPToken)[sfMPTAmount] =
+                        addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
+                    ac.update(sleMPToken);
+                }
+                else
+                {
+                    return false;  // Not supporting testing with IOU
+                }
+            }
+
+            if (args.accountShares)
+            {
+                auto const& pair = *args.accountShares;
+                auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account));
+                if (!sleMPToken)
+                    return false;
+                (*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
+                ac.update(sleMPToken);
+            }
+            return true;
+        };
+
+        static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments {
+            Adjustments sample = {
+                .assetsTotal = adjustment,
+                .assetsAvailable = adjustment,
+                .lossUnrealized = 0,
+                .sharesTotal = adjustment,
+                .vaultAssets = adjustment,
+                .accountAssets =  //
+                AccountAmount{.account = id, .amount = -adjustment},
+                .accountShares =  //
+                AccountAmount{.account = id, .amount = adjustment}};
+            fn(sample);
+            return sample;
+        };
+
+        Account const a3{"A3"};
+        Account const a4{"A4"};
+        auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+            env.fund(XRP(1000), a3, a4);
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+            env(tx);
+            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+            env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+            return true;
+        };
+
+        testcase << "Vault general checks";
+        doInvariantCheck(
+            {"vault deletion succeeded without deleting a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault updated by a wrong transaction type"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+                sleVault->setAccountID(sfAccount, a1.id());
+                ac.view().insert(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttPAYMENT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"vault deleted by a wrong transaction type",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation updated more than single vault",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                {
+                    auto const keylet =
+                        keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                    auto sleVault = ac.view().peek(keylet);
+                    if (!sleVault)
+                        return false;
+                    ac.view().erase(sleVault);
+                }
+                {
+                    auto const keylet =
+                        keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq()));
+                    auto sleVault = ac.view().peek(keylet);
+                    if (!sleVault)
+                        return false;
+                    ac.view().erase(sleVault);
+                }
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                {
+                    auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                    env(tx);
+                }
+                {
+                    auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()});
+                    env(tx);
+                }
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation updated more than single vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const insertVault = [&](Account const a) {
+                    auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence));
+                    auto sleVault = std::make_shared(vaultKeylet);
+                    auto const vaultPage = ac.view().dirInsert(
+                        keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id()));
+                    sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+                    sleVault->setAccountID(sfAccount, a.id());
+                    ac.view().insert(sleVault);
+                };
+                insertVault(a1);
+                insertVault(a2);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"deleted vault must also delete shares",
+             "deleted Vault without deleting its pseudo-account"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().erase(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"deleted vault must have no shares outstanding",
+             "deleted vault must have no assets outstanding",
+             "deleted vault must have no assets available"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().erase(sleVault);
+                ac.view().erase(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                // Note, such an "orphaned" update of MPT issuance attached to a
+                // vault is invalid; ttVAULT_SET must also update Vault object.
+                sleShares->setFieldH256(sfDomainID, uint256(13));
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without modifying a vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; },
+            XRPAmount{},
+            STTx{ttVAULT_DELETE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"updated vault must have shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsMaximum] = 200;
+                ac.view().update(sleVault);
+
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().erase(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault operation succeeded without updating shares",
+             "assets available must not be greater than assets outstanding"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsTotal] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                return true;
+            });
+
+        doInvariantCheck(
+            {"set must not change assets outstanding",
+             "set must not change assets available",
+             "set must not change shares outstanding",
+             "set must not change vault balance",
+             "assets available must not be negative",
+             "assets available must not be greater than assets outstanding",
+             "assets outstanding must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount]));
+                if (!slePseudoAccount)
+                    return false;
+                (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10;
+                ac.view().update(slePseudoAccount);
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsAvailable = (kDropsPerXrp * -100).value();
+                                   sample.assetsTotal = (kDropsPerXrp * -200).value();
+                                   sample.sharesTotal = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                sleVault->setAccountID(sfAccount, a2.id());
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"violation of vault immutable data"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfShareMPTID] = MPTID(42);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"vault transaction must not change loss unrealized",
+             "set must not change assets outstanding"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = 13;
+                                   sample.assetsTotal = 20;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"loss unrealized must not exceed the difference "
+             "between assets outstanding and available",
+             "vault transaction must not change loss unrealized"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = 13;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is
+        // allowed to change loss unrealized, so it isolates this check from the
+        // "must not change loss unrealized" invariant. Gated behind
+        // fixCleanup3_4_0 (see below).
+        doInvariantCheck(
+            {"loss unrealized must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // Without fixCleanup3_4_0 the same state must NOT trip the invariant,
+        // preserving pre-amendment behavior (no fork risk).
+        doInvariantCheck(
+            makeEnv(all_ - fixCleanup3_4_0),
+            {},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.lossUnrealized = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttLOAN_MANAGE, [](STObject& tx) {}},
+            {tesSUCCESS, tesSUCCESS},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"set assets outstanding must not exceed assets maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = 1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"assets maximum must not be negative"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = -1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"set must not change shares outstanding",
+             "updated zero sized vault must have no assets outstanding",
+             "updated zero sized vault must have no assets available"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfOutstandingAmount] = 0;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject& tx) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"updated shares must not exceed maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfMaximumAmount] = 10;
+                ac.view().update(sleShares);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"updated shares must not exceed maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        testcase << "Vault create";
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "updated zero sized vault must have no assets outstanding",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsTotal] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "updated zero sized vault must have no assets available",
+                "assets available must not be greater than assets outstanding",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsAvailable] = 9;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "loss unrealized must not exceed the difference between assets "
+                "outstanding and available",
+                "vault transaction must not change loss unrealized",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfLossUnrealized] = 1;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "created vault must be empty",
+                "create operation must not have updated a vault",
+                "invalid OutstandingAmount balance 0 9 0",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().update(sleVault);
+                (*sleShares)[sfOutstandingAmount] = 9;
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {
+                "assets maximum must not be negative",
+                "create operation must not have updated a vault",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                (*sleVault)[sfAssetsMaximum] = Number(-1);
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"create operation must not have updated a vault",
+             "shares issuer and vault pseudo-account must be the same",
+             "shares issuer must be a pseudo-account",
+             "shares issuer pseudo-account must point back to the vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                auto sleVault = ac.view().peek(keylet);
+                if (!sleVault)
+                    return false;
+                auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID]));
+                if (!sleShares)
+                    return false;
+                ac.view().update(sleVault);
+                (*sleShares)[sfIssuer] = a1.id();
+                ac.view().update(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const& a2, Env& env) {
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
+                env(tx);
+                return true;
+            });
+
+        doInvariantCheck(
+            {"vault created by a wrong transaction type", "account root created illegally"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                // The code below will create a valid vault with (almost) all
+                // the invariants holding. Except one: it is created by the
+                // wrong transaction type.
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+                // Create pseudo-account.
+                auto sleAccount = std::make_shared(keylet::account(pseudoId));
+                sleAccount->setAccountID(sfAccount, pseudoId);
+                sleAccount->setFieldAmount(sfBalance, STAmount{});
+                std::uint32_t const seqno =                             //
+                    ac.view().rules().enabled(featureSingleAssetVault)  //
+                    ? 0                                                 //
+                    : sequence;
+                sleAccount->setFieldU32(sfSequence, seqno);
+                sleAccount->setFieldU32(
+                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+                ac.view().insert(sleAccount);
+
+                auto const sharesMptId = makeMptID(sequence, pseudoId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                sleShares->at(sfIssuer) = pseudoId;
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                sleVault->at(sfAccount) = pseudoId;
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"shares issuer and vault pseudo-account must be the same",
+             "shares issuer pseudo-account must point back to the vault"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+                // Create pseudo-account.
+                auto sleAccount = std::make_shared(keylet::account(pseudoId));
+                sleAccount->setAccountID(sfAccount, pseudoId);
+                sleAccount->setFieldAmount(sfBalance, STAmount{});
+                std::uint32_t const seqno =                             //
+                    ac.view().rules().enabled(featureSingleAssetVault)  //
+                    ? 0                                                 //
+                    : sequence;
+                sleAccount->setFieldU32(sfSequence, seqno);
+                sleAccount->setFieldU32(
+                    sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+                // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+                // Setting wrong vault key
+                sleAccount->setFieldH256(sfVaultID, uint256(42));
+                ac.view().insert(sleAccount);
+
+                auto const sharesMptId = makeMptID(sequence, pseudoId);
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                sleShares->at(sfIssuer) = pseudoId;
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                // sleVault->at(sfAccount) = pseudoId;
+                // Setting wrong pseudo account ID
+                sleVault->at(sfAccount) = a2.id();
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        doInvariantCheck(
+            {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const sequence = ac.view().seq();
+                auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence));
+                auto sleVault = std::make_shared(vaultKeylet);
+                auto const vaultPage = ac.view().dirInsert(
+                    keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id()));
+                sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+                auto const sharesMptId = makeMptID(sequence, a2.id());
+                auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+                auto sleShares = std::make_shared(sharesKeylet);
+                auto const sharesPage = ac.view().dirInsert(
+                    keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id()));
+                sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+
+                sleShares->at(sfFlags) = 0;
+                // Setting wrong pseudo account ID
+                sleShares->at(sfIssuer) = AccountID(42);
+                sleShares->at(sfOutstandingAmount) = 0;
+                sleShares->at(sfSequence) = sequence;
+
+                sleVault->at(sfAccount) = a2.id();
+                sleVault->at(sfFlags) = 0;
+                sleVault->at(sfSequence) = sequence;
+                sleVault->at(sfOwner) = a1.id();
+                sleVault->at(sfAssetsTotal) = Number(0);
+                sleVault->at(sfAssetsAvailable) = Number(0);
+                sleVault->at(sfLossUnrealized) = Number(0);
+                sleVault->at(sfShareMPTID) = sharesMptId;
+                sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+
+                ac.view().insert(sleVault);
+                ac.view().insert(sleShares);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        testcase << "Vault deposit";
+        doInvariantCheck(
+            {"deposit must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"deposit assets outstanding must not exceed assets maximum"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) {
+                                   sample.assetsMaximum = 1;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        // This really convoluted unit tests makes the zero balance on the
+        // depositor, by sending them the same amount as the transaction fee.
+        // The operation makes no sense, but the defensive check in
+        // ValidVault::finalize is otherwise impossible to trigger.
+        doInvariantCheck(
+            {"deposit must increase vault balance", "deposit must change depositor balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = -100;
+                               }));
+            },
+            XRPAmount{100},
+            STTx{
+                ttVAULT_DEPOSIT,
+                [&](STObject& tx) {
+                    tx[sfFee] = XRPAmount(100);
+                    tx[sfAccount] = a3.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {"deposit must increase vault balance",
+             "deposit must decrease depositor balance",
+             "deposit must change vault and depositor balance by equal amount",
+             "deposit and assets outstanding must add up",
+             "deposit and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A2 to A3 to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.vaultAssets = -20;
+                                   sample.accountAssets->amount = 10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change depositor balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A3 to vault to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change depositor shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit must increase depositor shares",
+             "deposit must change depositor and vault shares by equal amount",
+             "deposit must not change vault balance by more than deposited "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = -5;
+                                   sample.sharesTotal = -10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit and assets outstanding must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
+                ac.view().update(sleA3);
+
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = 11;
+                               }));
+            },
+            XRPAmount{2000},
+            STTx{
+                ttVAULT_DEPOSIT,
+                [&](STObject& tx) {
+                    tx[sfAmount] = XRPAmount(10);
+                    tx[sfDelegate] = a3.id();
+                    tx[sfFee] = XRPAmount(2000);
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"deposit and assets outstanding must add up",
+             "deposit and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = 7;
+                                   sample.assetsAvailable = 7;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        testcase << "Vault withdrawal";
+        doInvariantCheck(
+            {"withdrawal must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // Almost identical to the really convoluted test for deposit, where the
+        // depositor spends only the transaction fee. In case of withdrawal,
+        // this test is almost the same as normal withdrawal where the
+        // sfDestination would have been A4, but has been omitted.
+        doInvariantCheck(
+            {"withdrawal must change one destination balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops to A4 to enforce total XRP balance
+                auto sleA4 = ac.view().peek(keylet::account(a4.id()));
+                if (!sleA4)
+                    return false;
+                (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10;
+                ac.view().update(sleA4);
+
+                return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountAssets->amount = -100;
+                               }));
+            },
+            XRPAmount{100},
+            STTx{
+                ttVAULT_WITHDRAW,
+                [&](STObject& tx) {
+                    tx[sfFee] = XRPAmount(100);
+                    tx[sfAccount] = a3.id();
+                    // This commented out line causes the invariant violation.
+                    // tx[sfDestination] = A4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp);
+
+        doInvariantCheck(
+            {
+                "withdrawal must change vault and destination balance by equal amount",
+                "withdrawal must decrease vault balance",
+                "withdrawal must increase destination balance",
+                "withdrawal and assets outstanding must add up",
+                "withdrawal and assets available must add up",
+            },
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+
+                // Move 10 drops from A2 to A3 to enforce total XRP balance
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10;
+                ac.view().update(sleA3);
+
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.vaultAssets = 10;
+                                   sample.accountAssets->amount = -20;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change one destination balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                 *sample.vaultAssets -= 5;
+                             })))
+                    return false;
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                if (!sleA3)
+                    return false;
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5;
+                ac.view().update(sleA3);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change depositor shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal must decrease depositor shares",
+             "withdrawal must change depositor and vault shares by equal "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = 5;
+                                   sample.sharesTotal = 10;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal and assets outstanding must add up",
+             "withdrawal and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = -15;
+                                   sample.assetsAvailable = -15;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        doInvariantCheck(
+            {"withdrawal and assets outstanding must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto sleA3 = ac.view().peek(keylet::account(a3.id()));
+                (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000;
+                ac.view().update(sleA3);
+
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.assetsTotal = -7;
+                               }));
+            },
+            XRPAmount{2000},
+            STTx{
+                ttVAULT_WITHDRAW,
+                [&](STObject& tx) {
+                    tx[sfAmount] = XRPAmount(10);
+                    tx[sfDelegate] = a3.id();
+                    tx[sfFee] = XRPAmount(2000);
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseXrp,
+            TxAccount::A2);
+
+        auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool {
+            env.fund(XRP(1000), a3, a4);
+
+            // Create MPT asset
+            {
+                json::Value jv;
+                jv[sfAccount] = a3.human();
+                jv[sfTransactionType] = jss::MPTokenIssuanceCreate;
+                jv[sfFlags] = tfMPTCanTransfer;
+                env(jv);
+                env.close();
+            }
+
+            auto const mptID = makeMptID(env.seq(a3) - 1, a3);
+            Asset const asset = MPTIssue(mptID);
+            // Authorize A1 A2 A4
+            {
+                json::Value jv;
+                jv[sfAccount] = a1.human();
+                jv[sfTransactionType] = jss::MPTokenAuthorize;
+                jv[sfMPTokenIssuanceID] = to_string(mptID);
+                env(jv);
+                jv[sfAccount] = a2.human();
+                env(jv);
+                jv[sfAccount] = a4.human();
+                env(jv);
+
+                env.close();
+            }
+            // Send tokens to A1 A2 A4
+            {
+                env(pay(a3, a1, asset(1000)));
+                env(pay(a3, a2, asset(1000)));
+                env(pay(a3, a4, asset(1000)));
+                env.close();
+            }
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
+            env(tx);
+            env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)}));
+            env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)}));
+            env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)}));
+            return true;
+        };
+
+        doInvariantCheck(
+            {"withdrawal must decrease depositor shares",
+             "withdrawal must change depositor and vault shares by equal "
+             "amount"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = 5;
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt,
+            TxAccount::A2);
+
+        testcase << "Vault clawback";
+        doInvariantCheck(
+            {"clawback must change vault balance"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) {
+                                   sample.vaultAssets.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        // Not the same as below check: attempt to clawback XRP
+        doInvariantCheck(
+            {"clawback may only be performed by the asset issuer"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseXrp);
+
+        // Not the same as above check: attempt to clawback MPT by bad account
+        doInvariantCheck(
+            {"clawback may only be performed by the asset issuer"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must decrease vault balance",
+             "clawback must decrease holder shares",
+             "clawback must change vault shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) {
+                                   sample.sharesTotal = 0;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must change holder shares"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares.reset();
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        doInvariantCheck(
+            {"clawback must change holder and vault shares by equal amount",
+             "clawback and assets outstanding must add up",
+             "clawback and assets available must add up"},
+            [&](Account const& a1, Account const& a2, ApplyContext& ac) {
+                auto const keylet =
+                    keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2));
+                return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) {
+                                   sample.accountShares->amount = -8;
+                                   sample.assetsTotal = -7;
+                                   sample.assetsAvailable = -7;
+                               }));
+            },
+            XRPAmount{},
+            STTx{
+                ttVAULT_CLAWBACK,
+                [&](STObject& tx) {
+                    tx[sfAccount] = a3.id();
+                    tx[sfHolder] = a4.id();
+                }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseMpt);
+
+        // ─────────────────────────────────────────────────────────────
+        // Closed-ended vault invariants added in ValidVault::finalize (create must supply both
+        // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase,
+        // withdraw not in Investment, loan origination only in Investment.
+
+        using d = NetClock::duration;
+        using tp = NetClock::time_point;
+
+        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
+
+        // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it
+        // from ac.view().seq(), which depends on how many env.close() calls preclose issued.
+        Keylet closedEndedKeylet = keylet::amendments();
+
+        // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with
+        // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and
+        // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub
+        // leaves the vault in Subscription.
+        auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) {
+            return [&, advanceBySub, doDeposit](
+                       Account const& a1, Account const& a2, Env& env) -> bool {
+                env.fund(XRP(1000), a3, a4);
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create(
+                    {.owner = a1,
+                     .asset = xrpIssue(),
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = red});
+                env(tx);
+                closedEndedKeylet = keylet;
+                if (doDeposit)
+                {
+                    env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
+                    env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
+                }
+                if (advanceBySub >= 0)
+                    env.close(tp{d{sub + advanceBySub}});
+                return true;
+            };
+        };
+
+        // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance)
+        // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE
+        // states no legitimate transactor would produce.
+        auto const insertBareClosedEndedVault =
+            [closedEnded](
+                ApplyContext& ac,
+                Account const& owner,
+                std::optional subscriptionDate,
+                std::optional redemptionDate) -> bool {
+            auto const sequence = ac.view().seq();
+            auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence));
+            auto sleVault = std::make_shared(vaultKeylet);
+            auto const vaultPage = ac.view().dirInsert(
+                keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id()));
+            if (!vaultPage)
+                return false;
+            sleVault->setFieldU64(sfOwnerNode, *vaultPage);
+
+            auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
+            auto sleAccount = std::make_shared(keylet::account(pseudoId));
+            sleAccount->setAccountID(sfAccount, pseudoId);
+            sleAccount->setFieldAmount(sfBalance, STAmount{});
+            sleAccount->setFieldU32(sfSequence, 0);
+            sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
+            sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
+            ac.view().insert(sleAccount);
+
+            auto const sharesMptId = makeMptID(sequence, pseudoId);
+            auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
+            auto sleShares = std::make_shared(sharesKeylet);
+            auto const sharesPage = ac.view().dirInsert(
+                keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
+            if (!sharesPage)
+                return false;
+            sleShares->setFieldU64(sfOwnerNode, *sharesPage);
+            sleShares->at(sfFlags) = 0;
+            sleShares->at(sfIssuer) = pseudoId;
+            sleShares->at(sfOutstandingAmount) = 0;
+            sleShares->at(sfSequence) = sequence;
+
+            sleVault->at(sfAccount) = pseudoId;
+            sleVault->at(sfFlags) = 0;
+            sleVault->at(sfSequence) = sequence;
+            sleVault->at(sfOwner) = owner.id();
+            sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}});
+            sleVault->at(sfAssetsTotal) = Number(0);
+            sleVault->at(sfAssetsAvailable) = Number(0);
+            sleVault->at(sfLossUnrealized) = Number(0);
+            sleVault->at(sfShareMPTID) = sharesMptId;
+            sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
+            sleVault->at(sfVaultKind) = closedEnded;
+            if (subscriptionDate)
+                sleVault->at(sfSubscriptionDate) = *subscriptionDate;
+            if (redemptionDate)
+                sleVault->at(sfRedemptionDate) = *redemptionDate;
+
+            ac.view().insert(sleVault);
+            ac.view().insert(sleShares);
+            return true;
+        };
+
+        testcase << "Vault create closed-ended";
+
+        // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate.
+        doInvariantCheck(
+            {"closed-ended vault must have SubscriptionDate and RedemptionDate"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate;
+        // exercises the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMinInvestmentPeriod - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and
+        // is caught by the sub-minimum branch of the gap check.
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub - 1;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right).
+        doInvariantCheck(
+            {"closed-ended vault RedemptionDate - SubscriptionDate must be "
+             "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                std::uint32_t const sub = 1'000'000'000;
+                std::uint32_t const red = sub + kMaxInvestmentPeriod;
+                return insertBareClosedEndedVault(ac, a1, sub, red);
+            },
+            XRPAmount{},
+            STTx{ttVAULT_CREATE, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED});
+
+        testcase << "Vault deposit closed-ended";
+
+        // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs
+        // simulates an otherwise valid deposit shape so only the phase invariant fires.
+        doInvariantCheck(
+            {"deposit only allowed in Subscription or NoPhase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault withdrawal closed-ended";
+
+        // A withdrawal from a closed-ended vault in the Investment phase.
+        doInvariantCheck(
+            {"withdrawal not allowed during Investment phase"},
+            [&](Account const&, Account const& a2, ApplyContext& ac) {
+                return kAdjust(
+                    ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {}));
+            },
+            XRPAmount{},
+            STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
+            TxAccount::A2);
+
+        testcase << "Vault loan set";
+
+        // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires
+        // on any vault mutation; touching the vault SLE with no field change is sufficient.
+        doInvariantCheck(
+            {"loan origination only allowed in Investment phase"},
+            [&](Account const&, Account const&, ApplyContext& ac) {
+                auto sleVault = ac.view().peek(closedEndedKeylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false));
+
+        testcase << "Vault loan set - closed-ended final payment past "
+                    "RedemptionDate";
+
+        // A newly-created loan against a closed-ended vault must satisfy StartDate +
+        // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same
+        // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant
+        // catches it even when preclaim is bypassed.
+        Keylet closedEndedBrokerKeylet = keylet::amendments();
+        std::uint32_t closedEndedRed = 0;
+        doInvariantCheck(
+            {"closed-ended loan final payment must precede RedemptionDate"},
+            [&](Account const& a1, Account const&, ApplyContext& ac) {
+                // Touch the vault so ValidVault::finalizeLoanSet sees an
+                // entry in afterVault_; the vault is in Investment, so
+                // finalizeLoanSet itself passes.
+                auto sleVault = ac.view().peek(closedEndedKeylet);
+                if (!sleVault)
+                    return false;
+                ac.view().update(sleVault);
+
+                // Read the broker's next loan sequence to build the loan
+                // keylet the same way LoanSet::doApply would.
+                auto sleBroker = ac.view().peek(closedEndedBrokerKeylet);
+                if (!sleBroker)
+                    return false;
+                std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence);
+
+                // Synthesize a Loan whose final scheduled payment lands
+                // exactly at RedemptionDate: StartDate = red, interval = 60,
+                // remaining = 1 => red + 60 >= red.
+                auto sleLoan = std::make_shared(
+                    keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
+                sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
+                sleLoan->at(sfLoanSequence) = loanSeq;
+                sleLoan->at(sfBorrower) = a1.id();
+                sleLoan->at(sfStartDate) = closedEndedRed;
+                sleLoan->at(sfPaymentInterval) = 60;
+                sleLoan->at(sfPaymentRemaining) = 1;
+                sleLoan->at(sfTotalValueOutstanding) = Number(100);
+                sleLoan->at(sfPeriodicPayment) = Number(1);
+                ac.view().insert(sleLoan);
+                return true;
+            },
+            XRPAmount{},
+            STTx{ttLOAN_SET, [](STObject&) {}},
+            {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+            [&](Account const& a1, Account const&, Env& env) -> bool {
+                auto const sub = env.now().time_since_epoch().count() + 60;
+                auto const red = sub + kMinInvestmentPeriod + 1'000'000;
+                closedEndedRed = red;
+
+                Vault const vault{env};
+                auto [tx, keylet] = vault.create(
+                    {.owner = a1,
+                     .asset = xrpIssue(),
+                     .vaultKind = closedEnded,
+                     .subscriptionDate = sub,
+                     .redemptionDate = red});
+                env(tx);
+                closedEndedKeylet = keylet;
+
+                // Create the loan broker; LoanBrokerSet has no phase gate.
+                closedEndedBrokerKeylet =
+                    keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1)));
+                env(loan_broker::set(a1, keylet.key));
+
+                // Advance parent close time into Investment so
+                // ValidVault::finalizeLoanSet is satisfied.
+                env.close(tp{d{sub + 1}});
+                return true;
+            });
+    }
+
+    // Minimal impaired-loan setup for testVaultLossExceedsGap.  Kept
+    // inline here so this file has no dependency on LoanTestBase.
+    Keylet
+    makeImpairedVault(
+        test::jtx::Account const& owner,
+        test::jtx::Account const& borrower,
+        test::jtx::Account const& issuer,
+        test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+
+        env.fund(XRP(1'000'000), issuer, borrower);
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}};
+        env(trust(owner, trustLimit));
+        env(trust(borrower, trustLimit));
+        env.close();
+
+        env(pay(issuer, owner, usd(100'000)));
+        env(pay(issuer, borrower, usd(1'000)));
+        env.close();
+
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults. The 10-year investment window
+        // covers this helper's 120 monthly payments so LoanSet's
+        // RedemptionDate bound is satisfied.
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner,
+             .asset = usd,
+             .subscriptionOffset = std::chrono::seconds{60},
+             .investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull}});
+        env(vaultTx);
+        env.close();
+
+        env(vault.deposit(
+            {.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+
+        {
+            using namespace loan_broker;
+            env(set(owner, vaultKeylet.key),
+                kCoverRateMinimum(percentageToTenthBips(1)),
+                kCoverRateLiquidation(xrpl::lending::kMaxCoverRate),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            env(coverDeposit(owner, brokerKeylet.key, usd(10'000).value()),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+        }
+
+        // LoanSet is gated on Investment; advance out of Subscription.
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerSle = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(brokerSle))
+            return vaultKeylet;
+
+        auto const loanKeylet =
+            keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        {
+            using namespace loan;
+            env(set(borrower, brokerKeylet.key, usd(100).value()),
+                kCounterparty(owner),
+                kInterestRate(TenthBips32{1000}),
+                kPaymentTotal(120),
+                kPaymentInterval(86400u * 30u),
+                kGracePeriod(86400u * 30u),
+                Sig(sfCounterpartySignature, owner),
+                Fee(env.current()->fees().base * 200));
+            env.close();
+
+            // Under fixCleanup3_4_0 impair requires the payment to already
+            // be late, so advance past the loan's due date first.
+            if (env.current()->rules().enabled(fixCleanup3_4_0))
+            {
+                auto const loanSle = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loanSle))
+                    return vaultKeylet;
+                std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+                env.close(
+                    NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+            }
+
+            env(manage(owner, loanKeylet.key, tfLoanImpair));
+            env.close();
+        }
+
+        return vaultKeylet;
+    }
+
+    // Regression test for the loss-vs-gap invariant relaxation introduced
+    // by fixCleanup3_4_0.  Even with the one-unit tolerance, a loss value
+    // exceeding (T - A) by more than one ULP must still fire.  Two
+    // mutations exercise this:
+    //   1. L = (T - A) * 2  — fires under both amendment settings.
+    //   2. L = (T - A) + 2 * oneUnit  — fires post-amendment, catching
+    //      any accidental widening of the tolerance beyond one unit.
+    void
+    testVaultLossExceedsGap()
+    {
+        testcase("vault loss exceeds gap (fixCleanup3_4_0 tolerance)");
+        using namespace test::jtx;
+
+        auto const kExpectedLog = std::vector{
+            "loss unrealized must not exceed the difference between assets "
+            "outstanding and available"};
+
+        for (auto const withFix : {false, true})
+        {
+            FeatureBitset amendments = all_;
+            if (!withFix)
+                amendments = amendments - fixCleanup3_4_0;
+
+            // Variant 1: L = (T - A) * 2. Fires under both settings.
+            {
+                Keylet vaultKeylet = keylet::vault(uint256{});
+                Account const issuer{"issuer_loss_gap"};
+                Account const borrower{"borrower_loss_gap"};
+
+                auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
+                    vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
+                    return BEAST_EXPECT(env.le(vaultKeylet));
+                };
+
+                doInvariantCheck(
+                    makeEnv(amendments),
+                    kExpectedLog,
+                    [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
+                        auto sle = ac.view().peek(vaultKeylet);
+                        if (!sle)
+                            return false;
+                        Number const total = sle->at(sfAssetsTotal);
+                        Number const available = sle->at(sfAssetsAvailable);
+                        (*sle)[sfLossUnrealized] = (total - available) * 2;
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{
+                        ttVAULT_DEPOSIT,
+                        [&vaultKeylet](STObject& tx) {
+                            tx.setFieldH256(sfVaultID, vaultKeylet.key);
+                        }},
+                    {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+                    preclose,
+                    TxAccount::A1);
+            }
+
+            // Variant 2: L = (T - A) + 2 * oneUnit at scale(T).  Must fire
+            // post-fix because the tolerance is exactly one unit.  A
+            // regression that widened it to two units would silently accept
+            // this state.
+            {
+                Keylet vaultKeylet = keylet::vault(uint256{});
+                Account const issuer{"issuer_loss_gap2"};
+                Account const borrower{"borrower_loss_gap2"};
+
+                auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool {
+                    vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env);
+                    return BEAST_EXPECT(env.le(vaultKeylet));
+                };
+
+                doInvariantCheck(
+                    makeEnv(amendments),
+                    kExpectedLog,
+                    [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool {
+                        auto sle = ac.view().peek(vaultKeylet);
+                        if (!sle)
+                            return false;
+                        Number const total = sle->at(sfAssetsTotal);
+                        Number const available = sle->at(sfAssetsAvailable);
+                        Asset const asset = sle->at(sfAsset);
+                        Number const oneUnit{1, scale(total, asset)};
+                        (*sle)[sfLossUnrealized] = (total - available) + oneUnit * 2;
+                        ac.view().update(sle);
+                        return true;
+                    },
+                    XRPAmount{},
+                    STTx{
+                        ttVAULT_DEPOSIT,
+                        [&vaultKeylet](STObject& tx) {
+                            tx.setFieldH256(sfVaultID, vaultKeylet.key);
+                        }},
+                    {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
+                    preclose,
+                    TxAccount::A1);
+            }
+        }
+    }
+
+    void
+    testVaultComputeCoarsestScale()
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        PrettyAsset const vaultAsset = issuer["IOU"];
+
+        struct TestCase
+        {
+            std::string name;
+            std::int32_t expectedMinScale;
+            std::vector values;
+        };
+
+        for (auto const mantissaScale : MantissaRange::getAllScales())
+        {
+            if (mantissaScale == MantissaRange::MantissaScale::Small)
+                continue;
+            NumberMantissaScaleGuard const g{mantissaScale};
+
+            auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
+                return {.delta = n, .scale = scale(n, vaultAsset.raw())};
+            };
+
+            auto const testCases = std::vector{
+                {
+                    .name = "No values",
+                    .expectedMinScale = 0,
+                    .values = {},
+                },
+                {
+                    .name = "Mixed integer and Number values",
+                    .expectedMinScale = -15,
+                    .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})},
+                },
+                {
+                    .name = "Mixed scales",
+                    .expectedMinScale = -17,
+                    .values =
+                        {makeDelta(Number{1, -2}),
+                         makeDelta(Number{5, -3}),
+                         makeDelta(Number{3, -2})},
+                },
+                {
+                    .name = "Equal scales",
+                    .expectedMinScale = -16,
+                    .values =
+                        {makeDelta(Number{1, -1}),
+                         makeDelta(Number{5, -1}),
+                         makeDelta(Number{1, -1})},
+                },
+                {
+                    .name = "Mixed mantissa sizes",
+                    .expectedMinScale = -12,
+                    .values =
+                        {makeDelta(Number{1}),
+                         makeDelta(Number{1234, -3}),
+                         makeDelta(Number{12345, -6}),
+                         makeDelta(Number{123, 1})},
+                },
+            };
+
+            for (auto const& tc : testCases)
+            {
+                testcase("vault computeCoarsestScale: " + tc.name);
+
+                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
+
+                BEAST_EXPECTS(
+                    actualScale == tc.expectedMinScale,
+                    "expected: " + std::to_string(tc.expectedMinScale) +
+                        ", actual: " + std::to_string(actualScale));
+                for (auto const& num : tc.values)
+                {
+                    // None of these scales are far enough apart that rounding the
+                    // values would lose information, so check that the rounded
+                    // value matches the original.
+                    auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                    BEAST_EXPECTS(
+                        actualRounded == num.delta,
+                        "number " + to_string(num.delta) + " rounded to scale " +
+                            std::to_string(actualScale) + " is " + to_string(actualRounded));
+                }
+            }
+
+            auto const testCases2 = std::vector{
+                {
+                    .name = "False equivalence",
+                    .expectedMinScale = -15,
+                    .values =
+                        {
+                            makeDelta(Number{1234567890123456789, -18}),
+                            makeDelta(Number{12345, -4}),
+                            makeDelta(Number{1}),
+                        },
+                },
+            };
+
+            // Unlike the first set of test cases, the values in these test could
+            // look equivalent if using the wrong scale.
+            for (auto const& tc : testCases2)
+            {
+                testcase("vault computeCoarsestScale: " + tc.name);
+
+                auto const actualScale = ValidVault::computeCoarsestScale(tc.values);
+
+                BEAST_EXPECTS(
+                    actualScale == tc.expectedMinScale,
+                    "expected: " + std::to_string(tc.expectedMinScale) +
+                        ", actual: " + std::to_string(actualScale));
+                std::optional first;
+                Number firstRounded;
+                for (auto const& num : tc.values)
+                {
+                    if (!first)
+                    {
+                        first = num.delta;
+                        firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                        continue;
+                    }
+                    auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
+                    BEAST_EXPECTS(
+                        numRounded != firstRounded,
+                        "at a scale of " + std::to_string(actualScale) + " " +
+                            to_string(num.delta) + " == " + to_string(*first));
+                }
+            }
+        }
+    }
+
+    void
+    run() override
+    {
+        testVault();
+        testVaultLossExceedsGap();
+        testVaultComputeCoarsestScale();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(InvariantsVault, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
index 32c49feb02..96adfd5254 100644
--- a/src/test/app/lending/LendingHelpers_test.cpp
+++ b/src/test/app/lending/LendingHelpers_test.cpp
@@ -1901,12 +1901,19 @@ public:
         env.fund(XRP(10'000), lender, borrower);
         env.close();
 
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a near-future
+        // SubscriptionDate, deposit while still in the Subscription phase,
+        // and advance past SubscriptionDate before creating the broker.
+        auto [vaultTx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(vaultTx);
         env.close();
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
         env.close();
 
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
         env(loan_broker::set(lender, vaultKeylet.key));
diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp
index 5efa65d506..d75b359868 100644
--- a/src/test/app/lending/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -6,6 +6,8 @@
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -58,6 +60,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -69,7 +72,13 @@ class LoanBroker_test : public beast::unit_test::Suite
 {
     // Ensure that all the features needed for Lending Protocol are included,
     // even if they are set to unsupported.
-    FeatureBitset const all_{jtx::testableAmendments()};
+    //
+    // featureLendingProtocolV1_1 is excluded from the default set: it adds
+    // the closed-ended vault gate on LoanBrokerSet::preclaim (see
+    // LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
+    // plain open-ended vaults. Tests that specifically exercise the
+    // amendment opt it back in explicitly and use closed-ended vaults.
+    FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
 
     void
     testDisabled()
@@ -869,7 +878,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1105,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             Account const issuer{"issuer"};
             auto const usd = alice["USD"];
-            Env env(*this);
+            Env env(*this, all_);
             env.fund(XRP(100'000), alice);
             env.close();
 
@@ -1208,7 +1217,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // This test is lifted directly from
         // https://bugs.immunefi.com/dashboard/submission/57808
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const alice{"alice"};
         env.fund(XRP(10000), alice);
@@ -1266,7 +1275,7 @@ class LoanBroker_test : public beast::unit_test::Suite
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1374,7 +1383,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1540,7 +1549,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const& broker = issuer;
 
         auto test = [&](auto&& getToken) {
-            Env env(*this);
+            Env env(*this, all_);
 
             env.fund(XRP(1'000), issuer, holder);
             env.close();
@@ -1613,7 +1622,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults";
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"};
         env.fund(XRP(20'000), issuer, lender, borrower);
@@ -1852,7 +1861,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverDeposit IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -1919,7 +1928,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverDeposit MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2002,7 +2011,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const issuer{"issuer"};
         Account const alice{"alice"};
         Account const dest{"dest"};
-        Env env{*this};
+        Env env{*this, all_};
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice, dest);
@@ -2068,7 +2077,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverWithdraw IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2180,7 +2189,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverWithdraw MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2301,7 +2310,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](TrustState trustState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 IOU with state: " << static_cast(trustState);
 
@@ -2426,7 +2435,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](MPTState mptState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 MPT with state: " << static_cast(mptState);
 
@@ -2532,6 +2541,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,
@@ -2743,6 +2878,126 @@ class LoanBroker_test : public beast::unit_test::Suite
         runTestCases(all_ - fixCleanup3_2_0);
     }
 
+    void
+    testCredentialPinsPseudoAccount()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+
+        // A credential issued to a LoanBroker pseudo-account can't be accepted
+        // or deleted by it, so it stays pinned in the pseudo-account's owner
+        // directory and blocks LoanBrokerDelete with tecHAS_OBLIGATIONS. A pin
+        // created before the cure activates is removed by LoanBrokerDelete once
+        // it does.
+        Account const alice{"alice"};  // vault & broker owner
+        Account const attacker{"attacker"};
+        char const credType[] = "FN36";
+
+        Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0};
+        env.fund(XRP(1'000'000), alice, attacker);
+        env.close();
+
+        Vault const vault{env};
+        auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()});
+        env(vtx);
+        env.close();
+        BEAST_EXPECT(env.le(vkeylet));
+
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+        env(set(alice.id(), vkeylet.key));
+        env.close();
+
+        auto const broker = env.le(brokerKeylet);
+        BEAST_EXPECT(broker);
+        Account const pseudo{"broker pseudo-account", broker->at(sfAccount)};
+        env.memoize(pseudo);
+
+        testcase("Credential pins broker pseudo-account");
+        env(credentials::create(pseudo, attacker, credType));
+        env.close();
+
+        auto const credKey = credentials::keylet(pseudo, attacker, credType);
+        BEAST_EXPECT(env.le(credKey));
+        BEAST_EXPECT(ownerCount(env, attacker) == 1);
+
+        env(del(alice.id(), brokerKeylet.key), Ter(tecHAS_OBLIGATIONS));
+        env.close();
+
+        env.enableFeature(fixCleanup3_4_0);
+        env.close();
+
+        // The pre-existing pin no longer blocks deletion; the credential is
+        // cleaned up and the issuer's owner count is restored.
+        testcase("LoanBrokerDelete removes pinned credential");
+        env(del(alice.id(), brokerKeylet.key));
+        env.close();
+
+        BEAST_EXPECT(!env.le(credKey));
+        BEAST_EXPECT(!env.le(brokerKeylet));
+        BEAST_EXPECT(!env.le(keylet::account(pseudo.id())));
+        BEAST_EXPECT(ownerCount(env, attacker) == 0);
+    }
+
+    void
+    testCredentialPinOverflow()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        testcase("Credential pin cleanup is bounded (tecINCOMPLETE)");
+
+        // A pseudo-account can be pinned with more credentials than one
+        // transaction is allowed to clean up. LoanBrokerDelete then removes
+        // them a bounded batch at a time, returning tecINCOMPLETE until the
+        // last batch.
+        Account const alice{"alice"};
+        Account const attacker{"attacker"};
+
+        Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0};
+        env.fund(XRP(10'000'000), alice, attacker);
+        env.close();
+
+        Vault const vault{env};
+        auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()});
+        env(vtx);
+        env.close();
+        BEAST_EXPECT(env.le(vkeylet));
+
+        auto const brokerKeylet =
+            keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+        env(set(alice.id(), vkeylet.key));
+        env.close();
+
+        auto const broker = env.le(brokerKeylet);
+        BEAST_EXPECT(broker);
+        Account const pseudo{"broker pseudo-account", broker->at(sfAccount)};
+        env.memoize(pseudo);
+
+        // Pin more than one cleanup batch's worth of credentials.
+        std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3;
+        for (std::uint16_t i = 0; i < count; ++i)
+            env(credentials::create(pseudo, attacker, std::to_string(i)));
+        env.close();
+        BEAST_EXPECT(ownerCount(env, attacker) == count);
+
+        env.enableFeature(fixCleanup3_4_0);
+        env.close();
+
+        // First delete removes one bounded batch and reports it isn't finished.
+        env(del(alice.id(), brokerKeylet.key), Ter(tecINCOMPLETE));
+        env.close();
+        BEAST_EXPECT(env.le(brokerKeylet));  // broker still exists
+        auto const remaining = ownerCount(env, attacker);
+        BEAST_EXPECT(remaining > 0 && remaining < count);
+
+        // Second delete finishes the cleanup and removes the broker.
+        env(del(alice.id(), brokerKeylet.key));
+        env.close();
+        BEAST_EXPECT(!env.le(brokerKeylet));
+        BEAST_EXPECT(!env.le(keylet::account(pseudo.id())));
+        BEAST_EXPECT(ownerCount(env, attacker) == 0);
+    }
+
 public:
     void
     run() override
@@ -2761,6 +3016,8 @@ public:
 
         testDisabled();
         testLifecycle();
+        testCredentialPinsPseudoAccount();
+        testCredentialPinOverflow();
         testInvalidLoanBrokerDelete();
         testInvalidLoanBrokerSet();
         testRequireAuth();
@@ -2770,6 +3027,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
index 11053b6fd0..a3ca28437d 100644
--- a/src/test/app/lending/LoanCashBasis_test.cpp
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -562,6 +562,7 @@ private:
             BEAST_EXPECT(vaultBeforeImpair);
             Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -612,6 +613,7 @@ private:
                 ? principalOutstanding
                 : totalValueOutstanding - managementFeeOutstanding;
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -822,12 +824,17 @@ private:
         Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding);
         Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair;
 
+        // With fixCleanup3_4_0, impairment is only allowed once the
+        // payment is late. After the earlier LoanPay the due date advanced by
+        // one interval, so use the current due date rather than startDate.
+        std::uint32_t const dueDateBeforeImpair = loanBeforeImpair->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} + 1s);
+
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
-        LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet);
         env.close(
-            stateAtImpair.startDate + std::chrono::seconds(paymentInterval) +
+            NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} +
             std::chrono::seconds(gracePeriod) + 60s);
 
         auto const vaultBeforeDefault = env.le(broker.vaultKeylet());
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
index b0c43190c5..f8bdb5a3d7 100644
--- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -238,6 +238,9 @@ private:
             Ter(tesSUCCESS));
         env.close();
 
+        // Under fixCleanup3_4_0 impair requires the payment to be late.
+        advancePastDueDate(env, loanKeylet);
+
         // Impair the loan to create unrealized loss
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
@@ -461,6 +464,7 @@ private:
         auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
 
         // Realize a loss via impairment before locking.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair));
         env.close();
 
diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp
index 6cced5c97a..dae6f6ce16 100644
--- a/src/test/app/lending/LoanLifecycle_test.cpp
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -347,7 +347,11 @@ private:
             auto const& asset = debtMaximumRequest.asset();
             auto const initialVault = asset(debtMaximumRequest * 100);
 
-            auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim
+            // only accepts closed-ended vaults, so build one and advance
+            // past SubscriptionDate before creating broker/loan.
+            auto [tx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = broker, .asset = asset});
             env(tx, txFee);
             env.close();
 
@@ -356,6 +360,8 @@ private:
                 txFee);
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
 
diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp
index 93d1671feb..038ef4067b 100644
--- a/src/test/app/lending/LoanPay_test.cpp
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -12,12 +13,17 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -30,6 +36,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -730,6 +737,200 @@ private:
         }
     }
 
+    // Which pseudo-account is left holding an unauthorized trust line when the
+    // repayment lands.
+    enum class UnauthorizedPayee {
+        // The vault's own line, as VaultCreate leaves it.
+        Vault,
+        // Same vault, but the issuer authorized the line by hand first.
+        VaultAuthorized,
+        // Vault line authorized, broker owner unable to take the fee, so the
+        // fee goes to the loan broker's pseudo-account instead.
+        Broker,
+    };
+
+    // A vault holding an IOU whose issuer requires authorization ends up with
+    // its own trust line unauthorized: VaultCreate opens the line without the
+    // auth flag, and the pseudo-account has no key to sign a TrustSet for
+    // itself. Neither deposits nor loan origination look at that line, so the
+    // vault appears to work right up to the first repayment, which is the only
+    // step that has to credit the vault back.
+    //
+    // The loan broker's pseudo-account has the same defect for the same reason,
+    // and LoanPay reaches it whenever the broker owner cannot take the fee.
+    //
+    // The issuer can still repair either line by hand, because TrustSet accepts
+    // a line that already exists even when its owner is a pseudo-account.
+    void
+    testRepayIntoUnauthorizedVault()
+    {
+        using namespace jtx;
+
+        Account const issuer{"issuer"};
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        auto runTestCases = [&](FeatureBitset features, UnauthorizedPayee payee) {
+            bool const pseudoExempt = features[fixCleanup3_4_0];
+            // With the vault's line repaired by the issuer, the only remaining
+            // unauthorized payee is the broker's pseudo-account.
+            bool const expectSuccess = pseudoExempt || payee == UnauthorizedPayee::VaultAuthorized;
+
+            auto const payeeLabel = [payee]() -> char const* {
+                switch (payee)
+                {
+                    case UnauthorizedPayee::Vault:
+                        return "vault";
+                    case UnauthorizedPayee::VaultAuthorized:
+                        return "vault authorized by the issuer";
+                    case UnauthorizedPayee::Broker:
+                        return "loan broker";
+                }
+                return "";  // LCOV_EXCL_LINE
+            }();
+
+            testcase << "LoanPay crediting an unauthorized " << payeeLabel << ": pseudo-account "
+                     << (pseudoExempt ? "exempt" : "not exempt");
+
+            Env env{*this, features};
+
+            env.fund(XRP(1'000'000), issuer, lender, borrower);
+            env.close();
+
+            env(fset(issuer, asfRequireAuth));
+            env.close();
+
+            PrettyAsset const asset = issuer[iouCurrency_];
+            env(trust(lender, asset(100'000'000)));
+            env(trust(borrower, asset(100'000'000)));
+            env.close();
+
+            // Authorize the two participants. Nothing asks the issuer to also
+            // authorize the vault, which is the whole point of this test.
+            env(trust(issuer, asset(0), lender, tfSetfAuth));
+            env(trust(issuer, asset(0), borrower, tfSetfAuth));
+            env.close();
+
+            env(pay(issuer, lender, asset(10'000'000)));
+            env(pay(issuer, borrower, asset(10'000)));
+            env.close();
+
+            // Creating the vault and funding it with deposits succeeds even
+            // though the vault cannot be authorized to hold the asset.
+            BrokerInfo const broker{createVaultAndBroker(env, asset, lender)};
+
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            auto const brokerSle = env.le(broker.brokerKeylet());
+            if (!BEAST_EXPECT(vaultSle && brokerSle))
+                return;
+
+            Account const vaultPseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+            Account const brokerPseudo{"broker pseudo-account", brokerSle->at(sfAccount)};
+
+            auto const lineIsAuthorized = [&](Account const& holder) -> bool {
+                auto const line = env.le(keylet::trustLine(holder, asset.raw().get()));
+                if (!BEAST_EXPECT(line))
+                    return false;
+                return line->isFlag(holder.id() > issuer.id() ? lsfLowAuth : lsfHighAuth);
+            };
+
+            BEAST_EXPECT(!lineIsAuthorized(vaultPseudo));
+            BEAST_EXPECT(!lineIsAuthorized(brokerPseudo));
+
+            if (payee != UnauthorizedPayee::Vault)
+            {
+                env(trust(issuer, asset(0), vaultPseudo, tfSetfAuth));
+                env.close();
+                BEAST_EXPECT(lineIsAuthorized(vaultPseudo));
+            }
+
+            using namespace loan;
+
+            // The service fee guarantees the broker is owed something on the
+            // first payment, so the broker leg of the transfer is exercised.
+            Number const serviceFee = asset(2).value();
+            auto const loanKeylet = nextLoanKeylet(env, broker);
+            env(set(borrower, broker.brokerID, asset(1'000).value()),
+                Sig(sfCounterpartySignature, lender),
+                kLoanServiceFee(serviceFee),
+                kInterestRate(percentageToTenthBips(12)),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+
+            // Paying the principal out of the vault never needed authorization.
+            BEAST_EXPECT(env.le(loanKeylet));
+
+            if (payee == UnauthorizedPayee::Broker)
+            {
+                // A deep-frozen owner cannot take the fee, so LoanPay pays it
+                // into the broker's pseudo-account instead.
+                env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze));
+                env.close();
+            }
+
+            auto const state = getCurrentState(env, broker, loanKeylet);
+            STAmount const payment{
+                broker.asset,
+                roundPeriodicPayment(
+                    broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
+
+            // Repayment turns an outstanding loan back into cash the vault can
+            // lend again, so AssetsAvailable is what moves. AssetsTotal already
+            // counted the loan.
+            auto const assetsAvailable = [&]() -> Number {
+                auto const sle = env.le(broker.vaultKeylet());
+                if (!BEAST_EXPECT(sle))
+                    return Number{};
+                return sle->at(sfAssetsAvailable);
+            };
+
+            auto const borrowerBefore = env.balance(borrower, asset).number();
+            auto const vaultBefore = env.balance(vaultPseudo, asset).number();
+            auto const brokerBefore = env.balance(brokerPseudo, asset).number();
+            auto const assetsAvailableBefore = assetsAvailable();
+
+            env(pay(borrower, loanKeylet.key, payment),
+                Ter(expectSuccess ? TER{tesSUCCESS} : TER{tecNO_AUTH}));
+            env.close();
+
+            if (expectSuccess)
+            {
+                BEAST_EXPECT(env.balance(borrower, asset).number() < borrowerBefore);
+                BEAST_EXPECT(env.balance(vaultPseudo, asset).number() > vaultBefore);
+                BEAST_EXPECT(assetsAvailable() > assetsAvailableBefore);
+                // Confirms the broker variant really did route the fee to the
+                // pseudo-account rather than to the owner.
+                BEAST_EXPECT(
+                    (env.balance(brokerPseudo, asset).number() > brokerBefore) ==
+                    (payee == UnauthorizedPayee::Broker));
+
+                // The payee is skipped by the check, not authorized by it: the line that just
+                // took the credit is still missing its auth flag.
+                if (payee == UnauthorizedPayee::Vault)
+                    BEAST_EXPECT(!lineIsAuthorized(vaultPseudo));
+                if (payee == UnauthorizedPayee::Broker)
+                    BEAST_EXPECT(!lineIsAuthorized(brokerPseudo));
+            }
+            else
+            {
+                // A rejected repayment must leave every balance untouched.
+                BEAST_EXPECT(env.balance(borrower, asset).number() == borrowerBefore);
+                BEAST_EXPECT(env.balance(vaultPseudo, asset).number() == vaultBefore);
+                BEAST_EXPECT(env.balance(brokerPseudo, asset).number() == brokerBefore);
+                BEAST_EXPECT(assetsAvailable() == assetsAvailableBefore);
+            }
+        };
+
+        for (auto const& features : {all_, all_ - fixCleanup3_4_0})
+        {
+            runTestCases(features, UnauthorizedPayee::Vault);
+            runTestCases(features, UnauthorizedPayee::VaultAuthorized);
+            runTestCases(features, UnauthorizedPayee::Broker);
+        }
+    }
+
     void
     testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features)
     {
@@ -834,10 +1035,400 @@ private:
             borrowerAfter + vaultAfter + lenderAfter);
     }
 
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate so that it matches the
+    // *current* (already fixed) parentCloseTime of the open ledger, without
+    // closing again. This exercises the same comparison
+    // (parentCloseTime vs. NextPaymentDueDate) at the exact boundary that
+    // env.close() cannot reliably reach.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
+    // With fixCleanup3_4_0, isPaymentLate() uses a strict (Exclusive)
+    // comparison: a payment due exactly "now" is not yet late. A plain
+    // (non-late) LoanPay submitted at the exact NextPaymentDueDate instant
+    // must therefore succeed, advance the due date by exactly one
+    // PaymentInterval, and charge only the regular periodic payment amount
+    // (no late interest / late fee).
+    void
+    testLoanPayAtExactDueDateSucceedsPostAmendment()
+    {
+        testcase("LoanPay at exact due date succeeds with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        // Set a large, non-zero late interest rate and late fee so that if
+        // the late-payment path were incorrectly taken, the extra charge
+        // would be large and easy to detect (far more than any rounding
+        // slack in the regular periodic payment amount).
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        STAmount const payFee{env.current()->fees().base};
+        auto const borrowerBefore = env.balance(borrower, asset).number();
+
+        // A plain payment (no tfLoanLatePayment) for exactly the regular
+        // periodic amount must succeed: at this instant the payment is not
+        // yet late.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le()/env.balance()
+        // do) reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Fee(payFee), Ter(tesSUCCESS));
+
+        auto const borrowerAfter = env.balance(borrower, asset).number();
+
+        // No more than the regular periodic amount (plus the transaction
+        // fee) was charged: if the late-payment path had wrongly been
+        // taken, the (large, non-zero) late interest and late fee set above
+        // would have pushed the charge well past this bound.
+        Number const charged = borrowerBefore - borrowerAfter - Number{payFee};
+        BEAST_EXPECT(charged > Number{});
+        BEAST_EXPECT(charged <= Number{roundedPeriodicPayment});
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - 1);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate + stateBefore.paymentInterval);
+    }
+
+    // Pins the amendment gate itself (as opposed to
+    // testLoanPayAtExactDueDateSucceedsPostAmendment, which pins the
+    // comparison operator): without fixCleanup3_4_0, isPaymentLate() keeps
+    // using the pre-amendment Inclusive comparison, so a payment due exactly
+    // "now" is already considered late, and a plain (non-late) LoanPay is
+    // rejected.
+    void
+    testLoanPayAtExactDueDateFailsPreAmendment()
+    {
+        testcase("LoanPay at exact due date fails without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Without the amendment, the due date is already considered late at
+        // this exact instant, so a plain payment must be rejected.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le() does)
+        // reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // computeLatePayment() must agree with isPaymentLate() at the exact
+    // due-date boundary: once fixCleanup3_4_0 is enabled, a payment due
+    // exactly "now" is not yet late, so a tfLoanLatePayment submitted at
+    // that same instant must be rejected with tecTOO_SOON rather than being
+    // admitted and charged the late interest/fee.
+    void
+    testLoanLatePaymentAtExactDueDateRejectedPostAmendment()
+    {
+        testcase("LoanPay(tfLoanLatePayment) at exact due date rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        // Overpay generously so that, if the late-payment path were
+        // incorrectly admitted, funds would not be the limiting factor;
+        // we want to isolate the timing check itself.
+        STAmount const generousAmount{
+            asset,
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) * 2};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // At this exact instant the loan is not yet late (Exclusive
+        // comparison), so even an explicit late payment must be rejected
+        // as premature, matching the plain-payment path.
+        //
+        // Note: deliberately not calling env.close() after this, for the
+        // same reason given in testLoanPayAtExactDueDateSucceedsPostAmendment
+        // above: closing would discard the direct NextPaymentDueDate
+        // override made via rawReplace().
+        env(pay(borrower, loanKeylet.key, generousAmount, tfLoanLatePayment), Ter(tecTOO_SOON));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // calculateBaseFee must use isPaymentLate(), not a raw inclusive
+    // hasExpired(): once fixCleanup3_4_0 is enabled, a plain catch-up at
+    // exactly NextPaymentDueDate succeeds and can process many payments, so
+    // the fee has to scale with that work. Charging a single base fee here
+    // would disagree with apply (and with the fixCleanup3_1_3 cap).
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePostAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+        BEAST_EXPECT(stateBefore.paymentRemaining > kLoanPaymentsPerFeeIncrement);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+        XRPAmount const escalatedFee{baseFee * (payCount / kLoanPaymentsPerFeeIncrement)};
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Under-fee: apply would process `payCount` payments, so a single
+        // base fee is not enough.
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(telINSUF_FEE_P));
+
+        // Same catch-up with the scaled fee must succeed at this instant.
+        // Do not env.close() after the SLE override (see
+        // testLoanPayAtExactDueDateSucceedsPostAmendment).
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(escalatedFee), Ter(tesSUCCESS));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - payCount);
+    }
+
+    // Without the amendment, inclusive hasExpired still treats the exact
+    // due-date instant as late, so calculateBaseFee correctly charges a
+    // single base fee and apply rejects a plain LoanPay with tecEXPIRED.
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePreAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
     void
     runAmendmentIndependent()
     {
         testLoanSetNearZeroInterestRateSucceeds();
+        testLoanPayAtExactDueDateSucceedsPostAmendment();
+        testLoanPayAtExactDueDateFailsPreAmendment();
+        testLoanLatePaymentAtExactDueDateRejectedPostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePreAmendment();
+        testRepayIntoUnauthorizedVault();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
index b666281fee..ded1c816a2 100644
--- a/src/test/app/lending/LoanRounding_test.cpp
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -948,6 +948,7 @@ private:
         env.close();
 
         // Impair the loan so LossUnrealized > 0.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
@@ -1017,6 +1018,7 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            advancePastDueDate(env, iouLoanKeylet);
             env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index 21772d0617..463273e227 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -5,27 +5,36 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -33,6 +42,30 @@ namespace xrpl::test {
 class LoanSecurity_test : public LoanTestBase
 {
 private:
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate instead, without closing the
+    // ledger again.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
     void
     testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
     {
@@ -411,13 +444,17 @@ private:
         Account const depositor{"depositor"};
         auto const txFee = Fee(XRP(100));
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one and advance past
+        // SubscriptionDate before creating the broker and the loan.
         Env env(*this);
         Vault const vault(env);
 
         env.fund(XRP(10'000), lender, issuer, borrower, depositor);
         env.close();
 
-        auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        auto [tx, vaultKeyLet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(tx, txFee);
         env.close();
 
@@ -425,6 +462,10 @@ private:
             txFee);
         env.close();
 
+        // Move into the Investment phase before creating the broker and
+        // the loan.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeyLet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
@@ -508,10 +549,557 @@ private:
             PaymentParameters{.showStepBalances = true});
     }
 
+    // Verify that with fixCleanup3_4_0:
+    // 1. A loan cannot be impaired before its payment is late.
+    // 2. Impairing a late loan does not change sfNextPaymentDueDate.
+    // 3. The unimpair operation does not change sfNextPaymentDueDate.
+    void
+    testImpairmentPaymentDateUnchanged()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impairment does not change payment due date");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // 1. Impairment must fail when payment is not yet late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 1b. Impairment must still fail at the exact due date instant: a
+        // payment due "now" is not yet late (strict/Exclusive comparison).
+        // Temporarily set NextPaymentDueDate to exactly the current
+        // parentCloseTime (without closing the ledger again), exercise the
+        // check, then restore the original due date.
+        {
+            std::uint32_t const exactNow =
+                env.current()->parentCloseTime().time_since_epoch().count();
+            setLoanNextPaymentDueDate(env, loanKeylet, exactNow);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+            setLoanNextPaymentDueDate(env, loanKeylet, originalNextDueDate);
+        }
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        // 2. Impairment succeeds when payment is late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 3. Unimpair also does not change sfNextPaymentDueDate
+        env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+    }
+
+    // Verify that without fixCleanup3_4_0, the pre-amendment
+    // impair/unimpair behaviour is preserved:
+    // 1. Impairing a loan before its payment is late moves
+    //    sfNextPaymentDueDate to "now".
+    // 2a. Unimpair within the original payment interval restores
+    //     sfNextPaymentDueDate to StartDate + PaymentInterval.
+    // 2b. Unimpair after the original due date sets
+    //     sfNextPaymentDueDate to now + PaymentInterval.
+    void
+    testImpairmentPaymentDatePreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Pre-amendment impair/unimpair date restoration");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        Number const principalRequest{1, 3};
+        auto createNewLoan = [&]() {
+            auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(sleBroker))
+                return keylet::loan(uint256{});
+            auto const lk =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+            env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+                Sig(sfCounterpartySignature, lender),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+            return lk;
+        };
+
+        // Default + delete a loan and replenish first-loss capital so the
+        // broker is ready for the next loan.
+        auto cleanupLoan = [&](Keylet const& loanKeylet, std::uint32_t dueDate) {
+            env.close(NetClock::time_point{NetClock::duration{dueDate + 60}} + 1s);
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+            env.close();
+
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+            auto const coverNeeded =
+                broker.asset(broker.params.coverDeposit).value() - brokerSle->at(sfCoverAvailable);
+            if (coverNeeded > 0)
+            {
+                env(loan_broker::coverDeposit(
+                    lender, broker.brokerID, STAmount{broker.asset, coverNeeded}));
+                env.close();
+            }
+            env(del(lender, loanKeylet.key));
+            env.close();
+        };
+
+        // ---- Case A: impair before late, unimpair within original interval ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            // Payment is not late yet - impair succeeds and moves due date
+            // to now (pre-amendment allows immediate impairment)
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const movedDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(movedDueDate != originalNextDueDate);
+                BEAST_EXPECT(movedDueDate < originalNextDueDate);
+            }
+
+            // Unimpair while still within the original payment interval. The
+            // normal due date (startDate + 600) has not yet expired, so it
+            // should be restored.
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+            }
+
+            cleanupLoan(loanKeylet, originalNextDueDate);
+        }
+
+        // ---- Case B: impair before late, unimpair after original due date ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 10s);
+
+            auto const timeBeforeUnimpair =
+                env.current()->header().parentCloseTime.time_since_epoch().count();
+
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const newDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(newDueDate > originalNextDueDate);
+                BEAST_EXPECT(newDueDate == timeBeforeUnimpair + 600);
+            }
+        }
+    }
+
+    // FN-68: a borrower must not be able to bypass late-payment charges by
+    // paying an impaired, overdue loan with a plain LoanPay. Under
+    // fixCleanup3_4_0 impairment no longer moves the due date, so
+    // the payment logic sees the real (overdue) date: a regular payment is
+    // rejected with tecEXPIRED, and only a tfLoanLatePayment (which charges
+    // the late fee + late interest) is accepted.
+    void
+    testImpairedOverdueLoanPayRequiresLateFlag()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay requires late-payment flag");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        // Loan with non-zero late-payment terms, so the late path carries a
+        // real penalty that the exploit would otherwise avoid.
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        std::uint32_t const paymentsBefore = loanSle->at(sfPaymentRemaining);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // Advance past the due date so the loan is overdue, then impair it
+        // (impairment is only allowed once the payment is late).
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue
+        // loan must be rejected. Before FN-9 the auto-unimpair pushed the due
+        // date into the future and this returned tesSUCCESS, letting the
+        // borrower skip the late fee and late interest.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
+        }
+
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+        }
+    }
+
+    // FN-68 (pre-amendment): documents the original vulnerability. Without
+    // fixCleanup3_4_0, impairing moves the due date and LoanPay
+    // auto-unimpair pushes it into the future before the late check, so a
+    // plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
+    // on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
+    // This is what testImpairedOverdueLoanPayRequiresLateFlag closes once the
+    // amendment is enabled.
+    void
+    testImpairedOverdueLoanPayBypassPreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay bypass (pre-amendment)");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The bug: a plain LoanPay is accepted as on-time and clears the
+        // loan's impaired flag, so the late fee / late interest are never
+        // charged.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+        }
+    }
+
+    // Default uses NextPaymentDueDate + GracePeriod. Once fixCleanup3_4_0
+    // is enabled, that gate is Exclusive, matching impair/isPaymentLate:
+    // default is allowed only after grace has passed, not at the instant
+    // it expires.
+    void
+    testLoanDefaultAtExactGraceExpiryRejectedPostAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Advance far enough that parentCloseTime > GracePeriod, so
+        // (now - grace) cannot underflow when pinning the exact expiry.
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace + 1);
+
+        // parentCloseTime == NextPaymentDueDate + GracePeriod: grace expires
+        // this instant, so default must still be too soon.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanDefault));
+        }
+
+        // One second after grace expires, default succeeds.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace - 1);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
+    void
+    testLoanDefaultAtExactGraceExpirySucceedsPreAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry succeeds without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace);
+
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
     void
     runAmendmentIndependent()
     {
         testRIPD3901();
+        testImpairmentPaymentDateUnchanged();
+        testImpairmentPaymentDatePreAmendment();
+        testImpairedOverdueLoanPayRequiresLateFlag();
+        testImpairedOverdueLoanPayBypassPreAmendment();
+        testLoanDefaultAtExactGraceExpiryRejectedPostAmendment();
+        testLoanDefaultAtExactGraceExpirySucceedsPreAmendment();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index b3669742fe..c9d4a3185b 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -496,9 +496,27 @@ protected:
 
         auto const coverRateMinValue = params.coverRateMin;
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults. Many callers of this
+        // helper leave vaultKind at the OpenEnded default and don't care
+        // about the vault kind per se — they just need a broker on a
+        // vault. When LP V1.1 is enabled, transparently promote to
+        // ClosedEnded so those tests keep working without threading
+        // vaultKind through every call site. Callers that explicitly
+        // asked for ClosedEnded are left untouched. Tests that want to
+        // exercise the open-ended rejection under LP V1.1 build their own
+        // vault directly instead of going through this helper, since it
+        // always promotes OpenEnded once the amendment is enabled.
+        auto effectiveVaultKind = params.vaultKind;
+        if (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+            effectiveVaultKind == VaultKind::OpenEnded)
+        {
+            effectiveVaultKind = VaultKind::ClosedEnded;
+        }
+
         std::optional subscriptionDate;
         std::optional redemptionDate;
-        if (params.vaultKind == VaultKind::ClosedEnded)
+        if (effectiveVaultKind == VaultKind::ClosedEnded)
         {
             auto const nowSec = env.now().time_since_epoch().count();
             subscriptionDate = nowSec + params.subscriptionOffset;
@@ -508,9 +526,9 @@ protected:
         auto [tx, vaultKeylet] = vault.create(
             {.owner = lender,
              .asset = asset,
-             .vaultKind = params.vaultKind == VaultKind::OpenEnded
+             .vaultKind = effectiveVaultKind == VaultKind::OpenEnded
                  ? std::optional{}
-                 : std::optional{std::to_underlying(params.vaultKind)},
+                 : std::optional{std::to_underlying(effectiveVaultKind)},
              .subscriptionDate = subscriptionDate,
              .redemptionDate = redemptionDate});
         if (params.vaultScale)
@@ -656,6 +674,23 @@ protected:
         return true;
     }
 
+    // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with tecTOO_SOON
+    // unless the loan payment is already late. Advance the ledger past the
+    // loan's sfNextPaymentDueDate so shared lifecycle flows still exercise
+    // the tesSUCCESS branch when the amendment is active. No-op when the
+    // amendment is disabled.
+    void
+    advancePastDueDate(jtx::Env& env, Keylet const& loanKeylet)
+    {
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+            return;
+        auto const loan = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loan))
+            return;
+        std::uint32_t const dueDate = loan->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+    }
+
     enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
 
     // Specify the accounts as params to allow other accounts to be used
@@ -1574,12 +1609,30 @@ protected:
 
         // Check the vault
         bool const canImpair = canImpairLoan(env, broker, state);
-        // Impair the loan, if possible
-        env(manage(lender, keylet.key, tfLoanImpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-        // Unimpair the loan
-        env(manage(lender, keylet.key, tfLoanUnimpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        // Under fixCleanup3_4_0, impair rejects a not-yet-late loan with
+        // tecTOO_SOON. Advancing time to satisfy the gate here would push
+        // the loan into a "late" state and break the toEndOfLife flows
+        // (singlePayment/fullPayment) that expect a fresh loan without the
+        // tfLoanLatePayment flag. The tesSUCCESS/tecLIMIT_EXCEEDED impair
+        // path is already covered under fixCleanup3_4_0 by dedicated tests
+        // in LoanSecurity_test.cpp and LoanCashBasis_test.cpp.
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            // Impair the loan, if possible
+            env(manage(lender, keylet.key, tfLoanImpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
+            // Unimpair the loan
+            env(manage(lender, keylet.key, tfLoanUnimpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        }
+        else
+        {
+            // With the fix on, a not-yet-late loan can never be impaired
+            // (tecTOO_SOON) and the follow-up unimpair on an unimpaired
+            // loan is still tecNO_PERMISSION.
+            env(manage(lender, keylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+            env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+        }
 
         auto const nextDueDate = startDate + *loanParams.payInterval;
 
@@ -2170,6 +2223,11 @@ protected:
                 {
                     // Check the vault
                     bool const canImpair = canImpairLoan(env, broker, state);
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. Advance past the loan's next due
+                    // date so this exercises the tesSUCCESS branch. No-op
+                    // when the fix is disabled.
+                    advancePastDueDate(env, loanKeylet);
                     // Impair the loan, if possible
                     env(manage(lender, loanKeylet.key, tfLoanImpair),
                         canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
@@ -2177,7 +2235,11 @@ protected:
                     if (canImpair)
                     {
                         state.flags |= tfLoanImpair;
-                        state.nextPaymentDate = env.now().time_since_epoch().count();
+                        // Prior to fixCleanup3_4_0 impair rewrote
+                        // sfNextPaymentDueDate to parentCloseTime. Under the
+                        // fix, the due date is preserved.
+                        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+                            state.nextPaymentDate = env.now().time_since_epoch().count();
 
                         // Once the loan is impaired, it can't be impaired again
                         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
@@ -2797,7 +2859,17 @@ protected:
 
                     auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
 
-                    if (canImpairLoan(env, broker, state))
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. This periodic-payment loop stays
+                    // within each payment interval, so the loan is never
+                    // late here; skip the impair rather than perturb the
+                    // payment schedule.
+                    auto const loanSle = env.le(loanKeylet);
+                    bool const impairAllowed = BEAST_EXPECT(loanSle) &&
+                        canImpairLoan(env, broker, state) &&
+                        (!env.current()->rules().enabled(fixCleanup3_4_0) ||
+                         isPaymentLate(*env.current(), loanSle));
+                    if (impairAllowed)
                     {
                         // Making a payment will unimpair the loan
                         env(manage(lender, loanKeylet.key, tfLoanImpair));
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index c6ff22bbb3..169a02c462 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -344,7 +345,12 @@ private:
         env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
         env.close();
 
-        // The payment is late by this point
+        // The payment is late by this point. With fixCleanup3_4_0,
+        // isPaymentLate() uses a strict (Exclusive) comparison, so advance
+        // one more ledger close to be sure the due date instant itself has
+        // passed, not merely reached.
+        env.close();
+
         env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
         env.close();
         env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
@@ -530,6 +536,61 @@ private:
         env.close();
     }
 
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+    // attaching a broker to an open-ended vault. VaultCreate itself is
+    // not gated by the amendment, so the same open-ended vault can be
+    // built under either feature set; only the broker create is
+    // amendment-sensitive. Cover both branches: LP V1.1 disabled lets
+    // the broker create succeed, LP V1.1 enabled rejects it. The gate
+    // only fires on the create path; existing brokers keep working.
+    void
+    testLoanBrokerRequiresClosedEndedVault()
+    {
+        testcase("LoanBrokerSet requires closed-ended vault under LP V1.1");
+        using namespace jtx;
+
+        Account const owner{"lp11_owner"};
+
+        auto const build = [&](FeatureBitset features,
+                               TER expected,
+                               std::optional updateExpected = std::nullopt) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = XRP(100)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(loan_broker::set(owner, vaultKeylet.key), Ter(expected));
+            env.close();
+
+            // The create-path gate is the only new check; updates to an
+            // existing broker on the same open-ended vault are not
+            // affected. Only exercise the update path when the create
+            // succeeded (so there is a broker to update).
+            if (updateExpected && expected == tesSUCCESS)
+            {
+                env(loan_broker::set(owner, vaultKeylet.key),
+                    loan_broker::kLoanBrokerId(brokerKeylet.key),
+                    loan_broker::kDebtMaximum(XRP(1'000).value()),
+                    Ter(*updateExpected));
+                env.close();
+            }
+        };
+
+        // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds.
+        build(all_, tesSUCCESS, tesSUCCESS);
+
+        // LP V1.1 enabled -> open-ended vault + broker rejected on create.
+        build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION);
+    }
+
     void
     runAmendmentIndependent()
     {
@@ -541,6 +602,7 @@ private:
         testInvalidLoanPay();
         testRequireAuth();
         testLimitExceeded();
+        testLoanBrokerRequiresClosedEndedVault();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
index 2dbd20f855..04f31c9526 100644
--- a/src/test/app/vault/VaultBugs_test.cpp
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -2,27 +2,42 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
+#include   // IWYU pragma: keep
+#include 
 #include 
 #include 
+#include 
 
+#include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -408,10 +423,17 @@ private:
         };
 
         {
+            // fixCleanup3_4_0 has to be off as well: its depositor-side check
+            // rejects alice's deposit for the same reason, so the invariant is
+            // only reachable with neither guard in place.
             testcase(
                 "bug: VaultDeposit below Vault precision canonicalized to zero "
                 "(pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
+            // Also remove fixCleanup3_4_0 so the VaultDeposit clamp
+            // introduced by that amendment does not short-circuit this
+            // pre-fixCleanup3_2_0 scenario with tecPRECISION_LOSS.
+            runScenario(
+                testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED);
         }
         {
             testcase(
@@ -421,6 +443,180 @@ private:
         }
     }
 
+    // A deposit does not transfer the requested amount. It transfers the
+    // request truncated to a whole number of shares and converted back, which
+    // can be strictly smaller. When that smaller value is below half a ULP at
+    // the depositor's own trust-line scale, the debit rounds away to nothing:
+    // the depositor pays nothing, while the vault books the assets and mints
+    // shares. ValidVault catches the desync at finalize time.
+    //
+    // Only a non-power-of-ten assets-to-shares ratio is needed, and that
+    // happens through ordinary use: LoanPay books accrued interest into
+    // sfAssetsTotal without minting shares.
+    //
+    // The fixCleanup3_2_0 guard in preclaim does not help, because it tests the
+    // raw requested amount, which is large enough to survive the rounding.
+    // Post-fixCleanup3_4_0 the post-truncation value is checked as well and the
+    // deposit is rejected with tecPRECISION_LOSS before anything moves.
+    void
+    testBugDepositShareTruncationSubUlp()
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        // How bob's trust line is set up before he deposits. Holding is the plain case: a large
+        // positive balance whose ULP swallows the debit. InDebt is the case where the stored
+        // balance and the spendable amount diverge: bob owes the issuer 1e16, and the issuer's
+        // limit on the same line lets him spend 1000 anyway. Reading the spendable amount there
+        // reports a small, finely scaled number, while the rounding of the debit is still governed
+        // by the 1e16 he actually holds.
+        enum class Line { Holding, InDebt };
+
+        auto runScenario = [this](FeatureBitset features, Line line, TER expected) {
+            std::string logs;
+            Env env(*this, features, std::make_unique(&logs));
+
+            Account const issuer{"issuer"};
+            Account const alice{"alice"};
+            Account const carol{"carol"};
+            Account const bob{"bob"};
+
+            env.fund(XRP(100'000), issuer, alice, carol, bob);
+            env.close();
+            env(fset(issuer, asfDefaultRipple));
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            PrettyAsset const bobUsd{bob["USD"]};
+            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
+            // Bob's balance sits exactly on a multiple-of-10 boundary at the
+            // 1e16 IOU precision cusp, where one ULP is 10.
+            STAmount const bobEdge{usd.raw(), Number{10'000'000'000'000'010LL}};
+            STAmount const bobDebt{bobUsd.raw(), Number{10'000'000'000'000'000LL}};
+            STAmount const oppositeLimit{bobUsd.raw(), Number{10'000'000'000'001'000LL}};
+
+            env(trust(alice, trustLimit));
+            env(trust(carol, trustLimit));
+            env(trust(bob, trustLimit));
+            env.close();
+
+            env(pay(issuer, alice, usd(1'000)));
+            env(pay(issuer, carol, usd(1'000)));
+            if (line == Line::Holding)
+            {
+                env(pay(issuer, bob, bobEdge));
+            }
+            else
+            {
+                // The issuer trusts bob's own USD, so bob can issue 1e16 back and still have
+                // 1000 of spendable room left on the same line.
+                env(trust(issuer, oppositeLimit));
+                env.close();
+                env(pay(bob, issuer, bobDebt));
+            }
+            env.close();
+
+            Vault const vault{env};
+            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
+            vaultTx[sfScale] = 0;
+            env(vaultTx);
+            env.close();
+
+            // Alice deposits 1000 USD, minting 1000 shares 1:1.
+            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
+            env.close();
+
+            // A loan broker on the vault, then a bullet loan at 24% interest:
+            // a single payment, one year out.
+            auto const brokerKeylet =
+                keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
+            env(set(alice, vaultKeylet.key));
+            env.close();
+
+            auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+            env(set(carol, brokerKeylet.key, usd(1'000).value()),
+                loan::kInterestRate(percentageToTenthBips(24)),
+                kGracePeriod(60),
+                kPaymentInterval(365 * 24 * 60 * 60),
+                kPaymentTotal(1),
+                Sig(sfCounterpartySignature, alice),
+                Fee(env.current()->fees().base * 2),
+                Ter(tesSUCCESS));
+            env.close();
+
+            // Advance to just before the single payment falls due and let carol
+            // repay principal plus interest. LoanPay is what books the accrued
+            // interest into sfAssetsTotal; under cash-basis accounting LoanSet
+            // alone does not. Share supply stays at 1000, so
+            // assetsTotal/sharesTotal becomes 1240/1000.
+            env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
+            env(pay(carol, loanKeylet.key, usd(2'000).value()), Ter(tesSUCCESS));
+            env.close();
+
+            // Pin the ratio the rest of the scenario reasons about, so the test cannot quietly
+            // stop exercising the bug if the setup drifts.
+            auto const sleVault = env.le(vaultKeylet);
+            BEAST_EXPECT(sleVault && sleVault->at(sfAssetsTotal) == Number{1'240});
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
+            BEAST_EXPECT(sleIssuance && sleIssuance->at(sfOutstandingAmount) == 1'000);
+
+            // Bob deposits 6 USD, which rounds to 10 at his own trust-line
+            // scale and so clears the fixCleanup3_2_0 guard. But
+            // floor(1000 * 6 / 1240) is 4 shares, worth 4 * 1240 / 1000 = 4.96,
+            // and that is below half a ULP of his balance, so it rounds away to
+            // nothing when subtracted.
+            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(6)}),
+                Ter(expected));
+            env.close();
+        };
+
+        // Strip featureLendingProtocolV1_1: this scenario runs an
+        // open-ended vault through deposit/broker/loan/repay/deposit,
+        // which spans both Subscription and post-loan lifetime — a phase
+        // pattern that only makes sense on open-ended vaults. The gate
+        // added by LP V1.1 is unrelated to the truncation bug asserted
+        // here.
+        auto const legacy = testableAmendments() - featureLendingProtocolV1_1;
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(legacy - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation lets depositor debit "
+                "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)");
+            runScenario(
+                legacy - fixCleanup3_2_0 - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(legacy, Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)");
+            runScenario(legacy - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance "
+                "round away to zero (pre-fixCleanup3_4_0)");
+            runScenario(legacy - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
+        }
+        {
+            testcase(
+                "bug: VaultDeposit share truncation against a debt balance rejected with "
+                "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
+            runScenario(legacy, Line::InDebt, tecPRECISION_LOSS);
+        }
+    }
+
     // Bug: ValidVault::visitEntry computes destinationDelta.scale as
     // max(before_exponent, after_exponent) for RippleState entries.  When a
     // withdrawal credits a destination whose IOU balance sits just below a
@@ -617,6 +813,128 @@ private:
         }
     }
 
+    // Scale 15 seed + deposit 5: pre-fix credited > paid; post-fix credited <= paid.
+    // fixCleanup3_2_0 is off so roundToVaultScale does not shrink the deposit first.
+    void
+    testBugVaultDepositOvercreditsAcrossScaleBoundary()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool expectOvercredit) {
+            Env env(*this, features);
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const depositor{"depositor"};
+            env.fund(XRP(1'000'000), owner, issuer, depositor);
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            Number const seed{9'999'999'999'999'999LL, -15};
+            Number const deposit{5};
+
+            env(trust(depositor, usd(1'000'000'000)));
+            env.close();
+            env(pay(issuer, depositor, usd(deposit)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            tx[sfScale] = 15;
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = issuer, .id = keylet.key, .amount = usd(seed)}));
+            env.close();
+
+            Number const totalBefore = env.le(keylet)->at(sfAssetsTotal);
+            Number const depositorBefore = env.balance(depositor, usd.raw()).number();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(deposit)}));
+            env.close();
+
+            Number const totalAfter = env.le(keylet)->at(sfAssetsTotal);
+            Number const depositorAfter = env.balance(depositor, usd.raw()).number();
+            Number const paid = depositorBefore - depositorAfter;
+            Number const credited = totalAfter - totalBefore;
+
+            if (expectOvercredit)
+            {
+                BEAST_EXPECTS(
+                    credited > paid,
+                    "AssetsTotal credited " + to_string(credited) + " for a payment of " +
+                        to_string(paid) + ", expected an overcredit");
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    credited <= paid,
+                    "AssetsTotal credited " + to_string(credited) + " for a payment of " +
+                        to_string(paid));
+            }
+        };
+
+        testcase(
+            "bug: VaultDeposit overcredits across an IOU scale boundary "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_2_0 - fixCleanup3_4_0, true);
+
+        testcase(
+            "bug: VaultDeposit no longer overcredits across an IOU scale boundary "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, false);
+    }
+
+    // 1e17 IOU at scale 0. Withdraw all-but-one, then the last share:
+    // pre-fix tecINVARIANT_FAILED, post-fix tesSUCCESS.
+    void
+    testBugVaultLockedByPartialWithdraw()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, TER expected) {
+            Env env(*this, features);
+            Account const owner{"owner"};
+            Account const issuer{"issuer"};
+            Account const holder{"holder"};
+            env.fund(XRP(1'000'000), owner, issuer, holder);
+            env.close();
+
+            PrettyAsset const usd{issuer["USD"]};
+            env(trust(holder, usd(Number{1, 18})));
+            env.close();
+            env(pay(issuer, holder, usd(Number{1, 17})));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
+            tx[sfScale] = 0;
+            env(tx);
+            env.close();
+            env(vault.deposit(
+                {.depositor = holder, .id = keylet.key, .amount = usd(Number{1, 17})}));
+            env.close();
+
+            MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
+            std::int64_t const allButOne = 100'000'000'000'000'000LL - 1;
+            env(vault.withdraw(
+                {.depositor = holder, .id = keylet.key, .amount = STAmount{share, allButOne}}));
+            env.close();
+
+            env(vault.withdraw(
+                    {.depositor = holder, .id = keylet.key, .amount = STAmount{share, 1}}),
+                Ter(expected));
+            env.close();
+        };
+
+        testcase(
+            "bug: VaultWithdraw permanently locks a large IOU vault "
+            "(pre-fixCleanup3_4_0)");
+        runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+        testcase(
+            "bug: VaultWithdraw no longer locks a large IOU vault "
+            "(post-fixCleanup3_4_0)");
+        runScenario(all_, tesSUCCESS);
+    }
+
     // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
     // shFULL_BALANCE), which for an IOU asset adds the counterparty's
     // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
@@ -792,6 +1110,570 @@ private:
         }
     }
 
+    // Shared setup for testBugClawbackRoundTripOvershoot and
+    // testBugWithdrawRoundTripOvershoot, which both need a vault at
+    // assetsTotal=7, sharesTotal=5 and differ only in what they do once
+    // that state is reached.
+    //
+    // The (7, 5) state is reached through ordinary transactions: a 5 USD
+    // deposit mints 5 shares 1:1, then a loan broker on the vault issues a
+    // single-payment bullet loan for the full 5 USD at 40% interest. When
+    // the borrower repays a year later, LoanPay books the 2 USD of accrued
+    // interest into sfAssetsTotal without minting shares, leaving
+    // assetsTotal=7 against sharesTotal=5 (see
+    // testBugDepositShareTruncationSubUlp for the same technique in more
+    // detail).
+    struct RoundTripOvershootVault
+    {
+        test::jtx::Account issuer;
+        test::jtx::Account holder;
+        PrettyAsset usd;
+        test::jtx::Vault vault;
+        Keylet vaultKeylet;
+        Number initialAssetsTotal;
+        Number initialAssetsAvailable;
+    };
+
+    std::optional
+    makeRoundTripOvershootVault(test::jtx::Env& env)
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const holder{"holder"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000), issuer, owner, holder, borrower);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        env.trust(usd(1'000), owner);
+        env.trust(usd(1'000), holder);
+        env.trust(usd(1'000), borrower);
+        env.close();
+
+        env(pay(issuer, holder, usd(100)));
+        env(pay(issuer, borrower, usd(100)));
+        env.close();
+
+        Vault const vault{env};
+        auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+        vaultTx[sfScale] = 0;
+        env(vaultTx);
+        env.close();
+
+        // Holder deposits 5 USD, minting 5 shares 1:1.
+        env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)}));
+        env.close();
+
+        // A loan broker on the vault, then a single bullet loan for the
+        // entire deposit at 40% interest, one payment, one year out.
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(set(owner, vaultKeylet.key));
+        env.close();
+
+        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
+        env(set(borrower, brokerKeylet.key, usd(5).value()),
+            loan::kInterestRate(percentageToTenthBips(40)),
+            kGracePeriod(60),
+            kPaymentInterval(365 * 24 * 60 * 60),
+            kPaymentTotal(1),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Advance to just before the single payment falls due and let the
+        // borrower repay principal plus interest. Share supply stays at 5,
+        // so assetsTotal/sharesTotal becomes 7/5.
+        env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
+        env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return std::nullopt;
+        auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
+
+        Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal);
+        Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable);
+        BEAST_EXPECT(initialAssetsTotal == usd(7).number());
+        BEAST_EXPECT(initialAssetsAvailable == usd(7).number());
+        {
+            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID));
+            if (!BEAST_EXPECT(sleIssuance))
+                return std::nullopt;
+            BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5);
+        }
+
+        return RoundTripOvershootVault{
+            .issuer = issuer,
+            .holder = holder,
+            .usd = usd,
+            .vault = vault,
+            .vaultKeylet = vaultKeylet,
+            .initialAssetsTotal = initialAssetsTotal,
+            .initialAssetsAvailable = initialAssetsAvailable};
+    }
+
+    // VaultClawback::assetsToClawback converts clawbackAmount to shares
+    // with round-to-nearest, then round-trips back to assets. When shares
+    // round up, assetsRecovered can exceed clawbackAmount.
+    //
+    // Repro: assetsTotal=7, sharesTotal=5, request 4:
+    //   shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
+    //
+    // Post-fixCleanup3_4_0: truncate shares so assetsRecovered <=
+    // clawbackAmount by construction.
+    void
+    testBugClawbackRoundTripOvershoot()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool withFix) {
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then claw back shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
+
+            auto const setup = makeRoundTripOvershootVault(env);
+            if (!BEAST_EXPECT(setup))
+                return;
+
+            auto const clawbackAmount = setup->usd(4);
+            env(setup->vault.clawback(
+                {.issuer = setup->issuer,
+                 .id = setup->vaultKeylet.key,
+                 .holder = setup->holder,
+                 .amount = clawbackAmount.value()}));
+
+            auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
+            if (!BEAST_EXPECT(vaultSleAfter))
+                return;
+            Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
+            Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal;
+            Number const clawbackNum = clawbackAmount.number();
+
+            Number const expectedPost{28LL, -1};
+            Number const expectedPre{42LL, -1};
+            if (withFix)
+            {
+                BEAST_EXPECT(assetsRecovered <= clawbackNum);
+                BEAST_EXPECT(assetsRecovered == expectedPost);
+            }
+            else
+            {
+                BEAST_EXPECT(assetsRecovered > clawbackNum);
+                BEAST_EXPECT(assetsRecovered == expectedPre);
+            }
+        };
+
+        {
+            testcase(
+                "bug: VaultClawback round-trip overshoot lets issuer recover "
+                "more than requested (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, false);
+        }
+        {
+            testcase(
+                "bug: VaultClawback round-trip overshoot is clamped so "
+                "assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), true);
+        }
+    }
+
+    // Same root cause as testBugClawbackRoundTripOvershoot on the
+    // withdraw path. Also bypasses the preclaim canWithdraw check, which
+    // validates destination limits against the requested amount only.
+    //
+    // Repro: assetsTotal=7, sharesTotal=5, request 4:
+    //   pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
+    //   post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4.
+    void
+    testBugWithdrawRoundTripOvershoot()
+    {
+        using namespace test::jtx;
+
+        auto runScenario = [this](FeatureBitset features, bool withFix) {
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then withdraw shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
+
+            auto const setup = makeRoundTripOvershootVault(env);
+            if (!BEAST_EXPECT(setup))
+                return;
+
+            auto const requested = setup->usd(4);
+            env(setup->vault.withdraw(
+                {.depositor = setup->holder,
+                 .id = setup->vaultKeylet.key,
+                 .amount = requested.value()}));
+
+            auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
+            if (!BEAST_EXPECT(vaultSleAfter))
+                return;
+            Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
+            Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal;
+            Number const requestedNum = requested.number();
+
+            Number const expectedPost{28LL, -1};
+            Number const expectedPre{42LL, -1};
+            if (withFix)
+            {
+                BEAST_EXPECT(assetsWithdrawn <= requestedNum);
+                BEAST_EXPECT(assetsWithdrawn == expectedPost);
+            }
+            else
+            {
+                BEAST_EXPECT(assetsWithdrawn > requestedNum);
+                BEAST_EXPECT(assetsWithdrawn == expectedPre);
+            }
+        };
+
+        {
+            testcase(
+                "bug: VaultWithdraw round-trip overshoot delivers more than "
+                "requested (pre-fixCleanup3_4_0)");
+            runScenario(testableAmendments() - fixCleanup3_4_0, false);
+        }
+        {
+            testcase(
+                "bug: VaultWithdraw round-trip overshoot is clamped so "
+                "assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
+            runScenario(testableAmendments(), true);
+        }
+    }
+
+    void
+    testCredentialPinsPseudoAccount()
+    {
+        using namespace test::jtx;
+
+        // A credential issued to a vault pseudo-account can't be accepted or
+        // deleted by it (pseudo-accounts can't sign), so it stays pinned in the
+        // pseudo-account's owner directory and blocks VaultDelete with
+        // tecHAS_OBLIGATIONS. A pin created before the cure activates is removed
+        // by VaultDelete once it does.
+        Account const owner{"owner"};
+        Account const attacker{"attacker"};
+        char const credType[] = "FN36";
+
+        Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0};
+        env.fund(XRP(1'000'000), owner, attacker);
+        env.close();
+
+        Vault const vault{env};
+        PrettyAsset const asset = xrpIssue();
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        auto const vaultSle = env.le(keylet);
+        BEAST_EXPECT(vaultSle);
+        Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+        env.memoize(pseudo);
+
+        // The pseudo-account owns the share issuance; the pin must not change
+        // its owner count (an unaccepted credential is owned by the issuer).
+        auto const pseudoOwnerCount = ownerCount(env, pseudo);
+
+        testcase("Credential pins vault pseudo-account");
+        env(credentials::create(pseudo, attacker, credType));
+        env.close();
+
+        auto const credKey = credentials::keylet(pseudo, attacker, credType);
+        BEAST_EXPECT(env.le(credKey));
+        BEAST_EXPECT(ownerCount(env, attacker) == 1);
+        BEAST_EXPECT(ownerCount(env, pseudo) == pseudoOwnerCount);
+
+        // The pin blocks deletion of an otherwise-empty vault.
+        env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecHAS_OBLIGATIONS));
+        env.close();
+
+        env.enableFeature(fixCleanup3_4_0);
+        env.close();
+
+        // The pre-existing pin no longer blocks deletion; the credential is
+        // cleaned up and the issuer's owner count is restored.
+        testcase("VaultDelete removes pinned credential");
+        env(vault.del({.owner = owner, .id = keylet.key}));
+        env.close();
+
+        BEAST_EXPECT(!env.le(credKey));
+        BEAST_EXPECT(!env.le(keylet));
+        BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id())));
+        BEAST_EXPECT(ownerCount(env, attacker) == 0);
+    }
+
+    void
+    testCredentialPinOverflow()
+    {
+        using namespace test::jtx;
+        testcase("Credential pin cleanup is bounded (tecINCOMPLETE)");
+
+        // A pseudo-account can be pinned with more credentials than one
+        // transaction is allowed to clean up. VaultDelete then removes them a
+        // bounded batch at a time, returning tecINCOMPLETE until the last batch.
+        Account const owner{"owner"};
+        Account const attacker{"attacker"};
+
+        Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0};
+        env.fund(XRP(10'000'000), owner, attacker);
+        env.close();
+
+        Vault const vault{env};
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+        env(tx);
+        env.close();
+        auto const vaultSle = env.le(keylet);
+        BEAST_EXPECT(vaultSle);
+        Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+        env.memoize(pseudo);
+
+        // Pin more than one cleanup batch's worth of credentials.
+        std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3;
+        for (std::uint16_t i = 0; i < count; ++i)
+            env(credentials::create(pseudo, attacker, std::to_string(i)));
+        env.close();
+        BEAST_EXPECT(ownerCount(env, attacker) == count);
+
+        env.enableFeature(fixCleanup3_4_0);
+        env.close();
+
+        // First delete removes one bounded batch and reports it isn't finished.
+        env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecINCOMPLETE));
+        env.close();
+        BEAST_EXPECT(env.le(keylet));  // vault still exists
+        auto const remaining = ownerCount(env, attacker);
+        BEAST_EXPECT(remaining > 0 && remaining < count);
+
+        // Second delete finishes the cleanup and removes the vault.
+        env(vault.del({.owner = owner, .id = keylet.key}));
+        env.close();
+        BEAST_EXPECT(!env.le(keylet));
+        BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id())));
+        BEAST_EXPECT(ownerCount(env, attacker) == 0);
+    }
+
+    struct ImpairedLoanVault
+    {
+        test::jtx::Account issuer;
+        test::jtx::Account holder;
+        PrettyAsset usd;
+        test::jtx::Vault vault;
+        Keylet vaultKeylet;
+        MPTID shareId;
+    };
+
+    // Impairing a 1,000 loan in a 10,000 vault leaves AssetsAvailable=9,000
+    // and AssetsTotal=10,000. otherDeposit > 0 splits the shares, 0 leaves
+    // holder as the sole shareholder.
+    std::optional
+    makeImpairedLoanVault(test::jtx::Env& env, int otherDeposit)
+    {
+        using namespace test::jtx;
+        using namespace loan_broker;
+        using namespace loan;
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const holder{"holder"};
+        Account const other{"other"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000), issuer, owner, holder, other, borrower);
+        env.close();
+
+        env(fset(issuer, asfAllowTrustLineClawback));
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const usd = issuer["USD"];
+        env.trust(usd(100'000), owner);
+        env.trust(usd(100'000), holder);
+        env.trust(usd(100'000), other);
+        env.trust(usd(100'000), borrower);
+        env.close();
+
+        int const holderDeposit = 10'000 - otherDeposit;
+        env(pay(issuer, holder, usd(holderDeposit)));
+        if (otherDeposit != 0)
+        {
+            env(pay(issuer, other, usd(otherDeposit)));
+        }
+        env.close();
+
+        Vault const vault{env};
+        auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
+        env(createTx);
+        env.close();
+
+        auto const vaultSle = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultSle))
+            return std::nullopt;
+        MPTID const shareId = vaultSle->at(sfShareMPTID);
+
+        env(vault.deposit(
+            {.depositor = holder, .id = vaultKeylet.key, .amount = usd(holderDeposit)}));
+        if (otherDeposit != 0)
+        {
+            env(vault.deposit(
+                {.depositor = other, .id = vaultKeylet.key, .amount = usd(otherDeposit)}));
+        }
+        env.close();
+
+        vault.closePastSubscription(subscriptionDate);
+
+        auto const brokerKeylet =
+            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+        env(set(owner, vaultKeylet.key));
+        env.close();
+
+        auto const sleBroker = env.le(brokerKeylet);
+        if (!BEAST_EXPECT(sleBroker))
+            return std::nullopt;
+        auto const loanKeylet =
+            keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, brokerKeylet.key, usd(1'000).value()),
+            loan::kInterestRate(percentageToTenthBips(0)),
+            kGracePeriod(60),
+            kPaymentInterval(120),
+            kPaymentTotal(10),
+            Sig(sfCounterpartySignature, owner),
+            Fee(env.current()->fees().base * 2),
+            Ter(tesSUCCESS));
+        env.close();
+
+        // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with
+        // tecTOO_SOON unless the payment is already late; advance the ledger
+        // past sfNextPaymentDueDate so impairment succeeds. No-op otherwise.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            auto const loanBefore = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanBefore))
+                return std::nullopt;
+            std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        auto const vaultAfter = env.le(vaultKeylet);
+        if (!BEAST_EXPECT(vaultAfter))
+            return std::nullopt;
+        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == usd(9'000).value());
+        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == usd(1'000).value());
+
+        return ImpairedLoanVault{
+            .issuer = issuer,
+            .holder = holder,
+            .usd = usd,
+            .vault = vault,
+            .vaultKeylet = vaultKeylet,
+            .shareId = shareId};
+    }
+
+    // Legacy clawback pricing burns every share; fixCleanup3_4_0 leaves 10%
+    // outstanding, backed by the impaired receivable.
+    void
+    testBugClawbackAfterLoanImpair()
+    {
+        using namespace test::jtx;
+
+        auto clawbackHolder = [](ImpairedLoanVault const& setup, STAmount const& amount) {
+            return setup.vault.clawback(
+                {.issuer = setup.issuer,
+                 .id = setup.vaultKeylet.key,
+                 .holder = setup.holder,
+                 .amount = amount});
+        };
+
+        auto runSole = [this, &clawbackHolder](FeatureBitset features, TER expected) {
+            testcase(
+                features[fixCleanup3_4_0]
+                    ? "VaultClawback after impaired loan (post-fixCleanup3_4_0)"
+                    : "VaultClawback after impaired loan (pre-fixCleanup3_4_0)");
+
+            Env env(*this, features);
+            auto const maybeSetup = makeImpairedLoanVault(env, 0);
+            if (!maybeSetup)
+            {
+                BEAST_EXPECT(false);
+                return;
+            }
+            ImpairedLoanVault const& setup = *maybeSetup;
+
+            auto const tokenBefore = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
+            auto const vaultBefore = env.le(setup.vaultKeylet);
+            auto const issuanceBefore = env.le(keylet::mptokenIssuance(setup.shareId));
+            if (!BEAST_EXPECT(tokenBefore) || !BEAST_EXPECT(vaultBefore) ||
+                !BEAST_EXPECT(issuanceBefore))
+                return;
+            std::uint64_t const sharesBefore = tokenBefore->getFieldU64(sfMPTAmount);
+
+            // The clawback of 19,000 exceeds AssetsAvailable (9,000), so
+            // VaultClawback clamps sharesDestroyed to whatever redeems
+            // exactly AssetsAvailable; compute that expected value using the
+            // same conversion helper VaultClawback itself uses, rather than
+            // assuming an exact 90/10 split holds under truncation.
+            auto const maybeSharesDestroyed = assetsToSharesWithdraw(
+                vaultBefore,
+                issuanceBefore,
+                setup.usd(9'000).value(),
+                TruncateShares::Yes,
+                WaiveUnrealizedLoss::Yes);
+            if (!BEAST_EXPECT(maybeSharesDestroyed))
+                return;
+            std::uint64_t const expectedSharesAfter =
+                sharesBefore - maybeSharesDestroyed->mpt().value();
+
+            env(clawbackHolder(setup, setup.usd(19'000).value()), Ter(expected));
+            env.close();
+            if (expected != tesSUCCESS)
+                return;
+
+            auto const vaultAfter = env.le(setup.vaultKeylet);
+            if (!BEAST_EXPECT(vaultAfter))
+                return;
+            BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == setup.usd(0).value());
+            BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == setup.usd(1'000).value());
+            BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == setup.usd(1'000).value());
+            auto const tokenAfter = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
+            if (!BEAST_EXPECT(tokenAfter))
+                return;
+            BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter);
+        };
+
+        runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
+        runSole(all_, tesSUCCESS);
+
+        testcase("VaultClawback after impaired loan, non-sole holder");
+        {
+            Env env(*this, all_);
+            auto const maybeSetup = makeImpairedLoanVault(env, 1'000);
+            if (!maybeSetup)
+            {
+                BEAST_EXPECT(false);
+                return;
+            }
+            ImpairedLoanVault const& setup = *maybeSetup;
+            // The waiver does not apply, so the holder's 9,000 shares are
+            // still priced at the discounted rate and cannot cover 9,000.
+            env(clawbackHolder(setup, setup.usd(9'000).value()), Ter(tecINSUFFICIENT_FUNDS));
+        }
+    }
+
 public:
     void
     run() override
@@ -801,10 +1683,18 @@ public:
         testBugMakeDeltaPosteriorScale();
         testBugMakeDeltaAnteriorScale();
         testVaultDepositCanonicalizeToZero();
+        testBugDepositShareTruncationSubUlp();
         testVaultWithdrawCanonicalizeToZero();
         testBugVaultDustDebitCanonicalizesToNoOp();
+        testBugVaultDepositOvercreditsAcrossScaleBoundary();
+        testBugVaultLockedByPartialWithdraw();
         testVaultDepositNegativeBalanceFromOppositeLimit();
+        testCredentialPinsPseudoAccount();
+        testCredentialPinOverflow();
         testBug6LimitBypassWithShares();
+        testBugClawbackRoundTripOvershoot();
+        testBugWithdrawRoundTripOvershoot();
+        testBugClawbackAfterLoanImpair();
     }
 };
 
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
index 2a9fe42b1c..0290b67047 100644
--- a/src/test/app/vault/VaultClawback_test.cpp
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -68,12 +68,20 @@ private:
             return sleIssuance->at(sfOutstandingAmount);
         };
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build vaults in this suite as
+        // closed-ended and advance past SubscriptionDate before creating
+        // brokers/loans. VaultClawback itself is not phase-gated. The
+        // subscription offset must be large enough that the deposit
+        // ledger close does not accidentally push us past SubscriptionDate
+        // (which would land the deposit in Investment phase and fail).
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -87,6 +95,10 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            // Move past SubscriptionDate so LoanBrokerSet/LoanSet run in
+            // the Investment phase.
+            vault.closePastSubscription(subscriptionDate);
+
             auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
             BEAST_EXPECT(availablePreDefault == totalPreDefault);
             BEAST_EXPECT(availablePreDefault == asset(100).value());
@@ -313,13 +325,21 @@ private:
         Env env(*this);
         env.enableFeature(fixCleanup3_1_3);
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults; some tests using this helper later
+        // attach loan brokers to the vault. Build it as closed-ended and
+        // advance past SubscriptionDate so subsequent broker/loan setup
+        // runs in the Investment phase. VaultClawback itself is not
+        // phase-gated. See the other setupVault (share tests) for why the
+        // subscription offset must be generous.
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor,
                                     Account const& issuer) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -331,6 +351,8 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             return std::make_pair(vault, vaultKeylet);
         };
 
@@ -1107,12 +1129,95 @@ private:
         }
     }
 
+    // The vault's pseudo-account issues the shares, so it never holds any, and naming it as Holder
+    // asks for a clawback that cannot move anything. Before the rule an implicit amount resolved to
+    // zero shares and ended in tecPRECISION_LOSS, while an explicit one debited the vault first and
+    // was caught by the invariant that shares must move.
+    void
+    testClawbackPseudoAccountHolder()
+    {
+        using namespace test::jtx;
+
+        auto const runScenario = [this](FeatureBitset features, std::string const& prefix) {
+            bool const guarded = features[fixCleanup3_4_0];
+            Env env{*this, features};
+
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+
+            env.fund(XRP(1'000), owner, depositor, issuer);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000), owner);
+            env.trust(asset(1'000), depositor);
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const vaultSle = env.le(keylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+            env.memoize(pseudo);
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto const assetsBefore = [&]() -> Number {
+                auto const sle = env.le(keylet);
+                if (!BEAST_EXPECT(sle))
+                    return Number{};
+                return sle->at(sfAssetsTotal);
+            }();
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, implicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecPRECISION_LOSS}));
+                env.close();
+            }
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, explicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                        .amount = asset(10).value(),
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecINVARIANT_FAILED}));
+                env.close();
+            }
+
+            // Neither attempt may touch the vault, whichever way it was refused.
+            auto const sleAfter = env.le(keylet);
+            BEAST_EXPECT(sleAfter && sleAfter->at(sfAssetsTotal) == assetsBefore);
+        };
+
+        runScenario(all_, "post-rule");
+        runScenario(all_ - fixCleanup3_4_0, "pre-rule");
+    }
+
 public:
     void
     run() override
     {
         testVaultClawbackBurnShares();
         testVaultClawbackAssets();
+        testClawbackPseudoAccountHolder();
         testVaultEscrowedMPT();
     }
 };
diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp
index db8943921b..5e058a13a8 100644
--- a/src/test/app/vault/VaultDomain_test.cpp
+++ b/src/test/app/vault/VaultDomain_test.cpp
@@ -4,6 +4,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -18,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -570,6 +572,301 @@ private:
         }
     }
 
+    // Withdrawing out of a private vault to a third party requires both the
+    // submitter and the destination to be members of the vault's permissioned
+    // domain. Withdrawal to self is exempt: revoking vault access must not
+    // trap already deposited funds. The asset issuer is exempt as a
+    // destination, so that frozen assets can always be returned.
+    void
+    testVaultWithdrawPrivateDestinationDomain(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_4_0];
+        testcase(
+            std::string{"VaultWithdraw private vault destination domain check"} +
+            (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const beneficiary{"beneficiary"};
+        Account const outsider{"outsider"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+
+        Env env{*this, features};
+        Vault const vault{env};
+
+        env.fund(
+            XRP(100'000), issuer, owner, depositor, beneficiary, outsider, pdOwner, credIssuer);
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        // Everyone holds Layer 1 (asset) permission, so anything blocked below
+        // is blocked by the Layer 2 (vault) check alone.
+        for (auto const& account : {owner, depositor, beneficiary, outsider})
+        {
+            env.trust(asset(1'000'000), account);
+            env(pay(issuer, account, asset(10'000)));
+        }
+        env.close();
+
+        auto const domainId = [&]() {
+            pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
+            env(pdomain::setTx(pdOwner, credentials));
+            env.close();
+            return pdomain::getNewDomain(env.meta());
+        }();
+
+        auto const joinDomain = [&](Account const& account) {
+            env(credentials::create(account, credIssuer, credType));
+            env(credentials::accept(account, credIssuer, credType));
+            env.close();
+        };
+        joinDomain(depositor);
+        joinDomain(beneficiary);
+
+        auto [createTx, keylet] =
+            vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+        env(createTx);
+        env.close();
+
+        {
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = to_string(domainId);
+            env(tx);
+            env.close();
+        }
+
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+        env.close();
+
+        auto const withdrawTo = [&, keylet = keylet](Account const& destination) {
+            auto tx =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
+            tx[sfDestination] = destination.human();
+            return tx;
+        };
+
+        {
+            // Destination holds both layers of permission.
+            env(withdrawTo(beneficiary));
+            env.close();
+        }
+
+        {
+            // Destination may hold the asset but was never let into the vault.
+            env(withdrawTo(outsider), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+        }
+
+        {
+            // The asset issuer can always receive, to keep the recovery path
+            // for frozen assets open.
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            // The vault owner gets no special treatment as a destination: it
+            // is a third party like any other and needs domain membership.
+            env(withdrawTo(owner), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+        }
+
+        {
+            // Withdrawal to self needs no Destination and stays unaffected.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+        }
+
+        {
+            // Naming yourself as the Destination is still a withdrawal to self.
+            env(withdrawTo(depositor));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw private vault submitter lost vault access"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
+            env.close();
+
+            // The exit of last resort: the submitter lost vault access but
+            // must still be able to redeem its own shares.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+
+            // Moving funds to anyone else is not allowed any more, even to a
+            // destination that is itself a domain member.
+            env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+
+            // Returning assets to the issuer stays open regardless.
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw private vault with no domain set"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            // Give the submitter its vault access back first, so that the
+            // vault having no domain is the only reason left to refuse.
+            env(credentials::create(depositor, credIssuer, credType));
+            env(credentials::accept(depositor, credIssuer, credType));
+            env.close();
+
+            auto tx = vault.set({.owner = owner, .id = keylet.key});
+            tx[sfDomainID] = "0";
+            env(tx);
+            env.close();
+
+            // Clearing the domain leaves the vault with nobody it considers
+            // authorized, so a third-party destination cannot qualify even
+            // though both ends of the payout hold a credential.
+            env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS)));
+            env.close();
+
+            // The two exempt paths survive the domain going away.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+
+            env(withdrawTo(issuer));
+            env.close();
+        }
+
+        {
+            testcase(
+                std::string{"VaultWithdraw public vault destination unaffected"} +
+                (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+            auto [publicTx, publicKeylet] = vault.create({.owner = owner, .asset = asset});
+            env(publicTx);
+            env.close();
+
+            env(vault.deposit({.depositor = owner, .id = publicKeylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto tx =
+                vault.withdraw({.depositor = owner, .id = publicKeylet.key, .amount = asset(1)});
+            tx[sfDestination] = outsider.human();
+            env(tx);
+            env.close();
+        }
+    }
+
+    void
+    testWithdrawCredentialDepositPreauth(FeatureBitset features)
+    {
+        testcase(
+            "withdraw with credential-based deposit preauth " +
+            std::string{features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"});
+        using namespace test::jtx;
+        using namespace std::chrono_literals;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+
+        Env env{*this, features};
+
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const dest{"dest"};
+        Account const credIssuer{"credIssuer"};
+        char const credType[] = "abcde";
+
+        env.fund(XRP(1000), owner, depositor, dest, credIssuer);
+        env(fset(dest, asfDepositAuth));
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1'000'000};
+        Vault vault{env};
+        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+        env(tx);
+        env.close();
+
+        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+        env.close();
+
+        auto withdrawToDest = [&]() {
+            auto wtx =
+                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
+            wtx[sfDestination] = dest.human();
+            return wtx;
+        };
+
+        // Without any preauth, withdraw to dest fails
+        env(withdrawToDest(), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Issue and accept a credential for the depositor (with expiration)
+        auto jv = credentials::create(depositor, credIssuer, credType);
+        std::uint32_t const expiration =
+            env.current()->header().parentCloseTime.time_since_epoch().count() + 100;
+        jv[sfExpiration.jsonName] = expiration;
+        env(jv);
+        env(credentials::accept(depositor, credIssuer, credType));
+        env.close();
+
+        auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
+        auto const credIdx =
+            credentials::ledgerEntry(env, depositor, credIssuer, credType)[jss::result][jss::index]
+                .asString();
+
+        // dest authorizes deposits from holders of credentials issued by credIssuer
+        env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}}));
+        env.close();
+
+        // Withdraw without supplying credentials still fails
+        env(withdrawToDest(), Ter{tecNO_PERMISSION});
+        env.close();
+
+        if (!fixEnabled)
+        {
+            // Pre-fix: sfCredentialIDs in VaultWithdraw is rejected as disabled
+            env(withdrawToDest(), credentials::Ids({credIdx}), Ter{temDISABLED});
+            env.close();
+            return;
+        }
+
+        // Withdraw with credentials succeeds
+        env(withdrawToDest(), credentials::Ids({credIdx}));
+        env.close();
+
+        // Bad credential id is rejected
+        std::string const invalidIdx =
+            "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034";
+        env(withdrawToDest(), credentials::Ids({invalidIdx}), Ter{tecBAD_CREDENTIALS});
+        env.close();
+
+        // Malformed credential array (duplicates) is rejected by checkFields
+        env(withdrawToDest(), credentials::Ids({credIdx, credIdx}), Ter{temMALFORMED});
+        env.close();
+
+        // Valid credential not authorized by dest hits authorizedDepositPreauth error path
+        char const credType2[] = "fghij";
+        env(credentials::create(depositor, credIssuer, credType2));
+        env(credentials::accept(depositor, credIssuer, credType2));
+        env.close();
+        auto const credIdx2 =
+            credentials::ledgerEntry(env, depositor, credIssuer, credType2)[jss::result][jss::index]
+                .asString();
+        env(withdrawToDest(), credentials::Ids({credIdx2}), Ter{tecNO_PERMISSION});
+        env.close();
+
+        // Advance time past expiration: credentials yield tecEXPIRED and are deleted
+        env.close(150s);
+        BEAST_EXPECT(env.le(credKeylet));
+        env(withdrawToDest(), credentials::Ids({credIdx}), Ter{tecEXPIRED});
+        env.close();
+        BEAST_EXPECT(!env.le(credKeylet));
+    }
+
 public:
     void
     run() override
@@ -578,6 +875,10 @@ public:
         testDomainLossAfterAcquisition();
         testDomainCheckBuyerSideOffer();
         testWithDomainChecXRP();
+        testVaultWithdrawPrivateDestinationDomain(all_ - fixCleanup3_4_0);
+        testVaultWithdrawPrivateDestinationDomain(all_);
+        testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0);
+        testWithdrawCredentialDepositPreauth(all_);
     }
 };
 
diff --git a/src/test/app/vault/VaultHelpers_test.cpp b/src/test/app/vault/VaultHelpers_test.cpp
new file mode 100644
index 0000000000..d52b732a60
--- /dev/null
+++ b/src/test/app/vault/VaultHelpers_test.cpp
@@ -0,0 +1,484 @@
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include   // IWYU pragma: keep
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl {
+
+// True unit test of `clampToAssetsTotalScale`. The function under test only
+// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a
+// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is
+// no jtx::Env and no transaction submitted anywhere in this file.
+//
+// Number regime: this suite relies on the default thread_local Number
+// mantissa range, which src/libxrpl/basics/Number.cpp initializes to
+// Large330 (19-digit mantissa, post-fixCleanup3_3_0 cusp-rounding behavior):
+//
+//   thread_local std::reference_wrapper Number::kRange =
+//       MantissaRange::Access::mantissaRange(MantissaRange::MantissaScale::Large330);
+//
+// Unlike transaction processing, this test never constructs a ledger `Rules`
+// object, so `STAmount::operator=(Number const&)` always takes its
+// `!getCurrentTransactionRules()` branch and calls `fromNumber`, independent
+// of amendment state. testProbeLarge330Regime() below asserts directly on a
+// value that only round-trips exactly under Large330, pinning the regime
+// rather than merely asserting it by comment.
+class VaultHelpers_test : public beast::unit_test::Suite
+{
+private:
+    // A single row of the clampToAssetsTotalScale table. `assetsTotal` and
+    // `delta` must already be genuine, on-grid STAmount values for `asset`.
+    struct Case
+    {
+        char const* name = nullptr;
+        Number assetsTotal;
+        Number delta;
+        std::optional expected;  // nullopt means tecPRECISION_LOSS
+    };
+
+    // Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set,
+    // mirroring what a transactor does: set the STNumber field, then call
+    // associateAsset() so it is quantized to the asset's STAmount grid, the
+    // same way VaultDeposit::doApply does for a real vault (see
+    // src/libxrpl/tx/transactors/vault/VaultDeposit.cpp).
+    static std::shared_ptr
+    makeVault(Asset const& asset, Number const& assetsTotal)
+    {
+        auto vault = std::make_shared(keylet::vault(uint256(1)));
+        vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
+        vault->at(sfAssetsTotal) = assetsTotal;
+        associateAsset(*vault, asset);
+        return vault;
+    }
+
+    // Runs every case in `cases` against `asset`, once per ambient rounding
+    // mode. The function must give the same answer under all four modes,
+    // and its answer must match the hand-derived `expected` value.
+    template 
+    void
+    runCases(Asset const& asset, std::array const& cases)
+    {
+        std::array const modes{
+            Number::RoundingMode::ToNearest,
+            Number::RoundingMode::Downward,
+            Number::RoundingMode::Upward,
+            Number::RoundingMode::TowardsZero};
+
+        for (auto const& c : cases)
+        {
+            testcase(c.name);
+
+            auto const vault = makeVault(asset, c.assetsTotal);
+            BEAST_EXPECTS(
+                Number(vault->at(sfAssetsTotal)) == c.assetsTotal,
+                std::string(c.name) +
+                    ": assetsTotal is not a genuine on-grid STAmount value (associateAsset "
+                    "changed it)");
+
+            STAmount const delta{asset, c.delta};
+            BEAST_EXPECTS(
+                Number(delta) == c.delta,
+                std::string(c.name) + ": delta is not a genuine on-grid STAmount value");
+
+            std::optional> reference;
+            for (auto const mode : modes)
+            {
+                NumberRoundModeGuard const rg(mode);
+                auto const result = clampToAssetsTotalScale(vault, delta);
+
+                // The function must be insensitive to the caller's ambient
+                // rounding mode: every mode must agree with the first one
+                // tried.
+                if (!reference)
+                {
+                    reference = result;
+                }
+                else
+                {
+                    BEAST_EXPECTS(
+                        result.has_value() == reference->has_value(),
+                        std::string(c.name) + ": result depends on ambient rounding mode");
+                    if (result.has_value() && reference->has_value())
+                    {
+                        BEAST_EXPECTS(
+                            *result == **reference,
+                            std::string(c.name) + ": value depends on ambient rounding mode");
+                    }
+                    else if (!result.has_value() && !reference->has_value())
+                    {
+                        BEAST_EXPECTS(
+                            result.error() == reference->error(),
+                            std::string(c.name) + ": error depends on ambient rounding mode");
+                    }
+                }
+
+                if (!c.expected)
+                {
+                    BEAST_EXPECTS(
+                        !result.has_value(),
+                        std::string(c.name) + ": expected tecPRECISION_LOSS, got success value " +
+                            (result.has_value() ? result->getText() : std::string()));
+                    if (!result.has_value())
+                    {
+                        BEAST_EXPECTS(
+                            result.error() == tecPRECISION_LOSS,
+                            std::string(c.name) + ": expected tecPRECISION_LOSS, got " +
+                                transToken(result.error()));
+                    }
+                    continue;
+                }
+
+                STAmount const expected{asset, *c.expected};
+                if (!BEAST_EXPECTS(
+                        result.has_value(),
+                        std::string(c.name) + ": expected success (" + expected.getText() +
+                            "), got " + transToken(result.error())))
+                {
+                    continue;
+                }
+
+                BEAST_EXPECTS(
+                    *result == expected,
+                    std::string(c.name) + ": expected " + expected.getText() + ", got " +
+                        result->getText());
+
+                // The result must always be positive...
+                BEAST_EXPECT(Number(*result) > Number{0});
+
+                // ...and never larger in magnitude than the requested delta.
+                BEAST_EXPECT(abs(Number(*result)) <= abs(c.delta));
+
+                // For IOU rows, re-flooring the result on the posterior grid
+                // must be a no-op: the result is already exactly
+                // representable at that scale.
+                //
+                // For debits this holds directly at postScale, because the
+                // result IS `roundToScale(magnitude, postScale, Downward)` by
+                // construction. For credits the result is
+                // `roundedPosterior - assetsTotal`, where roundedPosterior
+                // sits exactly on the postScale grid but assetsTotal sits on
+                // its own (possibly finer) natural grid; the difference of a
+                // multiple of 10^postScale and a multiple of 10^assetsScale
+                // is only guaranteed exact at the FINER of the two scales.
+                // Row 7 below ("overcredit fix across a scale boundary") is
+                // exactly this case: assetsTotal's own scale (-15) is finer
+                // than postScale (-14), so checking exactness at postScale
+                // alone fails even though the implementation is correct.
+                if (!asset.integral())
+                {
+                    bool const isDebit = c.delta.mantissa() < 0;
+                    Number const posterior =
+                        isDebit ? c.assetsTotal - Number(*result) : c.assetsTotal + Number(*result);
+                    int const postScale = scale(posterior, asset);
+                    int const checkScale =
+                        isDebit ? postScale : std::min(postScale, scale(c.assetsTotal, asset));
+                    STAmount const reFloored =
+                        roundToScale(*result, checkScale, Number::RoundingMode::Downward);
+                    BEAST_EXPECTS(
+                        reFloored == *result,
+                        std::string(c.name) + ": result " + result->getText() +
+                            " is not exact on the posterior grid (scale " +
+                            std::to_string(checkScale) + ")");
+                }
+            }
+        }
+    }
+
+    // Pins the Number mantissa regime this suite relies on. Under Large330,
+    // a 19-digit mantissa (max 10^19-1) is exact where a legacy 16-digit
+    // ("Small", max 10^16-1) regime would have to round it down to 16
+    // significant digits, changing both mantissa and exponent.
+    void
+    testProbeLarge330Regime()
+    {
+        testcase("probe: default Number regime is Large330 (19-digit mantissa)");
+
+        BEAST_EXPECT(Number::getMantissaScale() == MantissaRange::MantissaScale::Large330);
+
+        // std::numeric_limits::max(), 19 significant digits.
+        // This is already inside Large330's [10^18, 10^19-1] range, so
+        // constructing it is a no-op; under "Small" it would have to lose
+        // its low 3 digits.
+        Number const probe{9'223'372'036'854'775'807LL, 0};
+        BEAST_EXPECT(probe.mantissa() == 9'223'372'036'854'775'807LL);
+        BEAST_EXPECT(probe.exponent() == 0);
+    }
+
+    // -------------------------------------------------------------------
+    // IOU debits (delta negative).
+    // -------------------------------------------------------------------
+    void
+    testIouDebits(Asset const& iou)
+    {
+        std::array const cases{
+            Case{
+                // T = 1000000.000000005, delta = -1e-9.
+                // Posterior = 1000000.000000004, still 16 significant
+                // digits at exponent -9 (no rounding, no decade change).
+                // postScale = -9. magnitude 1e-9 has its own exponent -24
+                // (finer than -9), so it must be actually floored: 1e-9 is
+                // exactly 1 ULP at scale -9, so flooring is a no-op.
+                .name = "IOU debit: on-grid, same decade",
+                .assetsTotal = Number{1'000'000'000'000'005LL, -9},
+                .delta = Number{-1, -9},
+                .expected = Number{1, -9},
+            },
+            Case{
+                // T = 1000000, delta = -7.3e-10.
+                // Posterior = 999999.99999999927 exactly (17 significant
+                // digits: 15 nines, then "27"). Rounding to 16 digits
+                // (ToNearest) rounds the trailing "...92.7" up to
+                // "...93", giving mantissa 9999999999999993 at exponent
+                // -10 -- postScale = -10, ONE DIGIT FINER than the naive
+                // "posterior stays in T's decade at -9" guess, because
+                // subtracting anything positive from an exact power-of-ten
+                // total necessarily drops into the next lower decade
+                // (1000000 has 7 integer digits, 999999.x has 6).
+                // At scale -10 the ULP is 1e-10, and floor(7.3) = 7, so
+                // the debit is NOT sub-ULP: it floors to 7e-10, not to
+                // zero. See discrepancy note in the report.
+                .name = "IOU debit: sub-ULP at the naive scale, but not at the true postScale",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-73, -11},
+                .expected = Number{7, -10},
+            },
+            Case{
+                // T = 1000000, delta = -5.3e-9.
+                // Posterior = 999999.9999999947 exactly -- this needs only
+                // 16 significant digits (14 nines, then "47"), so it is
+                // exactly representable with NO rounding at exponent -10.
+                // postScale = -10 (again one digit finer than T's own -9,
+                // for the same power-of-ten-boundary reason as the row
+                // above). At that grid 5.3e-9 is exactly 53 ULPs (integer),
+                // so it floors to itself, unchanged.
+                .name = "IOU debit: exact at the true (finer) postScale",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-53, -10},
+                .expected = Number{53, -10},
+            },
+            Case{
+                // T = 1.000000000000000, delta = -7.3e-16.
+                // Posterior = 0.99999999999999927 exactly (17 significant
+                // digits: 15 nines then "27"). Rounding to 16 digits
+                // (ToNearest) gives mantissa 9999999999999993 at exponent
+                // -16 -- postScale = -16. At that grid, 7.3e-16 is 7.3
+                // ULPs (not integral), so it floors to 7e-16, not to
+                // itself. See discrepancy note in the report.
+                .name = "IOU debit: decade-crossing debit, floored (not exact) at finer grid",
+                .assetsTotal = Number{1, 0},
+                .delta = Number{-73, -17},
+                .expected = Number{7, -16},
+            },
+            Case{
+                // T = 1000000, delta = -999999.9999999999 (9.999999999999999e5).
+                // Posterior = 0.0000000001 = 1e-10 exactly. postScale is
+                // the exponent of 1e-10 as a canonical STAmount, i.e. -25 --
+                // far finer than the magnitude's own exponent (-10).
+                // roundToScale short-circuits ("value.exponent() >= scale")
+                // and returns the magnitude unchanged.
+                .name = "IOU debit: near-total debit, unchanged (finer postScale than magnitude)",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{-9'999'999'999'999'999LL, -10},
+                .expected = Number{9'999'999'999'999'999LL, -10},
+            },
+        };
+
+        runCases(iou, cases);
+    }
+
+    // -------------------------------------------------------------------
+    // IOU credits (delta positive).
+    // -------------------------------------------------------------------
+    void
+    testIouCredits(Asset const& iou)
+    {
+        std::array const cases{
+            Case{
+                // T = 1000000, delta = +2e-9. Posterior = 1000000.000000002,
+                // exactly 16 significant digits at exponent -9
+                // (postScale = -9, unchanged from T -- addition never
+                // crosses below the 1e6 boundary the way subtraction does).
+                // magnitude is already exact at that scale, so it passes
+                // through unchanged.
+                .name = "IOU credit: on-grid",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{2, -9},
+                .expected = Number{2, -9},
+            },
+            Case{
+                // T = 9.999999999999999, delta = +5.
+                // Exact posterior = 14.999999999999999 (17 significant
+                // digits: "14" then 15 nines). postScale is computed under
+                // ToNearest at the Number (19-digit) level: normalized
+                // mantissa 1499999999999999900 (exponent -17) divided by
+                // 1000 (to reach 16-digit IOU precision) gives
+                // 1499999999999999.9, which rounds UP to 1500000000000000
+                // -- i.e. exactly 15, at exponent -14. postScale = -14.
+                // Downward-guarded posterior (exact, no rounding needed
+                // since 17 digits < 19): 14.999999999999999. Flooring THAT
+                // to 16 digits at scale -14 (Downward) gives
+                // 1499999999999999 * 10^-14 = 14.99999999999999 (postScale
+                // already matches the STAmount's own exponent, so no
+                // further roundToScale is applied).
+                // actualDelta = 14.99999999999999 - 9.999999999999999
+                //             = 4.999999999999991.
+                // This mirrors testBugVaultDepositOvercreditsAcrossScaleBoundary
+                // in VaultBugs_test.cpp (same seed/deposit values), which
+                // asserts post-fix `credited <= paid` rather than an exact
+                // number; this row pins the exact value.
+                .name = "IOU credit: overcredit fix across a scale boundary",
+                .assetsTotal = Number{9'999'999'999'999'999LL, -15},
+                .delta = Number{5, 0},
+                .expected = Number{4'999'999'999'999'991LL, -15},
+            },
+            Case{
+                // Finding-1 regression: T = 1000000, delta = +9.999999999999999e-10.
+                // The exact sum needs ~25 significant digits (1000000 at
+                // position 6, delta's last digit at position -25), far
+                // beyond Number's 19-digit mantissa.
+                //
+                // postScale (computed under ToNearest): the digits of delta
+                // that land within the 19-digit window (positions -10..-12,
+                // "999") plus an all-nines remainder below position -12
+                // round UP under ToNearest, carrying all the way through
+                // the intervening zeros: the sum rounds to exactly
+                // 1000000.000000001, i.e. postScale = -9.
+                //
+                // But the credit branch computes the *posterior* under a
+                // Downward guard, not ToNearest: positions -10..-12 stay
+                // "999" (no carry), giving posterior = 1000000.000000000999
+                // exactly. Flooring that (Downward) to scale -9 truncates
+                // the "999" entirely, landing back on exactly 1000000 --
+                // i.e. the same as T. actualDelta = 0 => tecPRECISION_LOSS.
+                // This is the ambient-rounding leak the Downward guard on
+                // the credit-side sum exists to close; this row is a
+                // regression test that the guard is doing its job.
+                .name = "IOU credit: Finding-1 regression, ToNearest sum would overcredit",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{9'999'999'999'999'999LL, -25},
+                .expected = std::nullopt,
+            },
+            Case{
+                // Same shape as the row above, but delta = +9.995e-10 is a
+                // 19-digit half-even tie at the position-(-12) cusp: the
+                // remainder below the retained "999" digits is exactly
+                // 0.5 ULP, and ToNearest ties-to-even rounds the (odd) "9"
+                // up, carrying the same way. Downward-guarded posterior
+                // still truncates to "...000999" and floors back to T, so
+                // the outcome is identical: tecPRECISION_LOSS.
+                .name = "IOU credit: Finding-1 regression, 19-digit half-even tie",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{9'995, -13},
+                .expected = std::nullopt,
+            },
+            Case{
+                // T = 0, delta = +3.7e-5. Posterior grid is delta's own
+                // scale (postScale = -20, the canonical exponent of
+                // 3.7e-5), so the magnitude is trivially unchanged.
+                .name = "IOU credit: zero-total vault",
+                .assetsTotal = Number{0},
+                .delta = Number{37, -6},
+                .expected = Number{37, -6},
+            },
+            Case{
+                // T = 1000000, delta = +4e-10. Exact sum needs 17
+                // significant digits (leading "1" at position 6, trailing
+                // "4" at position -10); rounding to 16 digits drops the "4"
+                // entirely (0.4 ULP at scale -9 rounds down under both
+                // ToNearest and Downward), so postScale = -9 and the
+                // Downward-guarded posterior floors straight back to T.
+                // actualDelta = 0 => tecPRECISION_LOSS.
+                .name = "IOU credit: sub-ULP credit",
+                .assetsTotal = Number{1'000'000, 0},
+                .delta = Number{4, -10},
+                .expected = std::nullopt,
+            },
+        };
+
+        runCases(iou, cases);
+    }
+
+    // -------------------------------------------------------------------
+    // Integral assets (XRP, MPT): rounding is a no-op, magnitude is
+    // returned unchanged and positive regardless of delta's sign. This is
+    // a regression test for a signed-return bug: the function must not
+    // hand back a negative delta for a debit.
+    // -------------------------------------------------------------------
+    void
+    testIntegralAssets(Asset const& mpt, Asset const& xrp)
+    {
+        std::array const mptCases{
+            Case{
+                .name = "MPT debit: magnitude is positive, not the signed delta",
+                .assetsTotal = Number{1'000'000},
+                .delta = Number{-5},
+                .expected = Number{5},
+            },
+            Case{
+                .name = "MPT credit: unchanged",
+                .assetsTotal = Number{1'000'000},
+                .delta = Number{7},
+                .expected = Number{7},
+            },
+        };
+        runCases(mpt, mptCases);
+
+        std::array const xrpCases{
+            Case{
+                .name = "XRP debit: magnitude is positive, not the signed delta",
+                .assetsTotal = Number{100'000},
+                .delta = Number{-3},
+                .expected = Number{3},
+            },
+            Case{
+                .name = "XRP credit: unchanged",
+                .assetsTotal = Number{100'000},
+                .delta = Number{10},
+                .expected = Number{10},
+            },
+        };
+        runCases(xrp, xrpCases);
+    }
+
+public:
+    void
+    run() override
+    {
+        testProbeLarge330Regime();
+
+        test::jtx::Account const issuer{"issuer"};
+        Issue const iou{toCurrency("USD"), issuer.id()};
+        MPTIssue const mpt{makeMptID(1, issuer.id())};
+        Issue const xrp = xrpIssue();
+
+        testIouDebits(iou);
+        testIouCredits(iou);
+        testIntegralAssets(mpt, xrp);
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
+
+}  // namespace xrpl
diff --git a/src/test/app/vault/VaultInvariantPrecision_test.cpp b/src/test/app/vault/VaultInvariantPrecision_test.cpp
new file mode 100644
index 0000000000..a7eeda34ae
--- /dev/null
+++ b/src/test/app/vault/VaultInvariantPrecision_test.cpp
@@ -0,0 +1,458 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+// With fixCleanup3_4_0 disabled the six delta invariants and the
+// lossUnrealized > (assetsTotal - assetsAvailable) gap invariant spuriously
+// fire on legitimate flows; with the amendment enabled the one-unit
+// tolerance absorbs the sub-ULP drift and every one of these transactions
+// must succeed.  Exactness (assetsTotal delta == assetsAvailable delta
+// exactly) is covered by VaultTransactorPrecision_test.
+class VaultInvariantPrecision_test : public VaultPrecisionFixture
+{
+    // Deposit small integer amounts into an A-1 vault.  Pre-amendment,
+    // deposits of 1, 7, and 10'000'000 land on assetsTotal/assetsAvailable
+    // grids that disagree by one ULP and the invariant fires.  Post-
+    // amendment the tolerance-widened check accepts the same states.
+    void
+    testDepositBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 deposit boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            auto const before = read(env, f);
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+
+                auto const after = read(env, f);
+                Number const tDelta = after.assetsTotal - before.assetsTotal;
+                Number const aDelta = after.assetsAvailable - before.assetsAvailable;
+                Number const requested = asset(amount).number();
+
+                BEAST_EXPECT(tDelta <= requested);
+
+                Number const gap = tDelta > aDelta ? tDelta - aDelta : aDelta - tDelta;
+                BEAST_EXPECT(gap <= oneUnit(asset, after.assetsTotal));
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    actual == tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
+                        transToken(actual));
+            }
+        }
+    }
+
+    // Withdraw long-mantissa share counts from an A-1 vault.  Pre-fix
+    // some counts trip the withdraw delta invariants; post-fix none does.
+    void
+    testWithdrawBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 withdraw boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kShareCounts{
+            99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u, 333'333'333u};
+
+        // Fill the vault with enough shares that every count below is
+        // available to the depositor.
+        Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!f.asset || !f.broker)
+        {
+            BEAST_EXPECT(f.asset && f.broker);
+            return;
+        }
+        auto const& asset = *f.asset;
+
+        Vault const v{env};
+        // Deposit a large amount so we can afford every withdrawal below.
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        for (auto const count : kShareCounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal < count)
+                continue;
+
+            STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}};
+            env(v.withdraw(
+                    {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual != tecINVARIANT_FAILED,
+                    "shares=" + std::to_string(count) + " unexpected invariant failure");
+
+                if (actual == tesSUCCESS)
+                {
+                    auto const after = read(env, f);
+                    Number const tDelta = before.assetsTotal - after.assetsTotal;
+                    Number const pDelta = before.pseudo - after.pseudo;
+                    Number const gap = tDelta > pDelta ? tDelta - pDelta : pDelta - tDelta;
+                    // VaultTransactorPrecision_test tightens this to strict
+                    // equality.
+                    BEAST_EXPECT(gap <= oneUnit(asset, before.assetsTotal));
+                }
+            }
+            // Pre-fix behaviour is fixture-dependent: some share counts may
+            // succeed even without the amendment.  The important property is
+            // that post-fix no legitimate withdrawal is rejected by the
+            // widened invariant.
+        }
+    }
+
+    // Clawback of small IOU amounts against a live-loan vault.  Pre-fix
+    // some amounts trip the clawback delta invariants; post-fix none does.
+    // Also assert the owner force-burn path returns tecNO_PERMISSION
+    // under both amendment states (it never enters assetsToClawback).
+    void
+    testClawbackBoundaryInvariant(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 clawback boundary invariant") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 99, 333, 993, 2000};
+
+        Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false, /*allowClawback=*/true);
+        if (!f.asset || !f.broker)
+        {
+            BEAST_EXPECT(f.asset && f.broker);
+            return;
+        }
+        auto const& asset = *f.asset;
+
+        Vault const v{env};
+
+        // Give the depositor a stake so that the issuer has something to
+        // claw back.
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(2'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        for (auto const amount : kAmounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal == 0)
+                continue;
+
+            env(v.clawback(
+                    {.issuer = f.issuer,
+                     .id = f.vaultKeylet.key,
+                     .holder = f.depositor,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual != tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " unexpected invariant failure");
+            }
+            // Pre-fix behaviour is fixture-dependent: some clawback amounts
+            // may succeed even without the amendment.  The important
+            // property is that post-fix no legitimate clawback is rejected
+            // by the widened invariant.
+        }
+
+        // Owner force-burn only succeeds against an EMPTY vault (see
+        // VaultClawback::preclaim).  Our fixture keeps a live loan, so
+        // this must return tecNO_PERMISSION regardless of the amendment.
+        env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}),
+            Ter(tecNO_PERMISSION));
+        env.close();
+    }
+
+    // Deposit into an A-3 vault where the impaired-loan gap plus the
+    // interest earned from the sibling repayment lands L > (T - A) by
+    // sub-ULP.  Pre-fix the loss invariant fires; post-fix it does not.
+    void
+    testLossInvariantA3(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-3 loss invariant sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+
+                auto const after = read(env, f);
+                BEAST_EXPECT(
+                    after.lossUnrealized <= (after.assetsTotal - after.assetsAvailable) +
+                        oneUnit(asset, after.assetsTotal));
+            }
+            else
+            {
+                BEAST_EXPECTS(
+                    actual == tecINVARIANT_FAILED,
+                    "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " +
+                        transToken(actual));
+            }
+        }
+    }
+
+    // Full 17-magnitude A-1 deposit sweep.  Pre-fix {1, 7, 10'000'000}
+    // are the boundary amounts that fail; post-fix every amount succeeds.
+    void
+    testA1DepositMagnitudes(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-1 deposit magnitude sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{
+            1,
+            2,
+            5,
+            7,
+            10,
+            50,
+            100,
+            500,
+            1'000,
+            5'000,
+            10'000,
+            50'000,
+            100'000,
+            500'000,
+            1'000'000,
+            5'000'000,
+            10'000'000};
+        std::array const kPreFixFailures{1, 7, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+            }
+            else
+            {
+                bool const shouldFail =
+                    std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
+                if (shouldFail)
+                {
+                    BEAST_EXPECTS(
+                        actual == tecINVARIANT_FAILED,
+                        "pre-fix amount=" + std::to_string(amount) +
+                            " expected tecINVARIANT_FAILED, got " + transToken(actual));
+                }
+                // For other amounts pre-fix, we accept any outcome; the
+                // interesting property is only asserted for the known-failing
+                // ones.
+            }
+        }
+    }
+
+    // A-3 deposit sweep.  Pre-fix {1, 7, 10'000, 10'000'000} fail; post-fix
+    // every amount succeeds.  99'999 (delta tolerance) and 10'000'000
+    // (loss tolerance) are the two boundary cases that motivate this PR.
+    void
+    testA3DepositMagnitudes(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        bool const fixEnabled = features[fixCleanup3_4_0];
+        testcase(
+            std::string("A-3 deposit magnitude sweep") +
+            (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        std::array const kAmounts{
+            1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000, 99'999};
+
+        std::array const kPreFixFailures{1, 7, 10'000, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled};
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+            if (!f.asset || !f.broker)
+            {
+                BEAST_EXPECT(f.asset && f.broker);
+                continue;
+            }
+            auto const& asset = *f.asset;
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            TER const actual = env.ter();
+
+            if (fixEnabled)
+            {
+                BEAST_EXPECTS(
+                    actual == tesSUCCESS,
+                    "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " +
+                        transToken(actual));
+            }
+            else
+            {
+                bool const shouldFail =
+                    std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end();
+                if (shouldFail)
+                {
+                    BEAST_EXPECTS(
+                        actual == tecINVARIANT_FAILED,
+                        "pre-fix amount=" + std::to_string(amount) +
+                            " expected tecINVARIANT_FAILED, got " + transToken(actual));
+                }
+            }
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        for (auto const& features : {all_ - fixCleanup3_4_0, all_})
+        {
+            testDepositBoundaryInvariant(features);
+            testWithdrawBoundaryInvariant(features);
+            testClawbackBoundaryInvariant(features);
+            testLossInvariantA3(features);
+            testA1DepositMagnitudes(features);
+            testA3DepositMagnitudes(features);
+        }
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultInvariantPrecision, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h
new file mode 100644
index 0000000000..22a3276fdf
--- /dev/null
+++ b/src/test/app/vault/VaultPrecisionFixture.h
@@ -0,0 +1,256 @@
+#pragma once
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+
+namespace xrpl::test {
+
+// Shared fixture for VaultInvariantPrecision_test and
+// VaultTransactorPrecision_test.
+// impairAndPaySibling=false: 1000 USD vault and one ordinary loan.
+// impairAndPaySibling=true: a second loan is impaired then a sibling is paid
+// off, leaving lossUnrealized at assetsTotal - assetsAvailable.
+class VaultPrecisionFixture : public LoanTestBase
+{
+protected:
+    static constexpr std::uint32_t kFixturePaymentInterval = 86400u * 30u;
+    static constexpr std::uint32_t kFixtureGracePeriod = 86400u * 30u;
+    static constexpr std::uint32_t kFixturePaymentTotal = 120u;
+    // 10% APR, expressed in tenth-bips (1000 = 10.00 %).
+    static constexpr std::uint32_t kFixtureInterestTenthBips = 1000u;
+
+    struct Fixture
+    {
+        // Every account is initialised with a placeholder name because
+        // jtx::Account has no default constructor; setupSingleLoanVault
+        // overwrites them.
+        jtx::Account issuer{"vp_issuer_placeholder"};
+        jtx::Account lender{"vp_lender_placeholder"};
+        jtx::Account borrower{"vp_borrower_placeholder"};
+        // Distinct account used to deposit into the vault. Keeps share
+        // ownership independent of the initial vault seeding.
+        jtx::Account depositor{"vp_depositor_placeholder"};
+        // Optional so callers can BEAST_EXPECT(f.asset && f.broker)
+        // after setup; both are populated in the happy path.
+        std::optional asset;
+        std::optional broker;
+        // Keylet has no default constructor. Fill with an obviously
+        // meaningless placeholder; setupSingleLoanVault overwrites the
+        // fields that matter.
+        Keylet vaultKeylet{ltACCOUNT_ROOT, uint256{}};
+        Keylet loan1Keylet{ltACCOUNT_ROOT, uint256{}};
+        // Only meaningful when impairAndPaySibling == true.
+        Keylet loan2Keylet{ltACCOUNT_ROOT, uint256{}};
+        jtx::Account vaultAccount{"vp_vault_pseudo_placeholder"};
+        MPTID share;
+    };
+
+    // Read-only snapshot of the vault + share issuance at a point in time.
+    // Uses Number for exact arithmetic (no re-quantization).
+    struct Numbers
+    {
+        Asset asset;
+        MPTIssue share;
+        // The {} initializers are not redundant: Number's default constructor is explicit, so
+        // fields omitted from the designated initializer in read() below would otherwise fail
+        // copy-list-initialization.
+        // NOLINTBEGIN(readability-redundant-member-init)
+        Number assetsTotal{};      // sfAssetsTotal
+        Number assetsAvailable{};  // sfAssetsAvailable
+        Number lossUnrealized{};   // sfLossUnrealized
+        Number pseudo{};           // vault pseudo-account balance in the asset
+        Number sharesTotal{};      // sfOutstandingAmount on the share MPT
+        // NOLINTEND(readability-redundant-member-init)
+    };
+
+    static Numbers
+    read(jtx::Env const& env, Fixture const& f)
+    {
+        Numbers n{.asset = f.asset ? f.asset->raw() : Asset{}, .share = MPTIssue{f.share}};
+        if (auto const vaultSle = env.le(f.vaultKeylet))
+        {
+            n.assetsTotal = vaultSle->at(sfAssetsTotal);
+            n.assetsAvailable = vaultSle->at(sfAssetsAvailable);
+            n.lossUnrealized = vaultSle->at(sfLossUnrealized);
+        }
+        if (auto const issuanceSle = env.le(keylet::mptokenIssuance(f.share)))
+        {
+            n.sharesTotal = issuanceSle->at(sfOutstandingAmount);
+        }
+        if (f.asset)
+            n.pseudo = env.balance(f.vaultAccount, *f.asset).number();
+        return n;
+    }
+
+    // One unit at the STAmount scale of `assetsTotalAfter`.  Used as the
+    // tolerance in one-unit-band assertions.
+    static Number
+    oneUnit(Asset const& asset, Number const& assetsTotalAfter)
+    {
+        return Number{1, scale(assetsTotalAfter, asset)};
+    }
+
+    // Build the shared vault + loan(s) layout.  The caller constructs
+    // `env` with whatever FeatureBitset they want to exercise; this helper
+    // just uses it.  If `allowClawback` is true, the issuer's
+    // asfAllowTrustLineClawback flag is set BEFORE any trust line is
+    // established for that issuer.  A separate env.close() runs so the
+    // flag lands in the ledger before the trust lines are set up.
+    static Fixture
+    setupSingleLoanVault(jtx::Env& env, bool impairAndPaySibling, bool allowClawback = false)
+    {
+        using namespace jtx;
+        using namespace jtx::loan;
+        using namespace jtx::loan_broker;
+
+        Fixture f;
+        f.issuer = Account{"vp_issuer"};
+        f.lender = Account{"vp_lender"};
+        f.borrower = Account{"vp_borrower"};
+        f.depositor = Account{"vp_depositor"};
+
+        env.fund(XRP(1'000'000), f.issuer, f.lender, f.borrower, f.depositor);
+        env.close();
+
+        // Must be set BEFORE any trust line to `issuer` is created.
+        if (allowClawback)
+        {
+            env(fset(f.issuer, asfAllowTrustLineClawback));
+            env.close();
+        }
+
+        PrettyAsset const asset = f.issuer["USD"];
+        f.asset = asset;
+
+        env.trust(asset(1'000'000'000), f.lender);
+        env.trust(asset(1'000'000'000), f.borrower);
+        env.trust(asset(1'000'000'000), f.depositor);
+        env(pay(f.issuer, f.lender, asset(100'000'000)));
+        env(pay(f.issuer, f.borrower, asset(100'000'000)));
+        env(pay(f.issuer, f.depositor, asset(100'000'000)));
+        env.close();
+
+        BrokerParameters const brokerParams{
+            .vaultDeposit = 1'000,
+            .debtMax = 0,
+            .coverRateMin = percentageToTenthBips(1),
+            .coverDeposit = 10'000,
+            .managementFeeRate = TenthBips16{100},
+            .coverRateLiquidation = xrpl::lending::kMaxCoverRate};
+
+        // Build the vault + broker manually (rather than calling
+        // createVaultAndBroker) so we can seed only the lender/depositor
+        // trust lines we set up above, and skip the LoanTestBase auto
+        // funding that assumes an XRP asset.
+        Vault const vault{env};
+        auto [createTx, vaultKeylet] = vault.create({.owner = f.lender, .asset = asset});
+        env(createTx);
+        env.close();
+        f.vaultKeylet = vaultKeylet;
+
+        env(vault.deposit(
+            {.depositor = f.lender,
+             .id = vaultKeylet.key,
+             .amount = asset(brokerParams.vaultDeposit)}));
+        env.close();
+
+        auto const brokerKeylet =
+            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender)));
+
+        env(set(f.lender, vaultKeylet.key, brokerParams.flags),
+            kManagementFeeRate(brokerParams.managementFeeRate),
+            kDebtMaximum(asset(brokerParams.debtMax).value()),
+            kCoverRateMinimum(brokerParams.coverRateMin),
+            kCoverRateLiquidation(TenthBips32(brokerParams.coverRateLiquidation)));
+        env(coverDeposit(f.lender, brokerKeylet.key, asset(brokerParams.coverDeposit).value()));
+        env.close();
+
+        f.broker = BrokerInfo{asset, brokerKeylet, vaultKeylet, brokerParams};
+
+        auto const vaultSle = env.le(vaultKeylet);
+        f.vaultAccount = Account{"vp_vault_pseudo", vaultSle->at(sfAccount)};
+        f.share = vaultSle->at(sfShareMPTID);
+
+        Fee const bigFee{env.current()->fees().base * 200};
+
+        auto const setLoan = [&](Number const& principal) -> Keylet {
+            auto const brokerSle = env.le(brokerKeylet);
+            auto const loanKeylet = keylet::loan(
+                brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+            env(loan::set(f.borrower, brokerKeylet.key, asset(principal).number()),
+                Sig(sfCounterpartySignature, f.lender),
+                jtx::loan::kInterestRate(TenthBips32{kFixtureInterestTenthBips}),
+                jtx::loan::kPaymentTotal(kFixturePaymentTotal),
+                jtx::loan::kPaymentInterval(kFixturePaymentInterval),
+                jtx::loan::kGracePeriod(kFixtureGracePeriod),
+                bigFee);
+            env.close();
+            return loanKeylet;
+        };
+
+        // Loan 1: principal 7, the one ordinary loan in both fixtures.
+        // With vault deposit 1000, this leaves A ≈ 993 (see plan).
+        f.loan1Keylet = setLoan(Number{7});
+
+        if (!impairAndPaySibling)
+            return f;
+
+        // Loan 2: sibling loan of principal 11.
+        f.loan2Keylet = setLoan(Number{11});
+
+        // Pay off loan 2 in full so its total value flows into the vault
+        // and pushes T-A upward, meeting the residual loss.  Generous
+        // upper bound; the transactor takes only what is due.
+        //
+        // This happens before the impair below because impair under
+        // fixCleanup3_4_0 requires loan 1 to already be late, and the two
+        // loans are originated close enough together that advancing past
+        // loan 1's due date also makes loan 2 late — which would reject
+        // this full payment with tecEXPIRED.
+        auto const payoff = asset(Number{50}).value();
+        env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee);
+        env.close();
+
+        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            std::uint32_t const dueDate = env.le(f.loan1Keylet)->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
+        env.close();
+
+        return f;
+    }
+};
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp
index 2ac092b5a7..dbceb1cb9c 100644
--- a/src/test/app/vault/VaultRPC_test.cpp
+++ b/src/test/app/vault/VaultRPC_test.cpp
@@ -9,11 +9,13 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -122,6 +124,22 @@ private:
             }
         };
 
+        // An error response must carry a registered token together with the matching code and
+        // message, so that clients dispatching on either of them reach the same conclusion.
+        auto const checkError = [this](
+                                    json::Value const& result,
+                                    std::string const& token,
+                                    ErrorCodeI const code,
+                                    std::string const& message) {
+            BEAST_EXPECT(result[jss::error].asString() == token);
+            BEAST_EXPECT(result[jss::error_code].asInt() == code);
+            BEAST_EXPECT(result[jss::error_message].asString() == message);
+        };
+
+        std::string const badSeqMessage = "Invalid field 'seq', not a positive 32-bit integer.";
+        std::string const badFieldsMessage =
+            "Must specify either 'vault_id' or both 'owner' and 'seq'.";
+
         {
             testcase("RPC ledger_entry selected by key");
             json::Value jvParams;
@@ -276,16 +294,57 @@ private:
             jvParams[jss::ledger_index] = jss::validated;
             jvParams[jss::vault_id] = "foobar";
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
         }
 
         {
-            testcase("RPC vault_info json invalid index");
+            testcase("RPC vault_info json numeric vault_id");
             json::Value jvParams;
             jvParams[jss::ledger_index] = jss::validated;
             jvParams[jss::vault_id] = 0;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
+        }
+
+        {
+            testcase("RPC vault_info json object vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = json::Value(json::ValueType::Object);
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(
+                jv[jss::result],
+                "invalidParams",
+                RpcInvalidParams,
+                "Invalid field 'vault_id', not hex string.");
+        }
+
+        {
+            // An all-zero key is a well-formed request for a vault that cannot exist, not a
+            // malformed one. parseHex accepts both the padded form and the short "0".
+            testcase("RPC vault_info json all zero vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = strHex(uint256(beast::kZero));
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
+        }
+
+        {
+            testcase("RPC vault_info json short zero vault_id");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::vault_id] = "0";
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
         }
 
         {
@@ -308,7 +367,7 @@ private:
             jvParams[jss::owner] = owner.human();
             jvParams[jss::seq] = "foobar";
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
         }
 
         {
@@ -318,7 +377,7 @@ private:
             jvParams[jss::owner] = owner.human();
             jvParams[jss::seq] = 0;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
         }
 
         {
@@ -328,7 +387,7 @@ private:
             jvParams[jss::owner] = owner.human();
             jvParams[jss::seq] = -1;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
         }
 
         {
@@ -338,7 +397,7 @@ private:
             jvParams[jss::owner] = owner.human();
             jvParams[jss::seq] = 1e20;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
         }
 
         {
@@ -348,7 +407,7 @@ private:
             jvParams[jss::owner] = owner.human();
             jvParams[jss::seq] = true;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage);
         }
 
         {
@@ -358,7 +417,25 @@ private:
             jvParams[jss::owner] = "foobar";
             jvParams[jss::seq] = sequence;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(
+                jv[jss::result],
+                "actMalformed",
+                RpcActMalformed,
+                "Invalid field 'owner', not AccountID.");
+        }
+
+        {
+            testcase("RPC vault_info json array owner");
+            json::Value jvParams;
+            jvParams[jss::ledger_index] = jss::validated;
+            jvParams[jss::owner] = json::Value(json::ValueType::Array);
+            jvParams[jss::seq] = sequence;
+            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
+            checkError(
+                jv[jss::result],
+                "actMalformed",
+                RpcActMalformed,
+                "Invalid field 'owner', not AccountID.");
         }
 
         {
@@ -367,7 +444,7 @@ private:
             jvParams[jss::ledger_index] = jss::validated;
             jvParams[jss::owner] = owner.human();
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -376,7 +453,7 @@ private:
             jvParams[jss::ledger_index] = jss::validated;
             jvParams[jss::seq] = sequence;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -386,7 +463,7 @@ private:
             jvParams[jss::vault_id] = strHex(keylet.key);
             jvParams[jss::seq] = sequence;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -396,7 +473,7 @@ private:
             jvParams[jss::vault_id] = strHex(keylet.key);
             jvParams[jss::owner] = owner.human();
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -409,7 +486,7 @@ private:
             jvParams[jss::seq] = sequence;
             jvParams[jss::owner] = owner.human();
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -417,7 +494,7 @@ private:
             json::Value jvParams;
             jvParams[jss::ledger_index] = jss::validated;
             auto jv = env.rpc("json", "vault_info", to_string(jvParams));
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage);
         }
 
         {
@@ -427,15 +504,15 @@ private:
         }
 
         {
-            testcase("RPC vault_info command line invalid index");
+            testcase("RPC vault_info command line zero index");
             json::Value jv = env.rpc("vault_info", "0", "validated");
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
         }
 
         {
-            testcase("RPC vault_info command line invalid index");
+            testcase("RPC vault_info command line unknown index");
             json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
-            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
+            checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found.");
         }
 
         {
diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp
index 94c594f674..c2858a204d 100644
--- a/src/test/app/vault/VaultScale_test.cpp
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -71,7 +72,12 @@ private:
 
         auto testCase = [&, this](
                             std::uint8_t scale, std::function test) {
-            Env env{*this, testableAmendments()};
+            // These scale-focused tests build an open-ended vault and
+            // exercise deposit/withdraw/clawback (with one test also
+            // attaching a loan broker). featureLendingProtocolV1_1 adds a
+            // closed-ended vault gate on LoanBrokerSet::preclaim and is
+            // orthogonal to what this suite asserts, so strip it here.
+            Env env{*this, testableAmendments() - featureLendingProtocolV1_1};
             Account const owner{"owner"};
             Account const issuer{"issuer"};
             Account const depositor{"depositor"};
@@ -546,13 +552,13 @@ private:
             }
 
             {
-                testcase("Scale withdraw with rounding shares up");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
+                testcase("Scale withdraw with rounding shares up (truncated post-fixCleanup3_4_0)");
+                // Pre-fixCleanup3_4_0:
+                //   shares = round(875 * 3.75 / 87.5) = 38
+                //   assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
+                // Post-fixCleanup3_4_0:
+                //   shares = floor(37.5) = 37
+                //   assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.withdraw(
@@ -561,26 +567,23 @@ private:
                      .amount = STAmount(d.asset, Number(375, -2))});
                 env(tx);
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
                 BEAST_EXPECT(
                     env.balance(d.depositor, d.assets) ==
-                    STAmount(d.asset, start + Number(38, -1)));
+                    STAmount(d.asset, start + Number(37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(875 - 38, -1)));
+                    STAmount(d.asset, Number(875 - 37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(875 - 38, 0)));
+                    STAmount(d.share, -Number(875 - 37, 0)));
             }
 
             {
                 testcase("Scale withdraw with rounding shares down");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
+                // Chained state: 838 shares outstanding, 83.8 assets.
+                //   shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
+                //   assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.withdraw(
@@ -589,37 +592,37 @@ private:
                      .amount = STAmount(d.asset, Number(372, -2))});
                 env(tx);
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
                 BEAST_EXPECT(
                     env.balance(d.depositor, d.assets) ==
                     STAmount(d.asset, start + Number(37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(837 - 37, -1)));
+                    STAmount(d.asset, Number(838 - 37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(837 - 37, 0)));
+                    STAmount(d.share, -Number(838 - 37, 0)));
             }
 
             {
-                testcase("Scale withdraw tiny amount");
+                testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0");
+                // Chained state: 801 shares outstanding, 80.1 assets.
+                //   shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
+                // Zero shares => tecPRECISION_LOSS. State is unchanged.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.withdraw(
                     {.depositor = d.depositor,
                      .id = d.keylet.key,
                      .amount = STAmount(d.asset, Number(9, -2))});
-                env(tx);
+                env(tx, Ter{tecPRECISION_LOSS});
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
+                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
                 BEAST_EXPECT(
-                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
                 BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(800 - 1, -1)));
-                BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(800 - 1, 0)));
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
             }
 
             {
@@ -738,13 +741,13 @@ private:
             }
 
             {
-                testcase("Scale clawback with rounding shares up");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
+                testcase("Scale clawback with rounding shares up (truncated post-fixCleanup3_4_0)");
+                // Pre-fixCleanup3_4_0:
+                //   shares = round(875 * 3.75 / 87.5) = 38
+                //   assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
+                // Post-fixCleanup3_4_0:
+                //   shares = floor(37.5) = 37
+                //   assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.clawback(
@@ -754,24 +757,21 @@ private:
                      .amount = STAmount(d.asset, Number(375, -2))});
                 env(tx);
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
                 BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(875 - 38, -1)));
+                    STAmount(d.asset, Number(875 - 37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(875 - 38, 0)));
+                    STAmount(d.share, -Number(875 - 37, 0)));
             }
 
             {
                 testcase("Scale clawback with rounding shares down");
-                // assetsToSharesWithdraw:
-                //  shares = sharesTotal * (assets / assetsTotal)
-                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
-                // sharesToAssetsWithdraw:
-                //  assets = assetsTotal * (shares / sharesTotal)
-                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
+                // Chained state: 838 shares outstanding, 83.8 assets.
+                //   shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
+                //   assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.clawback(
@@ -781,18 +781,21 @@ private:
                      .amount = STAmount(d.asset, Number(372, -2))});
                 env(tx);
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
                 BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(837 - 37, -1)));
+                    STAmount(d.asset, Number(838 - 37, -1)));
                 BEAST_EXPECT(
                     env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(837 - 37, 0)));
+                    STAmount(d.share, -Number(838 - 37, 0)));
             }
 
             {
-                testcase("Scale clawback tiny amount");
+                testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0");
+                // Chained state: 801 shares outstanding, 80.1 assets.
+                //   shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
+                // Zero shares => tecPRECISION_LOSS. State is unchanged.
 
                 auto const start = env.balance(d.depositor, d.assets).number();
                 auto tx = d.vault.clawback(
@@ -800,16 +803,14 @@ private:
                      .id = d.keylet.key,
                      .holder = d.depositor,
                      .amount = STAmount(d.asset, Number(9, -2))});
-                env(tx);
+                env(tx, Ter{tecPRECISION_LOSS});
                 env.close();
-                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
+                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
                 BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
                 BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.assets) ==
-                    STAmount(d.asset, Number(800 - 1, -1)));
+                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
                 BEAST_EXPECT(
-                    env.balance(d.vaultAccount, d.shares) ==
-                    STAmount(d.share, -Number(800 - 1, 0)));
+                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
             }
 
             {
@@ -897,6 +898,117 @@ private:
                 BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
             }
         });
+
+        // peek() writes the open ledger only; do not close() before le().
+        auto seedLargeTotal = [](Env& env,
+                                 Data& d,
+                                 Number const& total,
+                                 Number const& available,
+                                 std::uint64_t outstanding) {
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(100, 0))});
+            env(tx);
+            env.close();
+            d.peek([&](SLE& vault, SLE& shares) -> bool {
+                vault[sfAssetsTotal] = total;
+                vault[sfAssetsAvailable] = available;
+                shares[sfOutstandingAmount] = outstanding;
+                return true;
+            });
+        };
+
+        auto expectVault = [this](
+                               Env& env,
+                               Data const& d,
+                               Number const& total,
+                               Number const& available,
+                               STAmount const& shareBalance) {
+            auto const sle = env.le(d.keylet);
+            BEAST_EXPECT(sle != nullptr);
+            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
+            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
+            BEAST_EXPECT(env.balance(d.depositor, d.shares) == shareBalance);
+        };
+
+        // T-6 is exact after the decade; recover 6.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback uses posterior scale across decade boundary");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{6};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - available, Number(0), d.share(94));
+        });
+
+        // T stays on the 10-asset grid; 6 is unrepresentable.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback rejects amount below posterior scale");
+
+            Number const midGridTotal{12345678901234567ll};
+            Number const available{6};
+            seedLargeTotal(env, d, midGridTotal, available, 12345678901234567ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tecPRECISION_LOSS));
+            expectVault(env, d, midGridTotal, available, d.share(100));
+        });
+
+        // A recovery larger than the anterior ULP also lands exactly on the finer posterior grid.
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale clawback preserves exact posterior amount");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{15};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto tx =
+                d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - available, Number(0), d.share(85));
+        });
+
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale deposit rejects amount below posterior scale");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{100};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto const assetsBefore = env.balance(d.depositor, d.assets);
+            auto tx = d.vault.deposit(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.asset, Number(6))});
+            env(tx, Ter(tecPRECISION_LOSS));
+            expectVault(env, d, midGridTotal, available, d.share(100));
+            BEAST_EXPECT(env.balance(d.depositor, d.assets) == assetsBefore);
+        });
+
+        testCase(0, [&, this](Env& env, Data d) {
+            testcase("Scale withdraw uses posterior scale across decade boundary");
+
+            Number const midGridTotal{10000000000000005ll};
+            Number const available{100};
+            seedLargeTotal(env, d, midGridTotal, available, 10000000000000005ull);
+
+            auto const assetsBefore = env.balance(d.depositor, d.assets);
+            auto tx = d.vault.withdraw(
+                {.depositor = d.depositor,
+                 .id = d.keylet.key,
+                 .amount = STAmount(d.share, Number(15))});
+            env(tx, Ter(tesSUCCESS));
+            expectVault(env, d, midGridTotal - Number(15), Number(85), d.share(85));
+            BEAST_EXPECT(
+                env.balance(d.depositor, d.assets) ==
+                STAmount(d.asset, assetsBefore.number() + Number(15)));
+        });
     }
 
     void
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
index ffaad07112..92d5dd04d4 100644
--- a/src/test/app/vault/VaultSoleShareholder_test.cpp
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -13,6 +13,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -133,6 +134,7 @@ private:
 
         {
             using namespace loan;
+            using namespace std::chrono_literals;
             env(set(f.borrower, f.brokerID, kStuckPrincipal),
                 Sig(sfCounterpartySignature, f.lender),
                 kPaymentTotal(kStuckPayTotal),
@@ -140,6 +142,15 @@ private:
                 Fee(env.current()->fees().base * 2),
                 Ter(tesSUCCESS));
             env.close();
+
+            // Impairment requires the payment to be late, so advance past
+            // the due date before impairing.
+            auto const loanSle = env.le(*f.loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return f;
+            std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
             env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
         }
@@ -464,7 +475,10 @@ private:
             "Vault withdraw: sole-shareholder partial fixed-shares uses "
             "full-price rate (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an
+        // open-ended vault and this test asserts amendment-independent
+        // withdrawal invariants (see the note on run()).
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
         {
@@ -551,7 +565,8 @@ private:
             "Vault withdraw: sole shareholder fully exits after impaired "
             "loan is repaid (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1 as above.
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
         {
@@ -588,7 +603,14 @@ private:
         BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
 
         // Borrower repays the loan in full (pays more than the outstanding
-        // total; the loan transactor caps the receivable).
+        // total each time; the loan transactor caps the receivable). The
+        // loan is still overdue from the impairment setup, so the first
+        // (and only remaining, since kStuckPayTotal == 2) outstanding
+        // installment must be caught up with a late payment before the
+        // final regular payment can close the loan out.
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
+            Ter(tesSUCCESS));
+        env.close();
         env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
         env.close();
 
@@ -639,12 +661,20 @@ public:
     void
     run() override
     {
-        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFixedAssetExit(all_);
-        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFullSharesRejected(all_);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
+        // These sole-shareholder exit scenarios build an open-ended vault
+        // and drive it through deposits, a loan broker, an impaired loan
+        // and finally a withdrawal by the last shareholder. Under
+        // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults, so this suite runs with
+        // the amendment stripped; the invariants asserted here are
+        // amendment-independent.
+        auto const legacy = all_ - featureLendingProtocolV1_1;
+        testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(legacy);
+        testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(legacy);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy);
         testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
         testWithdrawSoleShareholderLoanRepaymentExit();
     }
diff --git a/src/test/app/vault/VaultTransactorPrecision_test.cpp b/src/test/app/vault/VaultTransactorPrecision_test.cpp
new file mode 100644
index 0000000000..e06c87ee68
--- /dev/null
+++ b/src/test/app/vault/VaultTransactorPrecision_test.cpp
@@ -0,0 +1,357 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+namespace xrpl::test {
+
+// With fixCleanup3_4_0, deposit/withdraw/clawback apply one amount on the
+// sfAssetsTotal grid. These tests require T, A, and the pseudo-account to
+// change by the same Number; the invariant suite still allows a one-unit gap.
+class VaultTransactorPrecision_test : public VaultPrecisionFixture
+{
+    jtx::Env
+    makeEnv()
+    {
+        return jtx::Env{*this, jtx::envconfig(), all_, nullptr, beast::Severity::Disabled};
+    }
+
+    bool
+    ready(Fixture const& f)
+    {
+        return BEAST_EXPECT(f.asset && f.broker) && f.asset;
+    }
+
+    void
+    assertEqualDeltas(Numbers const& before, Numbers const& after, std::string const& tag)
+    {
+        Number const tDelta = before.assetsTotal - after.assetsTotal;
+        Number const aDelta = before.assetsAvailable - after.assetsAvailable;
+        Number const pDelta = before.pseudo - after.pseudo;
+        BEAST_EXPECTS(tDelta == aDelta, tag + " tDelta != aDelta");
+        BEAST_EXPECTS(tDelta == pDelta, tag + " tDelta != pDelta");
+    }
+
+    void
+    testDeposit()
+    {
+        using namespace jtx;
+
+        testcase("deposit clamp does not over-credit");
+
+        std::array const kAmounts{1, 7, 1'000, 10'000'000};
+
+        for (auto const amount : kAmounts)
+        {
+            Env env = makeEnv();
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!ready(f))
+                continue;
+            // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+            jtx::PrettyAsset const& asset = f.asset.value();
+
+            auto const before = read(env, f);
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(amount).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            if (env.ter() != tesSUCCESS)
+                continue;
+
+            auto const after = read(env, f);
+            Number const tDelta = after.assetsTotal - before.assetsTotal;
+            Number const requested = asset(amount).number();
+            BEAST_EXPECTS(
+                tDelta <= requested,
+                "amount=" + std::to_string(amount) + " tDelta exceeds requested");
+
+            Number const sharesMinted = after.sharesTotal - before.sharesTotal;
+            if (before.sharesTotal == Number{0})
+                continue;
+            Number const shareValue = (before.assetsTotal * sharesMinted) / before.sharesTotal;
+            BEAST_EXPECTS(
+                shareValue <= tDelta,
+                "amount=" + std::to_string(amount) + " shareValue > assetsTaken");
+        }
+
+        {
+            Env env = makeEnv();
+            auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+            if (!ready(f))
+                return;
+            // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+            // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+            jtx::PrettyAsset const& asset = f.asset.value();
+
+            Vault const v{env};
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(99'000'000).value()}),
+                Ter(std::ignore));
+            env.close();
+
+            auto const before = read(env, f);
+            Number const kLowerBound{1, 6};
+            BEAST_EXPECT(before.assetsTotal > kLowerBound);
+
+            auto const tinyAmount = asset(Number{1, -10}).value();
+            env(v.deposit(
+                    {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = tinyAmount}),
+                Ter(std::ignore));
+            env.close();
+
+            BEAST_EXPECTS(
+                env.ter() == tecPRECISION_LOSS,
+                std::string{"expected tecPRECISION_LOSS, got "} + transToken(env.ter()));
+
+            auto const after = read(env, f);
+            BEAST_EXPECT(after.assetsTotal == before.assetsTotal);
+            BEAST_EXPECT(after.assetsAvailable == before.assetsAvailable);
+            BEAST_EXPECT(after.sharesTotal == before.sharesTotal);
+        }
+    }
+
+    void
+    testWithdraw()
+    {
+        using namespace jtx;
+
+        testcase("withdraw deltas are equal");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkSuccess = [&](STAmount const& amount, std::string const& tag) {
+            auto const before = read(env, f);
+            env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = amount}),
+                Ter(std::ignore));
+            env.close();
+            if (env.ter() != tesSUCCESS)
+                return;
+
+            auto const after = read(env, f);
+            assertEqualDeltas(before, after, tag);
+
+            Number const sharesBurned = before.sharesTotal - after.sharesTotal;
+            if (before.sharesTotal == Number{0})
+                return;
+            Number const shareValue = (before.assetsTotal * sharesBurned) / before.sharesTotal;
+            Number const tDelta = before.assetsTotal - after.assetsTotal;
+            BEAST_EXPECTS(tDelta <= shareValue, tag + " payout > shareValue");
+        };
+
+        std::array const kShareCounts{99'999u, 333'333u, 1'234'567u};
+        for (auto const count : kShareCounts)
+        {
+            auto const before = read(env, f);
+            if (before.sharesTotal < count)
+                continue;
+            STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}};
+            checkSuccess(shareAmount, "shares=" + std::to_string(count));
+        }
+
+        std::array const kAssetAmounts{1, 7, 99};
+        for (auto const amount : kAssetAmounts)
+            checkSuccess(asset(amount).value(), "assets=" + std::to_string(amount));
+    }
+
+    // Withdraw more than sfAssetsAvailable must return tecINSUFFICIENT_FUNDS,
+    // not tecPRECISION_LOSS.
+    void
+    testWithdrawInsufficientFundsPrecedence()
+    {
+        using namespace jtx;
+
+        testcase("withdraw over available returns insufficient funds, not precision loss");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(1'000'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto const before = read(env, f);
+        if (!BEAST_EXPECT(before.assetsAvailable > Number{0}))
+            return;
+
+        STAmount const request = asset(before.assetsAvailable + Number{1}).value();
+        env(v.withdraw({.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = request}),
+            Ter(std::ignore));
+        env.close();
+
+        BEAST_EXPECTS(
+            env.ter() == tecINSUFFICIENT_FUNDS,
+            std::string{"expected tecINSUFFICIENT_FUNDS, got "} + transToken(env.ter()));
+    }
+
+    void
+    testClawback()
+    {
+        using namespace jtx;
+
+        testcase("clawback deltas are equal");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(
+            env,
+            /*impairAndPaySibling=*/false,
+            /*allowClawback=*/true);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(2'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkSuccess = [&](std::optional const& amount, std::string const& tag) {
+            auto const before = read(env, f);
+            if (before.sharesTotal == Number{0})
+                return;
+
+            env(v.clawback(
+                    {.issuer = f.issuer,
+                     .id = f.vaultKeylet.key,
+                     .holder = f.depositor,
+                     .amount = amount}),
+                Ter(std::ignore));
+            env.close();
+            if (env.ter() != tesSUCCESS)
+                return;
+
+            assertEqualDeltas(before, read(env, f), tag);
+        };
+
+        std::array const kAmounts{1, 7, 99};
+        for (auto const amount : kAmounts)
+            checkSuccess(asset(amount).value(), "amount=" + std::to_string(amount));
+
+        checkSuccess(std::nullopt, "sfAmount absent");
+    }
+
+    void
+    testImpairedVault()
+    {
+        using namespace jtx;
+
+        testcase("impaired vault loss stays within assetsTotal - assetsAvailable");
+
+        Env env = makeEnv();
+        auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true);
+        if (!ready(f))
+            return;
+        // ready() above guarantees f.asset is engaged; the guard is opaque to clang-tidy.
+        // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
+        jtx::PrettyAsset const& asset = f.asset.value();
+
+        Vault const v{env};
+        env(v.deposit(
+                {.depositor = f.depositor,
+                 .id = f.vaultKeylet.key,
+                 .amount = asset(5'000).value()}),
+            Ter(std::ignore));
+        env.close();
+
+        auto checkInvariant = [&](std::string const& tag) {
+            TER const actual = env.ter();
+            BEAST_EXPECTS(actual != tecINVARIANT_FAILED, tag + " unexpected invariant failure");
+            if (actual != tesSUCCESS)
+                return;
+            auto const after = read(env, f);
+            BEAST_EXPECTS(
+                after.lossUnrealized <= after.assetsTotal - after.assetsAvailable,
+                tag + " lossUnrealized exceeds assetsTotal - assetsAvailable");
+        };
+
+        std::array const kAmounts{1, 7, 51, 137};
+        for (std::size_t i = 0; i + 1 < kAmounts.size(); i += 2)
+        {
+            int const depositAmount = kAmounts[i];
+            int const withdrawAmount = kAmounts[i + 1];
+
+            env(v.deposit(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(depositAmount).value()}),
+                Ter(std::ignore));
+            env.close();
+            checkInvariant("deposit=" + std::to_string(depositAmount));
+
+            env(v.withdraw(
+                    {.depositor = f.depositor,
+                     .id = f.vaultKeylet.key,
+                     .amount = asset(withdrawAmount).value()}),
+                Ter(std::ignore));
+            env.close();
+            checkInvariant("withdraw=" + std::to_string(withdrawAmount));
+        }
+    }
+
+public:
+    void
+    run() override
+    {
+        testDeposit();
+        testWithdraw();
+        testWithdrawInsufficientFundsPrecedence();
+        testClawback();
+        testImpairedVault();
+    }
+};
+
+BEAST_DEFINE_TESTSUITE(VaultTransactorPrecision, app, xrpl);
+
+}  // namespace xrpl::test
diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp
index 4219ce4661..45f6d1deaf 100644
--- a/src/test/app/vault/VaultValidation_test.cpp
+++ b/src/test/app/vault/VaultValidation_test.cpp
@@ -5,10 +5,12 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -1068,6 +1070,113 @@ private:
         }
     }
 
+    // A pseudo-account belongs to a ledger object, so it must never be the
+    // destination of a withdrawal. The payout is refused either way, by the
+    // deposit authorization every pseudo-account carries, so the only change
+    // is a misleading tecNO_PERMISSION becoming tecPSEUDO_ACCOUNT. The check
+    // runs ahead of the private-vault domain check, which would otherwise
+    // report a domain problem against an account that can never join one.
+    void
+    testVaultWithdrawPseudoAccountDestination(FeatureBitset features)
+    {
+        using namespace test::jtx;
+
+        bool const withFix = features[fixCleanup3_4_0];
+        testcase(
+            std::string{"VaultWithdraw pseudo-account destination"} +
+            (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)"));
+
+        Account const issuer{"issuer"};
+        Account const owner{"owner"};
+        Account const depositor{"depositor"};
+        Account const pdOwner{"pdOwner"};
+        Account const credIssuer{"credIssuer"};
+        std::string const credType = "credential";
+
+        Env env{*this, features};
+        Vault const vault{env};
+
+        env.fund(XRP(100'000), issuer, owner, depositor, pdOwner, credIssuer);
+        // Rippling plays no part in what is being tested here, and would
+        // otherwise stop the payout before it reaches the check under test.
+        env(fset(issuer, asfDefaultRipple));
+        env.close();
+
+        PrettyAsset const asset = issuer["IOU"];
+        for (auto const& account : {owner, depositor})
+        {
+            env.trust(asset(1'000'000), account);
+            env(pay(issuer, account, asset(10'000)));
+        }
+        env.close();
+
+        // Another vault over the same asset supplies the destination. Its
+        // pseudo-account holds a trust line for the asset from creation, so
+        // the payout is refused for being a pseudo-account and nothing else.
+        auto const pseudoDestination = [&]() {
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+            return Account("otherVault", env.le(keylet)->at(sfAccount));
+        }();
+
+        TER const expected = withFix ? TER(tecPSEUDO_ACCOUNT) : TER(tecNO_PERMISSION);
+
+        auto const withdrawToPseudo = [&](uint256 const& vaultId) {
+            auto tx = vault.withdraw({.depositor = depositor, .id = vaultId, .amount = asset(1)});
+            tx[sfDestination] = pseudoDestination.human();
+            return tx;
+        };
+
+        {
+            auto [createTx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(createTx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+            env.close();
+
+            env(withdrawToPseudo(keylet.key), Ter(expected));
+            env.close();
+
+            // Withdrawing to self out of the same vault stays unaffected.
+            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
+            env.close();
+        }
+
+        {
+            auto const domainId = [&]() {
+                pdomain::Credentials const credentials{
+                    {.issuer = credIssuer, .credType = credType}};
+                env(pdomain::setTx(pdOwner, credentials));
+                env.close();
+                return pdomain::getNewDomain(env.meta());
+            }();
+
+            env(credentials::create(depositor, credIssuer, credType));
+            env(credentials::accept(depositor, credIssuer, credType));
+            env.close();
+
+            auto [createTx, keylet] =
+                vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
+            env(createTx);
+            env.close();
+
+            auto setTx = vault.set({.owner = owner, .id = keylet.key});
+            setTx[sfDomainID] = to_string(domainId);
+            env(setTx);
+            env.close();
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)}));
+            env.close();
+
+            // The domain check never gets a say: the destination is rejected
+            // for what it is, not for the domain it is missing.
+            env(withdrawToPseudo(keylet.key), Ter(expected));
+            env.close();
+        }
+    }
+
 public:
     void
     run() override
@@ -1078,6 +1187,9 @@ public:
         testCreateFailMPT();
         testVaultDeleteMemoData();
         testVaultCreateLEVersion();
+
+        testVaultWithdrawPseudoAccountDestination(all_ - fixCleanup3_4_0);
+        testVaultWithdrawPseudoAccountDestination(all_);
     }
 };
 
diff --git a/src/test/jtx/envconfig.h b/src/test/jtx/envconfig.h
index 1f920fca58..5ad24e25c4 100644
--- a/src/test/jtx/envconfig.h
+++ b/src/test/jtx/envconfig.h
@@ -3,6 +3,7 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -62,6 +63,19 @@ envconfig(F&& modfunc, Args&&... args)
     return modfunc(envconfig(), std::forward(args)...);
 }
 
+/**
+ * @brief adjust config to enable online_delete
+ *
+ * @param cfg config instance to be modified
+ *
+ * @param deleteInterval how many new ledgers should be available before
+ * rotating. Defaults to 8, because the standalone minimum is 8.
+ *
+ * @return unique_ptr to Config instance
+ */
+std::unique_ptr
+onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval = 8);
+
 /**
  * @brief adjust config so no admin ports are enabled
  *
diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp
index bc65738b44..14690058ec 100644
--- a/src/test/jtx/impl/envconfig.cpp
+++ b/src/test/jtx/impl/envconfig.cpp
@@ -7,8 +7,10 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -60,6 +62,15 @@ setupConfigForUnitTests(Config& cfg)
 
 namespace jtx {
 
+std::unique_ptr
+onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval)
+{
+    cfg->ledgerHistory = deleteInterval;
+    auto& section = cfg->section(Sections::kNodeDatabase);
+    section.set(Keys::kOnlineDelete, std::to_string(deleteInterval));
+    return cfg;
+}
+
 std::unique_ptr
 noAdmin(std::unique_ptr cfg)
 {
diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp
index c6cd49fa26..2743084beb 100644
--- a/src/test/jtx/impl/mpt.cpp
+++ b/src/test/jtx/impl/mpt.cpp
@@ -17,7 +17,7 @@
 #include 
 #include 
 #include 
-#include 
+#include 
 #include 
 #include 
 #include 
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index 978c3864d6..4688e8c4a6 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -3,17 +3,22 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -37,6 +42,27 @@ Vault::create(CreateArgs const& args) const
     return {jv, keylet};
 }
 
+std::tuple
+Vault::createClosedEnded(CreateClosedEndedArgs const& args) const
+{
+    auto const sub = env.now() + args.subscriptionOffset;
+    auto const red = sub + args.investmentWindow;
+    auto [jv, keylet] = create(
+        {.owner = args.owner,
+         .asset = args.asset,
+         .flags = args.flags,
+         .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+         .subscriptionDate = static_cast(sub.time_since_epoch().count()),
+         .redemptionDate = static_cast(red.time_since_epoch().count())});
+    return {jv, keylet, sub};
+}
+
+void
+Vault::closePastSubscription(NetClock::time_point subscriptionDate) const
+{
+    env.close(subscriptionDate + std::chrono::seconds{1});
+}
+
 json::Value
 Vault::set(SetArgs const& args)
 {
diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h
index 992051b61f..6b2ffddfb3 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -3,10 +3,12 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -39,6 +41,38 @@ struct Vault
     [[nodiscard]] std::tuple
     create(CreateArgs const& args) const;
 
+    struct CreateClosedEndedArgs
+    {
+        Account owner;
+        Asset asset;
+        std::optional flags =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        NetClock::duration subscriptionOffset = std::chrono::seconds{10};
+        NetClock::duration investmentWindow = std::chrono::seconds{1'000'000};
+    };
+
+    /**
+     * Return a VaultCreate transaction for a closed-ended vault, its
+     * expected keylet, and the vault's SubscriptionDate.
+     *
+     * Under featureLendingProtocolV1_1, LoanBrokerSet::preclaim only
+     * accepts closed-ended vaults, so tests that attach a loan broker
+     * need one. SubscriptionDate is set to now() + subscriptionOffset,
+     * giving callers a window to deposit while still in the Subscription
+     * phase; pass the returned date to closePastSubscription() afterwards
+     * to advance into the Investment phase.
+     */
+    [[nodiscard]] std::tuple
+    createClosedEnded(CreateClosedEndedArgs const& args) const;
+
+    /**
+     * Advance env's clock to just past subscriptionDate, moving a
+     * closed-ended vault from the Subscription phase into the Investment
+     * phase.
+     */
+    void
+    closePastSubscription(NetClock::time_point subscriptionDate) const;
+
     struct SetArgs
     {
         Account owner;
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/rpc/BookChanges_test.cpp b/src/test/rpc/BookChanges_test.cpp
index 98a9372982..f0b4a4e187 100644
--- a/src/test/rpc/BookChanges_test.cpp
+++ b/src/test/rpc/BookChanges_test.cpp
@@ -1,3 +1,4 @@
+#include 
 #include 
 #include 
 #include 
@@ -8,13 +9,33 @@
 #include 
 #include 
 
+#include 
+
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
 #include 
 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
 namespace xrpl::test {
 
 class BookChanges_test : public beast::unit_test::Suite
@@ -115,6 +136,195 @@ public:
         BEAST_EXPECT(jrr[jss::changes][0u][jss::domain].asString() == to_string(domainID));
     }
 
+    void
+    testSkipsOverflowingRate()
+    {
+        testcase("book_changes skips overflowing rate");
+        using namespace jtx;
+
+        Env env(*this);
+        Account const gw{"gw"};
+        Account const iouGw{"iouGw"};
+
+        auto const big = MPT{gw.id(), 1};
+        auto const usd = iouGw["USD"];
+
+        // This metadata represents a partial MPT/IOU offer fill whose deltas
+        // make divide(deltaGets, deltaPays) overflow before MPTokensV2 skips
+        // the unrepresentable book-change rate.
+        STObject finalFields = STObject::makeInnerObject(sfFinalFields);
+        finalFields.setFieldU32(sfSequence, 1);
+        finalFields.setFieldAmount(sfTakerGets, big(1'800'000'000'000'000'000ull));
+        finalFields.setFieldAmount(sfTakerPays, usd(9));
+
+        STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
+        previousFields.setFieldU32(sfSequence, 1);
+        previousFields.setFieldAmount(sfTakerGets, big(3'600'000'000'000'000'000ull));
+        previousFields.setFieldAmount(sfTakerPays, usd(18));
+
+        STObject modifiedOffer{sfModifiedNode};
+        modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
+        modifiedOffer.setFieldObject(sfFinalFields, finalFields);
+        modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
+
+        STArray affectedNodes{sfAffectedNodes};
+        affectedNodes.pushBack(std::move(modifiedOffer));
+
+        auto metadata = std::make_shared(sfTransactionMetaData);
+        metadata->setFieldArray(sfAffectedNodes, affectedNodes);
+
+        auto tx = std::make_shared(ttOFFER_CREATE, [](STObject&) {});
+
+        auto const test = [&](std::unordered_set> const& features) {
+            auto ledger = std::make_shared(
+                2,
+                NetClock::time_point{},
+                Rules{features},
+                env.current()->fees(),
+                env.app().getNodeFamily());
+
+            auto txSerializer = std::make_shared();
+            tx->add(*txSerializer);
+
+            auto metaSerializer = std::make_shared();
+            metadata->add(*metaSerializer);
+
+            ledger->rawTxInsert(uint256{1}, txSerializer, metaSerializer);
+            ledger->setImmutable();
+            ledger->setValidated();
+
+            try
+            {
+                auto const result =
+                    xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger));
+                BEAST_EXPECT(result[jss::type] == "bookChanges");
+                BEAST_EXPECT(result[jss::changes].size() == 0);
+            }
+            catch (std::overflow_error const&)
+            {
+                fail("Overflowing book-change rate shouldn't throw");
+            }
+        };
+
+        test(std::unordered_set>{});
+        test(std::unordered_set>{featureMPTokensV2});
+    }
+
+    // Build a ledger whose transactions are OfferCreates carrying the supplied
+    // consumed-offer deltas, then run computeBookChanges over it. Each pair is
+    // (TakerGets, TakerPays) fully consumed off a resting offer.
+    static json::Value
+    bookChangesFor(jtx::Env& env, std::vector> const& crossings)
+    {
+        auto ledger = std::make_shared(
+            2,
+            NetClock::time_point{},
+            Rules{std::unordered_set>{featureMPTokensV2}},
+            env.current()->fees(),
+            env.app().getNodeFamily());
+
+        std::uint32_t seq = 0;
+        for (auto const& [gets, pays] : crossings)
+        {
+            ++seq;
+
+            STObject finalFields = STObject::makeInnerObject(sfFinalFields);
+            finalFields.setFieldU32(sfSequence, seq);
+            finalFields.setFieldAmount(sfTakerGets, STAmount{gets.asset()});
+            finalFields.setFieldAmount(sfTakerPays, STAmount{pays.asset()});
+
+            STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
+            previousFields.setFieldU32(sfSequence, seq);
+            previousFields.setFieldAmount(sfTakerGets, gets);
+            previousFields.setFieldAmount(sfTakerPays, pays);
+
+            STObject modifiedOffer{sfModifiedNode};
+            modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
+            modifiedOffer.setFieldObject(sfFinalFields, finalFields);
+            modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
+
+            STArray affectedNodes{sfAffectedNodes};
+            affectedNodes.pushBack(std::move(modifiedOffer));
+
+            auto metadata = std::make_shared(sfTransactionMetaData);
+            metadata->setFieldArray(sfAffectedNodes, affectedNodes);
+
+            STTx const tx{ttOFFER_CREATE, [](STObject&) {}};
+
+            auto txSerializer = std::make_shared();
+            tx.add(*txSerializer);
+
+            auto metaSerializer = std::make_shared();
+            metadata->add(*metaSerializer);
+
+            ledger->rawTxInsert(uint256{seq}, txSerializer, metaSerializer);
+        }
+
+        ledger->setImmutable();
+        ledger->setValidated();
+
+        return xrpl::rpc::computeBookChanges(std::static_pointer_cast(ledger));
+    }
+
+    void
+    testSkipsOverflowingVolume()
+    {
+        testcase("book_changes skips overflowing volume");
+        using namespace jtx;
+
+        Env env(*this);
+
+        // Two crossings in one book, accumulated by the `+=` in the tally's
+        // else branch. The rate is 1 either way, so the divide() guard is not
+        // what is under test here.
+        //
+        // MPT: kMaxMpTokenAmount is INT64_MAX, so two halves sum past it. The
+        // add is a raw int64 add, which wraps to a negative amount rather than
+        // throwing, and canonicalize() only bounds the magnitude -- so before
+        // the fix this reported a negative volume.
+        {
+            auto const mptA = MPT{Account{"gw"}.id(), 1};
+            auto const mptB = MPT{Account{"gw"}.id(), 2};
+            auto const half = 5'000'000'000'000'000'000ull;  // 2 * half > INT64_MAX
+
+            auto const result =
+                bookChangesFor(env, {{mptA(half), mptB(half)}, {mptA(half), mptB(half)}});
+
+            BEAST_EXPECT(result[jss::type] == "bookChanges");
+            if (BEAST_EXPECT(result[jss::changes].size() == 1))
+            {
+                auto const& change = result[jss::changes][0u];
+                // The second crossing is dropped, so the first one's volume
+                // stands. Above all it must not be negative.
+                BEAST_EXPECT(change[jss::volume_a].asString() == std::to_string(half));
+                BEAST_EXPECT(change[jss::volume_b].asString() == std::to_string(half));
+            }
+        }
+
+        // IOU: the addition throws std::overflow_error once the summed
+        // exponent passes IOUAmount::kMaxExponent. Before the fix that
+        // escaped computeBookChanges entirely.
+        {
+            Account const gwA{"gwA"};
+            Account const gwB{"gwB"};
+            // Mantissa in range, exponent at the maximum: two of these sum to
+            // one exponent past it.
+            STAmount const bigA{gwA["USD"].issue(), UINT64_C(9'000'000'000'000'000), 80};
+            STAmount const bigB{gwB["EUR"].issue(), UINT64_C(9'000'000'000'000'000), 80};
+
+            try
+            {
+                auto const result = bookChangesFor(env, {{bigA, bigB}, {bigA, bigB}});
+                BEAST_EXPECT(result[jss::type] == "bookChanges");
+                BEAST_EXPECT(result[jss::changes].size() == 1);
+            }
+            catch (std::overflow_error const&)
+            {
+                fail("Overflowing book-change volume shouldn't throw");
+            }
+        }
+    }
+
     void
     run() override
     {
@@ -122,6 +332,8 @@ public:
         testLedgerInputDefaultBehavior();
 
         testDomainOffer();
+        testSkipsOverflowingRate();
+        testSkipsOverflowingVolume();
         // Note: Other aspects of the book_changes rpc are fertile grounds
         // for unit-testing purposes. It can be included in future work
     }
diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp
index af93108ff2..e7c5dd4a80 100644
--- a/src/test/rpc/LedgerRPC_test.cpp
+++ b/src/test/rpc/LedgerRPC_test.cpp
@@ -5,6 +5,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -20,6 +21,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
@@ -258,6 +260,102 @@ class LedgerRPC_test : public beast::unit_test::Suite
         BEAST_EXPECT(jrr[jss::ledger][jss::accountState].size() == 3u);
     }
 
+    void
+    testLedgerOwnerFundsMPTOffer()
+    {
+        testcase("Ledger owner_funds with MPT offer");
+        using namespace test::jtx;
+
+        Env env{*this};
+        Account const gw{"gateway"};
+        Account const alice{"alice"};
+        auto const usd = gw["USD"];
+
+        env.fund(XRP(10'000), gw, alice);
+        env.close();
+        env.trust(usd(1'000), alice);
+        env(pay(gw, alice, usd(100)));
+        MPTTester mpt(
+            {.env = env,
+             .issuer = gw,
+             .holders = {alice},
+             .pay = 100,
+             .flags = tfMPTRequireAuth | kMptDexFlags,
+             .authHolder = true,
+             .close = false});
+        MPT const mptAsset = mpt;
+        env.close();
+
+        env(noop(alice));
+        // These offers differ only by TakerGets asset type. Omitting
+        // owner_funds serializes the tx JSON without computing offer balances;
+        // owner_funds=true asks LedgerToJson to compute accountFunds(TakerGets)
+        // for both offers, which is where IOU and MPT used to diverge.
+        env(offer(alice, XRP(10), usd(10)));
+        env(offer(alice, XRP(10), mptAsset(10)));
+        // The MPT offer was created while authorized. Unauthorizing in the
+        // same ledger makes owner_funds depend on AuthHandling::IgnoreAuth.
+        mpt.authorize({.account = gw, .holder = alice, .flags = tfMPTUnauthorize});
+        env(noop(alice));
+        env.close();
+
+        auto const ledgerHash = to_string(env.closed()->header().hash);
+
+        auto const getTransactions = [&](bool includeOwnerFunds) {
+            json::Value params;
+            params[jss::ledger_hash] = ledgerHash;
+            params[jss::transactions] = true;
+            params[jss::expand] = true;
+            // The baseline omits owner_funds, which the RPC treats as false.
+            // Setting it true requests the same ledger, but asks the ledger
+            // serializer to add owner_funds to offer transactions in that
+            // ledger's transaction array.
+            if (includeOwnerFunds)
+                params[jss::owner_funds] = true;
+
+            auto const result = env.rpc("json", "ledger", to_string(params))[jss::result];
+            BEAST_EXPECT(!result.isMember(jss::error));
+            BEAST_EXPECT(result[jss::ledger][jss::transactions].isArray());
+            return result[jss::ledger][jss::transactions];
+        };
+
+        auto const findOffer = [](json::Value const& txs, bool mpt) -> json::Value const* {
+            for (auto i = 0u; i < txs.size(); ++i)
+            {
+                auto const& tx = txs[i].isMember(jss::tx_json) ? txs[i][jss::tx_json] : txs[i];
+                if (tx[jss::TransactionType] == jss::OfferCreate &&
+                    tx[jss::TakerGets].isMember(jss::mpt_issuance_id) == mpt)
+                {
+                    return &txs[i];
+                }
+            }
+            return nullptr;
+        };
+
+        // Baseline: same ledger request without owner_funds fields.
+        auto const baseline = getTransactions(false);
+        BEAST_EXPECT(baseline.size() == 5u);
+        BEAST_EXPECT(findOffer(baseline, false) != nullptr);
+        BEAST_EXPECT(findOffer(baseline, true) != nullptr);
+
+        // Same ledger request with owner_funds added to eligible offer txs.
+        auto const withOwnerFunds = getTransactions(true);
+        // Requesting owner_funds must not change which ledger transactions are
+        // returned, even when one offer's TakerGets is MPT.
+        BEAST_EXPECT(withOwnerFunds.size() == baseline.size());
+
+        // The IOU offer is the control case for expected owner_funds output.
+        auto const* iouOfferTx = findOffer(withOwnerFunds, false);
+        if (BEAST_EXPECT(iouOfferTx != nullptr))
+            BEAST_EXPECT((*iouOfferTx)[jss::owner_funds] == "100");
+
+        // MPT owner_funds should match the IOU behavior, even though Alice is
+        // unauthorized by the ledger snapshot used for serialization.
+        auto const* mptOfferTx = findOffer(withOwnerFunds, true);
+        if (BEAST_EXPECT(mptOfferTx != nullptr))
+            BEAST_EXPECT((*mptOfferTx)[jss::owner_funds] == "100");
+    }
+
     /**
      * @brief ledger RPC requests as a way to drive
      * input options to lookupLedger. The point of this test is
@@ -719,6 +817,7 @@ public:
         testLedgerFull();
         testLedgerFullNonAdmin();
         testLedgerAccounts();
+        testLedgerOwnerFundsMPTOffer();
         testLookupLedger();
         testNoQueue();
         testQueue();
diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp
index 32f93eb1f7..8e958b40d4 100644
--- a/src/tests/libxrpl/basics/Number.cpp
+++ b/src/tests/libxrpl/basics/Number.cpp
@@ -1,5 +1,6 @@
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -16,6 +17,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -183,6 +185,17 @@ TEST(NumberTest, limits)
         }
         EXPECT_TRUE(caught);
 
+        try
+        {
+            Number{1, 2000000, Number::Normalized{}};
+            ADD_FAILURE();
+        }
+        catch (std::overflow_error const& e)
+        {
+            std::string const expected = "Number::normalize 2";
+            EXPECT_EQ(e.what(), expected) << e.what();
+        }
+
         if (scale == MantissaRange::MantissaScale::Large330)
         {
             // Normalization with the other scales, including the older large mantissa scales, will
@@ -406,6 +419,158 @@ TEST(NumberTest, add)
     }
 }
 
+TEST(NumberTest, add_sub_extreme_exponents)
+{
+    for (auto const mantissaScale : MantissaRange::getAllScales())
+    {
+        NumberMantissaScaleGuard const sg(mantissaScale);
+
+        auto const scale = Number::getMantissaScale();
+
+        EXPECT_EQ(Number::getround(), Number::RoundingMode::ToNearest)
+            << to_string(Number::getround());
+
+        // Special cases: Exponents at each end of the allowable range
+        for (auto const round :
+             {Number::RoundingMode::ToNearest,
+              Number::RoundingMode::TowardsZero,
+              Number::RoundingMode::Downward,
+              Number::RoundingMode::Upward})
+        {
+            NumberRoundModeGuard const rg{round};
+
+            auto const bigMantissa = std::invoke([scale, round] {
+                auto m = Number::maxMantissa();
+                if (scale != MantissaRange::MantissaScale::Small)
+                {
+                    // At the large scales, the maxMantissa is not representable, so we need to
+                    // shrink it down to a representable value.
+                    m /= 10;
+                }
+                if (round == Number::RoundingMode::Upward)
+                {
+                    // Rounding upward will overflow if the mantissa is at maxMantissa. Subtract an
+                    // arbitrary small value to keep the mantissa near the limit, but with a
+                    // little room to grow. 67 has no meaning, except that it's, you know,
+                    // six seven.
+                    m -= 67;
+                }
+                return m;
+            });
+            auto const params = {
+                std::make_pair(Number::minMantissa(), 0),
+                // At the large scales, the maxMantissa is not representable, so we need to shrink
+                // it down to a representable value. Rounding upward will overflow if the mantissa
+                // is right at the all nines value. To keep things a little simpler, do those
+                // modifications unconditionally.
+                std::make_pair(bigMantissa, 1),
+            };
+            for (auto const& [mantissa, exponentOffset] : params)
+            {
+                auto const x = Number{mantissa, Number::kMaxExponent, Number::Normalized{}};
+                auto const y =
+                    Number{mantissa, Number::kMinExponent + exponentOffset, Number::Normalized{}};
+
+                std::ostringstream detail;
+                detail << "Scale: " << to_string(scale) << ", round: " << to_string(round)
+                       << ", x: " << x << ", y: " << y;
+
+                EXPECT_EQ(x.mantissa(), mantissa);
+                EXPECT_EQ(x.exponent(), Number::kMaxExponent);
+                EXPECT_NE(x, beast::kZero);
+                EXPECT_EQ(y.mantissa(), mantissa);
+                EXPECT_EQ(y.exponent(), Number::kMinExponent + exponentOffset);
+                EXPECT_NE(y, beast::kZero);
+
+                {
+                    // x + y
+                    auto const result = x + y;
+
+                    if (round == Number::RoundingMode::Upward)
+                    {
+                        // Rounding upward will take that little x-bit and round result up to the
+                        // next representable value.
+                        EXPECT_NE(result, x);
+                        EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()}));
+                    }
+                    else
+                    {
+                        EXPECT_EQ(result, x);
+                    }
+                }
+                {
+                    // x - y
+                    auto const result = x - y;
+
+                    switch (round)
+                    {
+                        case Number::RoundingMode::TowardsZero:
+                            if (scale < MantissaRange::MantissaScale::Large330)
+                            {
+                                // Rounding TowardsZero was broken before Large330.
+                                EXPECT_EQ(result, x) << detail.str();
+                                break;
+                            }
+                            [[fallthrough]];
+                        case Number::RoundingMode::Downward:
+                            // Rounding downward (or toward zero in Large330) will take that little
+                            // x-bit and round result down to the next representable value.
+                            EXPECT_NE(result, x) << detail.str();
+                            EXPECT_EQ(result, (Number{x.mantissa() - 1, x.exponent()}))
+                                << detail.str();
+                            break;
+                        default:
+                            // Rounding up and toNearest rounds back to the original value
+                            EXPECT_EQ(result, x) << detail.str();
+                    }
+                }
+                {
+                    // y + x
+                    auto const result = y + x;
+
+                    if (round == Number::RoundingMode::Upward)
+                    {
+                        // Rounding upward will take that little x-bit and round result up to the
+                        // next representable value.
+                        EXPECT_NE(result, x);
+                        EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()}));
+                    }
+                    else
+                    {
+                        EXPECT_EQ(result, x);
+                    }
+                }
+                {
+                    // y - x
+                    auto const result = y - x;
+
+                    switch (round)
+                    {
+                        case Number::RoundingMode::TowardsZero:
+                            if (scale < MantissaRange::MantissaScale::Large330)
+                            {
+                                // Rounding TowardsZero was broken before Large330.
+                                EXPECT_EQ(result, -x) << detail.str();
+                                break;
+                            }
+                            [[fallthrough]];
+                        case Number::RoundingMode::Upward:
+                            // Rounding upward (or toward zero in Large330) will take that little
+                            // x-bit and round result up to the next representable negative value.
+                            EXPECT_NE(result, -x) << detail.str();
+                            EXPECT_EQ(result, (Number{-x.mantissa() + 1, x.exponent()}))
+                                << detail.str();
+                            break;
+                        default:
+                            // Rounding up and toNearest rounds back to the original value
+                            EXPECT_EQ(result, -x) << detail.str();
+                    }
+                }
+            }
+        }
+    }
+}
+
 TEST(NumberTest, sub)
 {
     for (auto const mantissaScale : MantissaRange::getAllScales())
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
index 5b0a8c9146..043ab0a252 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     LoanBrokerCoverWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     LoanBrokerCoverWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
index 4067a6551d..518957d47b 100644
--- a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
+++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp
@@ -33,6 +33,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     VaultWithdrawBuilder builder{
         accountValue,
@@ -45,6 +46,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
     // Set optional fields
     builder.setDestination(destinationValue);
     builder.setDestinationTag(destinationTagValue);
+    builder.setCredentialIDs(credentialIDsValue);
 
     auto tx = builder.build(publicKey, secretKey);
 
@@ -90,6 +92,14 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip)
         EXPECT_TRUE(tx.hasDestinationTag());
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = tx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+        EXPECT_TRUE(tx.hasCredentialIDs());
+    }
+
 }
 
 // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -110,6 +120,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
     auto const amountValue = canonical_AMOUNT();
     auto const destinationValue = canonical_ACCOUNT();
     auto const destinationTagValue = canonical_UINT32();
+    auto const credentialIDsValue = canonical_VECTOR256();
 
     // Build an initial transaction
     VaultWithdrawBuilder initialBuilder{
@@ -122,6 +133,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
 
     initialBuilder.setDestination(destinationValue);
     initialBuilder.setDestinationTag(destinationTagValue);
+    initialBuilder.setCredentialIDs(credentialIDsValue);
 
     auto initialTx = initialBuilder.build(publicKey, secretKey);
 
@@ -166,6 +178,13 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip)
         expectEqualField(expected, *actualOpt, "sfDestinationTag");
     }
 
+    {
+        auto const& expected = credentialIDsValue;
+        auto const actualOpt = rebuiltTx.getCredentialIDs();
+        ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present";
+        expectEqualField(expected, *actualOpt, "sfCredentialIDs");
+    }
+
 }
 
 // 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -229,6 +248,8 @@ TEST(TransactionsVaultWithdrawTests, OptionalFieldsReturnNullopt)
     EXPECT_FALSE(tx.getDestination().has_value());
     EXPECT_FALSE(tx.hasDestinationTag());
     EXPECT_FALSE(tx.getDestinationTag().has_value());
+    EXPECT_FALSE(tx.hasCredentialIDs());
+    EXPECT_FALSE(tx.getCredentialIDs().has_value());
 }
 
 }
diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp
index c84cdf504f..7f7d6ffba2 100644
--- a/src/tests/libxrpl/shamap/SHAMap.cpp
+++ b/src/tests/libxrpl/shamap/SHAMap.cpp
@@ -3,19 +3,23 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -346,4 +350,149 @@ TEST_F(SHAMapPathProof, verify_proof_path)
     EXPECT_FALSE(map.verifyProofPath(rootHash, key, badPath));
 }
 
+// A legitimate proof path for two keys sharing all 63 leading nibbles is 65 elements: inner nodes
+// at depths 0..63 plus the leaf at depth 64. This pins that the 65 bound is real, so the fix for
+// the forged-path case below must not simply tighten the length limit.
+TEST_F(SHAMapPathProof, legitimate_deep_path_is_sixty_five_elements)
+{
+    tests::TestNodeFamily f{j_};
+    SHAMap map{SHAMapType::FREE, f};
+    map.setUnbacked();
+
+    auto const kA = uint256{std::string_view{std::string(63, 'a') + "1"}};
+    auto const kB = uint256{std::string_view{std::string(63, 'a') + "2"}};
+
+    for (auto const& k : {kA, kB})
+    {
+        Buffer vuc{32};
+        std::fill_n(vuc.data(), vuc.size(), std::uint8_t{1});
+        ASSERT_TRUE(map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, std::move(vuc))));
+    }
+    map.invariants();
+
+    auto const pathA = map.getProofPath(kA);
+    ASSERT_TRUE(pathA.has_value());
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    EXPECT_EQ(pathA->size(), 65u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kA, *pathA));
+    // NOLINTEND(bugprone-unchecked-optional-access)
+
+    auto const pathB = map.getProofPath(kB);
+    ASSERT_TRUE(pathB.has_value());
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    EXPECT_EQ(pathB->size(), 65u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kB, *pathB));
+    // NOLINTEND(bugprone-unchecked-optional-access)
+}
+
+// A forged path of 65 hash-chained inner nodes reaches depth kLeafDepth, where only the leaf
+// terminating the path may sit. Such a path must be rejected.
+TEST_F(SHAMapPathProof, all_inner_path_at_leaf_depth_is_rejected)
+{
+    // An arbitrary well-formed key; the test does not care about its specific value.
+    constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8");
+
+    // Build upwards from the deepest node so each parent's selected branch carries its child's hash
+    // and the hash chain validates at every level.
+    std::vector path;
+    SHAMapHash childHash{uint256{1}};
+
+    for (auto depth = SHAMap::kLeafDepth + 1u; depth-- > 0;)
+    {
+        auto const id = SHAMapNodeID::createID(std::min(depth, SHAMap::kLeafDepth - 1u), kTestKey);
+        auto const branch = selectBranch(id, kTestKey);
+
+        Serializer s;
+        for (auto i = 0u; i < SHAMap::kBranchFactor; ++i)
+            s.addBitString(i == branch ? childHash.asUInt256() : uint256{});
+        s.add8(kWireTypeInner);
+        path.push_back(s.getData());
+
+        auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.back()));
+        ASSERT_TRUE(node);
+        node->updateHash();
+        childHash = node->getHash();
+    }
+
+    ASSERT_EQ(path.size(), 65u);
+    EXPECT_FALSE(SHAMap::verifyProofPath(childHash.asUInt256(), kTestKey, path));
+}
+
+/**
+ * Wrap a leaf blob in a forged root inner node whose branch for `key` carries that leaf's hash.
+ *
+ * The resulting two-element path hash-chains for `key` no matter which leaf sits at the bottom,
+ * which is exactly the substitution a peer could attempt.
+ *
+ * @param leafBlob the wire form of the leaf to place at the bottom of the path.
+ * @param key the key the forged path claims to prove.
+ * @return the path (deepest element first) and the forged root hash, or an empty path if the leaf
+ *         blob does not parse.
+ */
+static std::pair, uint256>
+forgeRootOverLeaf(Blob const& leafBlob, uint256 const& key)
+{
+    auto leaf = SHAMapTreeNode::makeFromWire(makeSlice(leafBlob));
+    if (!leaf || !leaf->isLeaf())
+        return {};
+    leaf->updateHash();
+
+    auto const branch = selectBranch(SHAMapNodeID::createID(0, key), key);
+    Serializer s;
+    for (auto i = 0u; i < SHAMap::kBranchFactor; ++i)
+        s.addBitString(i == branch ? leaf->getHash().asUInt256() : uint256{});
+    s.add8(kWireTypeInner);
+
+    auto root = SHAMapTreeNode::makeFromWire(makeSlice(s.peekData()));
+    if (!root)
+        return {};
+    root->updateHash();
+
+    return {std::vector{leafBlob, s.getData()}, root->getHash().asUInt256()};
+}
+
+// The hash chain above a leaf proves nothing about which key that leaf holds, so a peer can graft a
+// genuine leaf from elsewhere in the map onto a path forged for another key. Comparing the terminal
+// leaf's own key against the key being proved is what rejects it.
+TEST_F(SHAMapPathProof, substituted_leaf_for_other_key_is_rejected)
+{
+    tests::TestNodeFamily f{j_};
+    SHAMap map{SHAMapType::FREE, f};
+    map.setUnbacked();
+
+    // Two arbitrary keys differing in their first nibble, so each leaf hangs off the root directly.
+    constexpr uint256 kKey("1c8cec8e5e9b0e5e0e0f5b3e2c9f7a1d6b4e8c2a0d7f3b9e5c1a8d4f2b6e0c93");
+    constexpr uint256 kOtherKey("e3f1a7d5b9c2e8f406a1d3b5c7e9f2a4d6b8c0e2f4a6d8b0c2e4f6a8d0b2c4e6");
+
+    for (auto const& k : {kKey, kOtherKey})
+    {
+        ASSERT_TRUE(map.addItem(
+            SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()})));
+    }
+    map.invariants();
+
+    auto const ownPath = map.getProofPath(kKey);
+    auto const otherPath = map.getProofPath(kOtherKey);
+    ASSERT_TRUE(ownPath.has_value());
+    ASSERT_TRUE(otherPath.has_value());
+
+    // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above
+    // The genuine leaf blobs, deepest element first.
+    auto const& ownLeaf = ownPath->front();
+    auto const& otherLeaf = otherPath->front();
+    // NOLINTEND(bugprone-unchecked-optional-access)
+
+    // Control: the forged root is accepted when the leaf below it really is kKey's leaf, so the
+    // rejection below can only come from the leaf key comparison.
+    auto const [goodPath, goodRoot] = forgeRootOverLeaf(ownLeaf, kKey);
+    ASSERT_EQ(goodPath.size(), 2u);
+    EXPECT_TRUE(SHAMap::verifyProofPath(goodRoot, kKey, goodPath));
+
+    // Same forged root, but kOtherKey's leaf substituted at the bottom: the hash chain still
+    // validates, yet the path does not prove anything about kKey.
+    auto const [badPath, badRoot] = forgeRootOverLeaf(otherLeaf, kKey);
+    ASSERT_EQ(badPath.size(), 2u);
+    EXPECT_FALSE(SHAMap::verifyProofPath(badRoot, kKey, badPath));
+}
+
 }  // namespace xrpl::tests
diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h
index 32163fd57b..140b12fa59 100644
--- a/src/xrpld/app/ledger/LedgerMaster.h
+++ b/src/xrpld/app/ledger/LedgerMaster.h
@@ -123,7 +123,10 @@ public:
     failedSave(std::uint32_t seq, uint256 const& hash);
 
     std::string
-    getCompleteLedgers();
+    getCompleteLedgers() const;
+
+    std::size_t
+    missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const;
 
     /**
      * Apply held transactions to the open ledger
@@ -190,7 +193,7 @@ public:
     fixMismatch(ReadView const& ledger);
 
     bool
-    haveLedger(std::uint32_t seq);
+    haveLedger(std::uint32_t seq) const;
     void
     clearLedger(std::uint32_t seq);
     bool
@@ -348,7 +351,7 @@ private:
     // A set of transactions to replay during the next close
     std::unique_ptr replayData_;
 
-    std::recursive_mutex completeLock_;
+    std::recursive_mutex mutable completeLock_;
     RangeSet completeLedgers_;
 
     // Publish thread is running.
diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
index 83d76bcd2a..878b257b69 100644
--- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp
@@ -57,6 +57,7 @@
 #include 
 #include 
 
+#include 
 #include 
 
 #include 
@@ -492,7 +493,7 @@ LedgerMaster::setBuildingLedger(LedgerIndex i)
 }
 
 bool
-LedgerMaster::haveLedger(std::uint32_t seq)
+LedgerMaster::haveLedger(std::uint32_t seq) const
 {
     std::scoped_lock const sl(completeLock_);
     return boost::icl::contains(completeLedgers_, seq);
@@ -1576,12 +1577,36 @@ LedgerMaster::getPublishedLedger()
 }
 
 std::string
-LedgerMaster::getCompleteLedgers()
+LedgerMaster::getCompleteLedgers() const
 {
     std::scoped_lock const sl(completeLock_);
     return to_string(completeLedgers_);
 }
 
+std::size_t
+LedgerMaster::missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const
+{
+    if (first > last)
+    {
+        // In expected usage, this will never happen because "first" is generally initialized to
+        // "last", "last" is guaranteed to grow monotonically, and "first" either doesn't change
+        // or grows more slowly.
+        // LCOV_EXCL_START
+        UNREACHABLE("xrpl::LedgerMaster::missingFromCompleteLedgerRange : invalid parameters");
+        return 0;
+        // LCOV_EXCL_STOP
+    }
+
+    RangeSet const target{range(first, last)};
+
+    auto const missing = [&target, this] {
+        std::scoped_lock const sl(completeLock_);
+        return target - completeLedgers_;
+    }();
+
+    return boost::icl::size(missing);
+}
+
 std::optional
 LedgerMaster::getCloseTimeBySeq(LedgerIndex ledgerIndex)
 {
diff --git a/src/xrpld/app/ledger/detail/LedgerToJson.cpp b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
index 9d3820e9f7..921c640f06 100644
--- a/src/xrpld/app/ledger/detail/LedgerToJson.cpp
+++ b/src/xrpld/app/ledger/detail/LedgerToJson.cpp
@@ -208,6 +208,7 @@ fillJsonTx(
                 account,
                 amount,
                 FreezeHandling::IgnoreFreeze,
+                AuthHandling::IgnoreAuth,
                 beast::Journal{beast::Journal::getNullSink()});
             txJson[jss::owner_funds] = ownerFunds.getText();
         }
diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp
index f467473e54..2dd1f83dd5 100644
--- a/src/xrpld/app/misc/NetworkOPs.cpp
+++ b/src/xrpld/app/misc/NetworkOPs.cpp
@@ -77,10 +77,11 @@
 #include 
 #include 
 #include 
-#include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -88,8 +89,11 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -4881,8 +4885,7 @@ NetworkOPsImp::getBookPage(
 
     ReadView const& view = *lpLedger;
 
-    bool const bGlobalFreeze =
-        isGlobalFrozen(view, book.out.getIssuer()) || isGlobalFrozen(view, book.in.getIssuer());
+    bool const bGlobalFreeze = isGlobalFrozen(view, book.out) || isGlobalFrozen(view, book.in);
 
     bool bDone = false;
     bool bDirectAdvance = true;
@@ -4892,7 +4895,7 @@ NetworkOPsImp::getBookPage(
     unsigned int uBookEntry = 0;
     STAmount saDirRate;
 
-    auto const rate = transferRate(view, book.out.getIssuer());
+    auto const rate = transferRate(view, book.out);
     auto viewJ = registry_.get().getJournal("View");
 
     while (!bDone && iLimit-- > 0)
@@ -4941,12 +4944,37 @@ NetworkOPsImp::getBookPage(
                 auto const& saTakerPays = sleOffer->getFieldAmount(sfTakerPays);
                 STAmount saOwnerFunds;
                 bool firstOwnerOffer(true);
+                auto foundBalance = [&]() {
+                    auto umBalanceEntry = umBalance.find(uOfferOwnerID);
+                    if (umBalanceEntry == umBalance.end())
+                        return false;
+
+                    // Found in running balance table.
+                    saOwnerFunds = umBalanceEntry->second;
+                    firstOwnerOffer = false;
+                    return true;
+                };
 
                 if (book.out.getIssuer() == uOfferOwnerID)
                 {
-                    // If an offer is selling issuer's own IOUs, it is fully
-                    // funded.
-                    saOwnerFunds = saTakerGets;
+                    book.out.visit(
+                        [&](Issue const&) {
+                            // If an offer is selling issuer's own IOUs, it is
+                            // fully funded.
+                            saOwnerFunds = saTakerGets;
+                        },
+                        [&](MPTIssue const& issue) {
+                            // MPT issuers have bounded self-issuance. Use the
+                            // running balance table so multiple issuer-owned
+                            // offers share the same remaining issuance
+                            // headroom.
+                            if (!foundBalance())
+                            {
+                                // Did not find balance in table.
+
+                                saOwnerFunds = issuerFundsToSelfIssue(view, issue);
+                            }
+                        });
                 }
                 else if (bGlobalFreeze)
                 {
@@ -4956,15 +4984,7 @@ NetworkOPsImp::getBookPage(
                 }
                 else
                 {
-                    auto umBalanceEntry = umBalance.find(uOfferOwnerID);
-                    if (umBalanceEntry != umBalance.end())
-                    {
-                        // Found in running balance table.
-
-                        saOwnerFunds = umBalanceEntry->second;
-                        firstOwnerOffer = false;
-                    }
-                    else
+                    if (!foundBalance())
                     {
                         // Did not find balance in table.
 
@@ -5000,7 +5020,28 @@ NetworkOPsImp::getBookPage(
                 {
                     // Need to charge a transfer fee to offer owner.
                     offerRate = rate;
-                    saOwnerFundsLimit = divide(saOwnerFunds, offerRate);
+                    // Why MPT does not use divide(): divide() is built for an
+                    // IOU mantissa, which is always normalized into
+                    // [1e15, 1e16]. An MPT mantissa is the raw int64 balance,
+                    // and divide() scales the numerator by 1e17, so a balance
+                    // over ~1.8e17 leaves uint64 range and throws -- failing
+                    // the whole RPC rather than this one offer.
+                    //
+                    // Why mulRatio is safe: it evaluates in 128 bits, and here
+                    // it cannot overflow either. offerRate is
+                    // 1e9 + 10'000 * TransferFee, so this branch runs only with
+                    // offerRate > kParityRate, making the quotient smaller than
+                    // saOwnerFunds. Rounded down, so reported liquidity is
+                    // never overstated.
+                    saOwnerFundsLimit = saOwnerFunds.holds()
+                        ? toSTAmount(
+                              mulRatio(
+                                  saOwnerFunds.mpt(),
+                                  kParityRate.value,
+                                  offerRate.value,
+                                  /*roundUp*/ false),
+                              saOwnerFunds.asset())
+                        : divide(saOwnerFunds, offerRate);
                 }
 
                 if (saOwnerFundsLimit >= saTakerGets)
@@ -5057,6 +5098,8 @@ NetworkOPsImp::getBookPage(
 
 // This is the new code that uses the book iterators
 // It has temporarily been disabled
+// If this path is re-enabled, add MPT support mirroring the functional
+// getBookPage() implementation above.
 
 void
 NetworkOPsImp::getBookPage(
diff --git a/src/xrpld/app/misc/SHAMapStore.h b/src/xrpld/app/misc/SHAMapStore.h
index eeb04df53d..9d50f988b5 100644
--- a/src/xrpld/app/misc/SHAMapStore.h
+++ b/src/xrpld/app/misc/SHAMapStore.h
@@ -8,6 +8,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -34,8 +35,8 @@ public:
     virtual void
     start() = 0;
 
-    virtual void
-    rendezvous() const = 0;
+    [[nodiscard]] virtual bool
+    rendezvous(std::optional const& timeout = {}) const = 0;
 
     virtual void
     stop() = 0;
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp
index 9e3f1ac52b..e19df597a2 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.cpp
+++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp
@@ -9,6 +9,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -30,6 +31,8 @@
 #include 
 
 #include 
+#include 
+#include 
 #include 
 #include 
 #include 
@@ -127,22 +130,6 @@ SHAMapStoreImp::SHAMapStoreImp(
 
     if (deleteInterval_ != 0u)
     {
-        // Configuration that affects the behavior of online delete
-        getIfExists(section, Keys::kDeleteBatch, deleteBatch_);
-        std::uint32_t temp = 0;
-        if (getIfExists(section, Keys::kBackOffMilliseconds, temp) ||
-            // Included for backward compatibility with an undocumented setting
-            getIfExists(section, Keys::kBackOff, temp))
-        {
-            backOff_ = std::chrono::milliseconds{temp};
-        }
-        if (getIfExists(section, Keys::kAgeThresholdSeconds, temp))
-            ageThreshold_ = std::chrono::seconds{temp};
-        if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp))
-            recoveryWaitTime_ = std::chrono::seconds{temp};
-
-        getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_);
-
         auto const minInterval =
             config.standalone() ? kMinimumDeletionIntervalSa : kMinimumDeletionInterval;
         if (deleteInterval_ < minInterval)
@@ -159,6 +146,40 @@ SHAMapStoreImp::SHAMapStoreImp(
                 std::to_string(config.ledgerHistory) + ")");
         }
 
+        // Configuration that affects the behavior of online delete
+        getIfExists(section, Keys::kDeleteBatch, deleteBatch_);
+        std::uint32_t temp = 0;
+        if (getIfExists(section, Keys::kBackOffMilliseconds, temp) ||
+            // Included for backward compatibility with an undocumented setting
+            getIfExists(section, Keys::kBackOff, temp))
+        {
+            backOff_ = std::chrono::milliseconds{temp};
+        }
+        if (getIfExists(section, Keys::kAgeThresholdSeconds, temp))
+            ageThreshold_ = std::chrono::seconds{temp};
+        if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp))
+            recoveryWaitTime_ = std::chrono::seconds{temp};
+        if (recoveryWaitTime_ < std::chrono::seconds{1})
+            Throw("recovery_wait_seconds must be at least 1 second");
+
+        getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_);
+
+        if (getIfExists(section, Keys::kMaxWaitingLedgers, temp))
+        {
+            maxWaitingLedgers_ = temp;
+        }
+        else
+        {
+            maxWaitingLedgers_ = deleteInterval_;
+        }
+
+        auto const minWaiting = minInterval / 4;
+        if (maxWaitingLedgers_ < minWaiting)
+        {
+            Throw(
+                "max_waiting_ledgers must be at least " + std::to_string(minWaiting));
+        }
+
         stateDb_.init(config, dbName_);
         dbPaths();
     }
@@ -235,14 +256,22 @@ SHAMapStoreImp::onLedgerClosed(std::shared_ptr const& ledger)
     cond_.notify_one();
 }
 
-void
-SHAMapStoreImp::rendezvous() const
+[[nodiscard]]
+bool
+SHAMapStoreImp::rendezvous(std::optional const& timeout) const
 {
     if (!working_)
-        return;
+        return true;
+
+    auto notWorking = [&] { return !working_; };
 
     std::unique_lock lock(mutex_);
-    rendezvous_.wait(lock, [&] { return !working_; });
+    if (timeout)
+    {
+        return rendezvous_.wait_for(lock, *timeout, notWorking);
+    }
+    rendezvous_.wait(lock, notWorking);
+    return true;
 }
 
 int
@@ -275,7 +304,7 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node)
     }
     if ((++nodeCount % checkHealthInterval_) == 0u)
     {
-        if (healthWait() == HealthResult::Stopping)
+        if (healthWait() != HealthResult::KeepGoing)
             return false;
     }
 
@@ -326,9 +355,35 @@ SHAMapStoreImp::run()
             stateDb_.setLastRotated(lastRotated);
         }
 
+        // We're starting a new cycle, so reset back to the default.
+        lastSuccessfulHealthCheck_ = 0;
+
         bool const readyToRotate = validatedSeq >= lastRotated + deleteInterval_ &&
             canDelete_ >= lastRotated - 1 && healthWait() == HealthResult::KeepGoing;
 
+        {
+            // Note that this is set after the healthWait() check, so that we
+            // don't start the rotation until the validated ledger is fully
+            // processed. It is not guaranteed to be done at this point. It also
+            // allows the testLedgerGaps unit test to work.
+            std::unique_lock lock(mutex_);
+            if (newLedger_)
+            {
+                // It is possible, though very unlikely outside of tests which manipulate internals,
+                // that healthWait() took so long that the validated ledger (newLedger_) has moved
+                // on from where we started. If that's the case, update lastGoodValidatedLedger_
+                // to that ledger's sequence number.
+                lastGoodValidatedLedger_ = newLedger_->header().seq;
+            }
+            else
+            {
+                lastGoodValidatedLedger_ = validatedSeq;
+            }
+            auto const l = lastGoodValidatedLedger_;
+            lock.unlock();
+            JLOG(journal_.trace()) << "run: Set lastGoodValidatedLedger_ to " << l;
+        }
+
         // will delete up to (not including) lastRotated
         if (readyToRotate)
         {
@@ -336,11 +391,19 @@ SHAMapStoreImp::run()
                                   << lastRotated << " deleteInterval " << deleteInterval_
                                   << " canDelete_ " << canDelete_ << " state "
                                   << app_.getOPs().strOperatingMode(false) << " age "
-                                  << ledgerMaster_->getValidatedLedgerAge().count() << 's';
+                                  << ledgerMaster_->getValidatedLedgerAge().count()
+                                  << "s. Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
 
             clearPrior(lastRotated);
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
 
             JLOG(journal_.debug()) << "copying ledger " << validatedSeq;
             std::uint64_t nodeCount = 0;
@@ -359,8 +422,15 @@ SHAMapStoreImp::run()
                 continue;
             }
 
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
             // Only log if we completed without a "health" abort
             JLOG(journal_.debug())
                 << "copied ledger " << validatedSeq << " nodecount " << nodeCount;
@@ -384,8 +454,15 @@ SHAMapStoreImp::run()
 
             JLOG(journal_.debug()) << "freshening caches";
             freshenCaches();
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
             // Only log if we completed without a "health" abort
             JLOG(journal_.debug()) << validatedSeq << " freshened caches";
 
@@ -394,8 +471,15 @@ SHAMapStoreImp::run()
             JLOG(journal_.debug()) << validatedSeq << " new backend " << newBackend->getName();
 
             clearCaches(validatedSeq);
-            if (healthWait() == HealthResult::Stopping)
-                return;
+            switch (healthWait())
+            {
+                case HealthResult::Stopping:
+                    return;
+                case HealthResult::Expired:
+                    continue;
+                case HealthResult::KeepGoing:
+                    break;
+            }
 
             lastRotated = validatedSeq;
 
@@ -411,7 +495,9 @@ SHAMapStoreImp::run()
                     clearCaches(validatedSeq);
                 });
 
-            JLOG(journal_.warn()) << "finished rotation " << validatedSeq;
+            JLOG(journal_.warn()) << "finished rotation. validatedSeq: " << validatedSeq
+                                  << ", lastRotated: " << lastRotated
+                                  << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
         }
     }
 }
@@ -559,7 +645,7 @@ SHAMapStoreImp::clearSql(
         min = *m;
     }
 
-    if (min > lastRotated || healthWait() == HealthResult::Stopping)
+    if (min > lastRotated || healthWait() != HealthResult::KeepGoing)
         return;
     if (min == lastRotated)
     {
@@ -572,18 +658,19 @@ SHAMapStoreImp::clearSql(
                            << lastRotated;
     while (min < lastRotated)
     {
+        // The very first sleep is, arguably wasted, but clearSql is called multiple times for
+        // different tables, so the time is amortized among all the operations. This results in
+        // a backoff in between each set of tables, too.
+        std::this_thread::sleep_for(backOff_);
+        if (healthWait() != HealthResult::KeepGoing)
+            return;
+
         min = std::min(lastRotated, min + deleteBatch_);
         JLOG(journal_.trace()) << "Begin: Delete up to " << deleteBatch_
                                << " rows with LedgerSeq < " << min << " from: " << tableName;
         deleteBeforeSeq(min);
         JLOG(journal_.trace()) << "End: Delete up to " << deleteBatch_ << " rows with LedgerSeq < "
                                << min << " from: " << tableName;
-        if (healthWait() == HealthResult::Stopping)
-            return;
-        if (min < lastRotated)
-            std::this_thread::sleep_for(backOff_);
-        if (healthWait() == HealthResult::Stopping)
-            return;
     }
     JLOG(journal_.debug()) << "finished deleting from: " << tableName;
 }
@@ -616,7 +703,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
     JLOG(journal_.trace()) << "Begin: Clear internal ledgers up to " << lastRotated;
     ledgerMaster_->clearPriorLedgers(lastRotated);
     JLOG(journal_.trace()) << "End: Clear internal ledgers up to " << lastRotated;
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     auto& db = app_.getRelationalDatabase();
@@ -626,7 +713,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "Ledgers",
         [&db]() -> std::optional { return db.getMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     if (!app_.config().useTxTables())
@@ -637,7 +724,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "Transactions",
         [&db]() -> std::optional { return db.getTransactionsMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteTransactionsBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 
     clearSql(
@@ -645,30 +732,136 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated)
         "AccountTransactions",
         [&db]() -> std::optional { return db.getAccountTransactionsMinLedgerSeq(); },
         [&db](LedgerIndex min) -> void { db.deleteAccountTransactionsBeforeLedgerSeq(min); });
-    if (healthWait() == HealthResult::Stopping)
+    if (healthWait() != HealthResult::KeepGoing)
         return;
 }
 
 SHAMapStoreImp::HealthResult
 SHAMapStoreImp::healthWait()
 {
-    auto age = ledgerMaster_->getValidatedLedgerAge();
-    OperatingMode mode = netOPs_->getOperatingMode();
-    std::unique_lock lock(mutex_);
-    while (!stop_ && (mode != OperatingMode::FULL || age > ageThreshold_))
-    {
-        lock.unlock();
-        JLOG(journal_.warn()) << "Waiting " << recoveryWaitTime_.count()
-                              << "s for node to stabilize. state: "
-                              << app_.getOPs().strOperatingMode(mode, false) << ". age "
-                              << age.count() << 's';
-        std::this_thread::sleep_for(recoveryWaitTime_);
+    // Gets the current status of the server from ledgerMaster_ and netOPs_. Must be called
+    // while mutex_ is unlocked to avoid unlikely, but possible, deadlock with ledgerMaster_'s
+    // completeLock_.
+    // Releasing the lock may mean that status will be slightly out of date when the lock is
+    // reacquired, but it's close enough. In a normal rotation, healthWait() is called frequently,
+    // so a false positive will be detected on the next call, and a false negative will be detected
+    // in the next loop iteration. Database rotation is important, but not timely, so an extra
+    // delay is fine.
+    auto readServerStatus = [this](
+                                LedgerIndex& index,
+                                bool& buildingIndex,
+                                std::chrono::seconds& age,
+                                OperatingMode& mode,
+                                std::size_t& numMissing,
+                                LedgerIndex const lowerBound,
+                                ScopeUnlock const&) {
+        index = ledgerMaster_->getValidLedgerIndex();
+        bool const haveIndex = ledgerMaster_->haveLedger(index);
         age = ledgerMaster_->getValidatedLedgerAge();
         mode = netOPs_->getOperatingMode();
-        lock.lock();
+
+        numMissing =
+            lowerBound == 0 ? 0 : ledgerMaster_->missingFromCompleteLedgerRange(lowerBound, index);
+
+        buildingIndex = (numMissing == 1 && !haveIndex);
+    };
+
+    // Tracked server status properties
+    LedgerIndex index = 0;
+    bool buildingIndex = false;
+    std::chrono::seconds age;
+    OperatingMode mode = OperatingMode::DISCONNECTED;
+    std::size_t numMissing = 0;
+
+    std::unique_lock lock(mutex_);
+
+    auto const waitTime = recoveryWaitTime_;
+    auto const ageThreshold = ageThreshold_;
+    {
+        auto const lowerBound = lastGoodValidatedLedger_;
+
+        ScopeUnlock const unlock(lock);
+
+        readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock);
+    }
+    // If index gets past this point without the health check succeeding, return
+    // HealthWait::Expired. This depends on index being initialized, so it must be after
+    // readServerStatus().
+    auto const lastSuccess = lastSuccessfulHealthCheck_ == 0 ? index : lastSuccessfulHealthCheck_;
+    auto const circuitBreaker = lastSuccess + maxWaitingLedgers_;
+
+    auto healthy = [&] {
+        // Special case: If the server is disconnected, it's not doing any ledger I/O, because
+        // it's focused on trying to get peers. A disconnected state is should never be caused by
+        // the activity of the server. It's usually limited to hardware or connectivity issues. Take
+        // advantage of that to run as much rotation I/O as possible before it comes back online.
+        if (mode == OperatingMode::DISCONNECTED)
+            return true;
+        if (age > ageThreshold)
+            return false;
+        if (numMissing > 0)
+            return false;
+        if (mode != OperatingMode::FULL)
+            return false;
+        return true;
+    };
+
+    while (!stop_ && !healthy() && index < circuitBreaker)
+    {
+        // Future-proofing: this value shouldn't change while we are sleeping, but grab it while we
+        // have the lock in case it does.
+        auto const lowerBound = lastGoodValidatedLedger_;
+
+        ScopeUnlock const unlock(lock);
+
+        auto const [stream, waitMs] = std::invoke(
+            [mode, age, ageThreshold, buildingIndex, waitTime, index, lastSuccess, this]
+            -> std::pair {
+                if (mode != OperatingMode::FULL || age > ageThreshold ||
+                    (index - lastSuccess > maxWaitingLedgers_ / 4))
+                    return {journal_.warn(), waitTime};
+                if (buildingIndex)
+                {
+                    // We expect this ledger to be built soon, so log at a lower level, and don't
+                    // wait as long.
+                    return {
+                        journal_.trace(),
+                        std::chrono::duration_cast(waitTime) / 10};
+                }
+                return {journal_.info(), waitTime};
+            });
+        JLOG(stream) << "Waiting " << waitMs.count() << "ms for node to stabilize. state: "
+                     << app_.getOPs().strOperatingMode(mode, false) << ". age " << age.count()
+                     << "s. Missing ledgers: " << numMissing << ". Expect: " << lowerBound << "-"
+                     << index << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers();
+        std::this_thread::sleep_for(waitMs);
+
+        [[maybe_unused]]
+        LedgerIndex const lastLedger = index;
+        readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock);
+        SOMETIMES(
+            index > lastLedger, "SHAMapStoreImp::healthWait : validated ledger index changed");
     }
 
-    return stop_ ? HealthResult::Stopping : HealthResult::KeepGoing;
+    auto const result = std::invoke([index, circuitBreaker, this]() -> HealthResult {
+        if (stop_)
+            return HealthResult::Stopping;
+        if (index < circuitBreaker)
+            return HealthResult::KeepGoing;
+        JLOG(journal_.error()) << "online_delete rotation has been unable to make progress for "
+                               << maxWaitingLedgers_ << " ledgers. "
+                               << "validated ledger index: " << index
+                               << ", last successful health check index: "
+                               << lastSuccessfulHealthCheck_
+                               << ", circuit breaker index: " << circuitBreaker;
+        return HealthResult::Expired;
+    });
+
+    XRPL_ASSERT(lock.owns_lock(), "SHAMapStoreImp::healthWait : lock held");
+    if (result == HealthResult::KeepGoing)
+        lastSuccessfulHealthCheck_ = index;
+
+    return result;
 }
 
 void
diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h
index 8a1b7504b9..c1e9199665 100644
--- a/src/xrpld/app/misc/SHAMapStoreImp.h
+++ b/src/xrpld/app/misc/SHAMapStoreImp.h
@@ -88,6 +88,13 @@ private:
     std::thread thread_;
     bool stop_ = false;
     bool healthy_ = true;
+    // Used to prevent ledger gaps from forming during online deletion. Keeps
+    // track of the last validated ledger that was processed without gaps. There
+    // are no guarantees about gaps while online delete is not running. For
+    // that, use advisory_delete and check for gaps externally.
+    LedgerIndex lastGoodValidatedLedger_ = 0;
+    // Used to prevent the circuit breaker from tripping too quickly.
+    LedgerIndex lastSuccessfulHealthCheck_ = 0;
     mutable std::condition_variable cond_;
     mutable std::condition_variable rendezvous_;
     mutable std::mutex mutex_;
@@ -102,12 +109,18 @@ private:
     std::chrono::milliseconds backOff_{100};
     std::chrono::seconds ageThreshold_{60};
     /**
-     * If  the node is out of sync during an online_delete healthWait()
-     * call, sleep the thread for this time, and continue checking until
-     * recovery.
+     * If the node is out of sync, or any recent ledgers are not
+     * available during an online_delete healthWait() call, sleep
+     * the thread for this time, and continue checking until recovery.
      * See also: "recovery_wait_seconds" in xrpld-example.cfg
      */
-    std::chrono::seconds recoveryWaitTime_{5};
+    std::chrono::seconds recoveryWaitTime_{2};
+    /**
+     * If the rotation stays "unhealthy" for a very long time, the process is aborted, and tried
+     * again later. This value represents the number of ledgers that must be validated without
+     * making rotation progress before the process is aborted.
+     */
+    std::uint32_t maxWaitingLedgers_ = deleteBatch_;
 
     // these do not exist upon SHAMapStore creation, but do exist
     // as of run() or before
@@ -163,8 +176,9 @@ public:
     void
     onLedgerClosed(std::shared_ptr const& ledger) override;
 
-    void
-    rendezvous() const override;
+    [[nodiscard]]
+    bool
+    rendezvous(std::optional const& timeout = {}) const override;
     int
     fdRequired() const override;
 
@@ -192,7 +206,7 @@ private:
         for (auto const& key : cache.getKeys())
         {
             dbRotating_->fetchNodeObject(key, 0, node_store::FetchType::Synchronous, true);
-            if (!(++check % checkHealthInterval_) && healthWait() == HealthResult::Stopping)
+            if (!(++check % checkHealthInterval_) && healthWait() != HealthResult::KeepGoing)
                 return true;
         }
 
@@ -220,11 +234,11 @@ private:
     /**
      * This is a health check for online deletion that waits until xrpld is
      * stable before returning. It returns an indication of whether the server
-     * is stopping.
+     * is stopping, or if this attempt should be abandoned.
      *
      * @return Whether the server is stopping.
      */
-    enum class HealthResult { Stopping, KeepGoing };
+    enum class HealthResult { Stopping, Expired, KeepGoing };
     [[nodiscard]] HealthResult
     healthWait();
 
diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h
index 16f7ea8e43..1912b0512e 100644
--- a/src/xrpld/rpc/BookChanges.h
+++ b/src/xrpld/rpc/BookChanges.h
@@ -7,6 +7,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -18,6 +19,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 
@@ -50,6 +52,36 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
             std::optional>>  // optional: domain id
         tally;
 
+    // Accumulating volume can exceed what the asset can represent, and the two
+    // types fail differently: STAmount's IOU addition throws, while its MPT
+    // addition is a raw int64 add that wraps past kMaxMpTokenAmount to a
+    // negative amount. Reject both so that one extreme crossing cannot poison
+    // this ledger's report, which is otherwise permanent -- the ledger is
+    // immutable and the computation deterministic.
+    auto const checkedAdd = [](STAmount& acc, STAmount const& delta) {
+        return acc.asset().visit(
+            [&](Issue const&) {
+                try
+                {
+                    acc += delta;
+                }
+                catch (std::overflow_error const&)
+                {
+                    return false;
+                }
+                return true;
+            },
+            [&](MPTIssue const&) {
+                // Both volumes are non-negative by the time they reach the
+                // tally, so this cannot underflow.
+                auto const room = static_cast(kMaxMpTokenAmount) - acc.mpt().value();
+                if (delta.mpt().value() > room)
+                    return false;
+                acc += delta;
+                return true;
+            });
+    };
+
     for (auto& tx : lpAccepted->txs)
     {
         if (!tx.first || !tx.second || !tx.first->isFieldPresent(sfTransactionType))
@@ -123,7 +155,16 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
             if (second == beast::kZero)
                 continue;
 
-            STAmount const rate = divide(first, second, noIssue());
+            std::optional maybeRate;
+            try
+            {
+                maybeRate = divide(first, second, noIssue());
+            }
+            catch (std::overflow_error const&)
+            {
+                continue;
+            }
+            STAmount const rate = *maybeRate;
 
             if (first < beast::kZero)
                 first = -first;
@@ -161,8 +202,15 @@ computeBookChanges(std::shared_ptr const& lpAccepted)
                 // increment volume
                 auto& entry = tally[key];
 
-                std::get<0>(entry) += first;   // side A vol
-                std::get<1>(entry) += second;  // side B vol
+                // Commit both sides or neither, so an overflow on the second
+                // cannot leave the entry half-updated. Skipping the crossing
+                // matches how an unrepresentable rate is handled above.
+                STAmount volA = std::get<0>(entry);
+                STAmount volB = std::get<1>(entry);
+                if (!checkedAdd(volA, first) || !checkedAdd(volB, second))
+                    continue;
+                std::get<0>(entry) = volA;  // side A vol
+                std::get<1>(entry) = volB;  // side B vol
 
                 if (std::get<2>(entry) < rate)  // high
                     std::get<2>(entry) = rate;
diff --git a/src/xrpld/rpc/detail/AccountAssets.cpp b/src/xrpld/rpc/detail/AccountAssets.cpp
index 67b9174fe3..0e71836b74 100644
--- a/src/xrpld/rpc/detail/AccountAssets.cpp
+++ b/src/xrpld/rpc/detail/AccountAssets.cpp
@@ -49,7 +49,7 @@ accountSourceAssets(
     {
         for (auto const& rspEntry : *mpts)
         {
-            if (!rspEntry.isZeroBalance() && !rspEntry.isMaxedOut())
+            if (rspEntry.canSend(account))
                 assets.insert(rspEntry.getMptID());
         }
     }
@@ -86,8 +86,10 @@ accountDestAssets(
     {
         for (auto const& rspEntry : *mpts)
         {
-            if (rspEntry.isZeroBalance() && !rspEntry.isMaxedOut())
-                assets.insert(rspEntry.getMptID());
+            // Any cached MPT entry means this account already has an issuance
+            // or MPToken object. A maxed-out issuance does not prevent
+            // receiving existing MPT from another holder.
+            assets.insert(rspEntry.getMptID());
         }
     }
 
diff --git a/src/xrpld/rpc/detail/MPT.h b/src/xrpld/rpc/detail/MPT.h
index 93c8517539..68054b2d0b 100644
--- a/src/xrpld/rpc/detail/MPT.h
+++ b/src/xrpld/rpc/detail/MPT.h
@@ -1,5 +1,7 @@
 #pragma once
 
+#include 
+#include 
 #include 
 
 namespace xrpl {
@@ -31,14 +33,11 @@ public:
         return mptID_;
     }
     [[nodiscard]] bool
-    isZeroBalance() const
+    canSend(AccountID const& account) const
     {
-        return zeroBalance_;
-    }
-    [[nodiscard]] bool
-    isMaxedOut() const
-    {
-        return maxedOut_;
+        // A maxed-out issuance only prevents the issuer from creating more
+        // MPT. Holders can still send existing balances.
+        return account == getMPTIssuer(mptID_) ? !maxedOut_ : !zeroBalance_;
     }
 };
 
diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp
index 9d9c1e290c..827393b071 100644
--- a/src/xrpld/rpc/detail/PathRequest.cpp
+++ b/src/xrpld/rpc/detail/PathRequest.cpp
@@ -420,20 +420,22 @@ PathRequest::parseJson(json::Value const& jvParams)
                 // If the assets don't match, ignore the source asset.
                 if (srcPathAsset == saSendMax_->asset())
                 {
-                    // If neither is the source and they are not equal, then the
-                    // source issuer is illegal.
-                    if (srcIssuerID != *raSrcAccount_ &&
-                        saSendMax_->getIssuer() != *raSrcAccount_ &&
-                        srcIssuerID != saSendMax_->getIssuer())
-                    {
-                        jvStatus_ = rpcError(RpcSrcIsrMalformed);
-                        return PFR_PJ_INVALID;
-                    }
-
-                    // If both are the source, use the source.
-                    // Otherwise, use the one that's not the source.
-                    srcPathAsset.visit(
+                    auto const status = srcPathAsset.visit(
                         [&](Currency const& currency) {
+                            // If neither is the source and they are not equal,
+                            // then the source issuer is illegal. srcIssuerID
+                            // comes from the optional IOU source_currencies
+                            // issuer field, so this reconciliation is IOU-only.
+                            if (srcIssuerID != *raSrcAccount_ &&
+                                saSendMax_->getIssuer() != *raSrcAccount_ &&
+                                srcIssuerID != saSendMax_->getIssuer())
+                            {
+                                jvStatus_ = rpcError(RpcSrcIsrMalformed);
+                                return PFR_PJ_INVALID;
+                            }
+
+                            // If both are the source, use the source.
+                            // Otherwise, use the one that's not the source.
                             if (srcIssuerID != *raSrcAccount_)
                             {
                                 sciSourceAssets_.insert(Issue{currency, srcIssuerID});
@@ -442,11 +444,18 @@ PathRequest::parseJson(json::Value const& jvParams)
                             {
                                 sciSourceAssets_.insert(Issue{currency, saSendMax_->getIssuer()});
                             }
+                            else
                             {
                                 sciSourceAssets_.insert(Issue{currency, *raSrcAccount_});
                             }
+                            return PFR_PJ_NOCHANGE;
                         },
-                        [&](MPTID const& mpt) { sciSourceAssets_.insert(mpt); });
+                        [&](MPTID const& mpt) {
+                            sciSourceAssets_.insert(mpt);
+                            return PFR_PJ_NOCHANGE;
+                        });
+                    if (status == PFR_PJ_INVALID)
+                        return status;
                 }
             }
             else
diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp
index 642b5c4253..1f530a1165 100644
--- a/src/xrpld/rpc/detail/Pathfinder.cpp
+++ b/src/xrpld/rpc/detail/Pathfinder.cpp
@@ -224,7 +224,7 @@ Pathfinder::Pathfinder(
     , dstAmount_(saDstAmount)
     , srcPathAsset_(uSrcPathAsset)
     , srcIssuer_(uSrcIssuer)
-    , srcAmount_(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount))
+    , srcAmount_(srcAmount.value_or(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount)))
     , convertAll_(convertAllCheck(dstAmount_))
     , domain_(domain)
     , ledger_(cache->getLedger())
@@ -815,8 +815,8 @@ Pathfinder::getPathsOut(
                 {
                     for (auto const& mpt : *mpts)
                     {
-                        if (pathAsset.get() != mpt.getMptID() || mpt.isZeroBalance() ||
-                            mpt.isMaxedOut() || bAuthRequired)
+                        if (pathAsset.get() != mpt.getMptID() || !mpt.canSend(account) ||
+                            bAuthRequired)
                             continue;
                         if (isDstAsset && dstAccount == getMPTIssuer(mpt))
                         {
@@ -1079,7 +1079,10 @@ Pathfinder::addLink(
                             }
                             if constexpr (kIsMpt)
                             {
-                                return asset.isZeroBalance() || asset.isMaxedOut() ||
+                                // `asset` came from uEndAccount's cached MPTs.
+                                // `acct` is the next issuer hop, not the
+                                // account whose balance is being tested.
+                                return !asset.canSend(uEndAccount) ||
                                     requireAuth(*ledger_, MPTIssue{asset}, acct);
                             }
                         };
diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp
index c216192ab3..0aa5334bd2 100644
--- a/src/xrpld/rpc/handlers/VaultInfo.cpp
+++ b/src/xrpld/rpc/handlers/VaultInfo.cpp
@@ -26,36 +26,48 @@ parseVault(json::Value const& params, json::Value& jvResult)
     uint256 uNodeIndex = beast::kZero;
     if (hasVaultId && !hasOwner && !hasSeq)
     {
-        if (!uNodeIndex.parseHex(params[jss::vault_id].asString()))
+        // asString() throws on an object or an array, so the type comes first.
+        auto const& vaultId = params[jss::vault_id];
+        if (!vaultId.isString() || !uNodeIndex.parseHex(vaultId.asString()))
         {
-            rpc::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcInvalidParams, rpc::expectedFieldMessage(jss::vault_id, "hex string"), jvResult);
             return std::nullopt;
         }
         // else uNodeIndex holds the value we need
     }
     else if (!hasVaultId && hasOwner && hasSeq)
     {
-        auto const id = parseBase58(params[jss::owner].asString());
+        auto const& owner = params[jss::owner];
+        auto const id = owner.isString() ? parseBase58(owner.asString())
+                                         : std::optional{};
         if (!id)
         {
-            rpc::injectError(RpcActMalformed, jvResult);
-            return std::nullopt;
-        }
-        if (!(params[jss::seq].isInt() || params[jss::seq].isUInt()) ||
-            params[jss::seq].asDouble() <= 0.0 ||
-            params[jss::seq].asDouble() > double(json::Value::kMaxUInt))
-        {
-            rpc::injectError(RpcInvalidParams, jvResult);
+            rpc::injectError(
+                RpcActMalformed, rpc::expectedFieldMessage(jss::owner, "AccountID"), jvResult);
             return std::nullopt;
         }
 
-        auto const seq = SeqProxy::rawSequence(params[jss::seq].asUInt());
+        // Int and UInt are both 32 bits wide, so the type check is the only upper bound needed.
+        auto const& seqField = params[jss::seq];
+        if (!(seqField.isInt() || seqField.isUInt()) || seqField.asDouble() <= 0.0)
+        {
+            rpc::injectError(
+                RpcInvalidParams,
+                rpc::expectedFieldMessage(jss::seq, "a positive 32-bit integer"),
+                jvResult);
+            return std::nullopt;
+        }
+
+        auto const seq = SeqProxy::rawSequence(seqField.asUInt());
         uNodeIndex = keylet::vault(*id, seq).key;
     }
     else
     {
-        // Invalid combination of fields vault_id/owner/seq
-        rpc::injectError(RpcInvalidParams, jvResult);
+        rpc::injectError(
+            RpcInvalidParams,
+            "Must specify either 'vault_id' or both 'owner' and 'seq'.",
+            jvResult);
         return std::nullopt;
     }
 
@@ -71,20 +83,25 @@ doVaultInfo(rpc::JsonContext& context)
     if (!lpLedger)
         return jvResult;
 
-    auto const uNodeIndex = parseVault(context.params, jvResult).value_or(beast::kZero);
-    if (uNodeIndex == beast::kZero)
+    // No key means the request could not be turned into one, and parseVault has already said why.
+    auto const uNodeIndex = parseVault(context.params, jvResult);
+    if (!uNodeIndex)
+        return jvResult;
+
+    // A zero key names an entry that cannot exist, and the ledger refuses to be asked for one.
+    if (*uNodeIndex == beast::kZero)
     {
-        jvResult[jss::error] = "malformedRequest";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
-    auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex));
+    auto const sleVault = lpLedger->read(keylet::vault(*uNodeIndex));
     auto const sleIssuance = sleVault == nullptr  //
         ? nullptr
         : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
     if (!sleVault || !sleIssuance)
     {
-        jvResult[jss::error] = "entryNotFound";
+        rpc::injectError(RpcEntryNotFound, jvResult);
         return jvResult;
     }
 
diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
index 6b244af1a9..2276f98a0e 100644
--- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp
+++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -30,6 +31,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl {
@@ -115,29 +117,37 @@ doAccountInfo(rpc::JsonContext& context)
     }
     auto const accountID{id.value()};
 
-    static constexpr std::array, 9> kLsFlags{
-        {{"defaultRipple", lsfDefaultRipple},
-         {"depositAuth", lsfDepositAuth},
-         {"disableMasterKey", lsfDisableMaster},
-         {"disallowIncomingXRP", lsfDisallowXRP},
-         {"globalFreeze", lsfGlobalFreeze},
-         {"noFreeze", lsfNoFreeze},
-         {"passwordSpent", lsfPasswordSpent},
-         {"requireAuthorization", lsfRequireAuth},
-         {"requireDestinationTag", lsfRequireDestTag}}};
-
-    static constexpr std::array, 4>
-        kDisallowIncomingFlags{
-            {{"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
+    // Flags that are always reported.
+    static constexpr auto kAccountRootFlags =
+        std::to_array>(
+            {{"allowTrustLineClawback", lsfAllowTrustLineClawback},
+             {"defaultRipple", lsfDefaultRipple},
+             {"depositAuth", lsfDepositAuth},
+             {"disableMasterKey", lsfDisableMaster},
              {"disallowIncomingCheck", lsfDisallowIncomingCheck},
+             {"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer},
              {"disallowIncomingPayChan", lsfDisallowIncomingPayChan},
-             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}}};
+             {"disallowIncomingTrustline", lsfDisallowIncomingTrustline},
+             {"disallowIncomingXRP", lsfDisallowXRP},
+             {"globalFreeze", lsfGlobalFreeze},
+             {"noFreeze", lsfNoFreeze},
+             {"passwordSpent", lsfPasswordSpent},
+             {"requireAuthorization", lsfRequireAuth},
+             {"requireDestinationTag", lsfRequireDestTag}});
 
-    static constexpr std::pair kAllowTrustLineClawbackFlag{
-        "allowTrustLineClawback", lsfAllowTrustLineClawback};
+    // Flags that are only reported when their amendment is enabled. This can't be `constexpr`,
+    // since the amendment IDs are computed at runtime.
+    static auto const kAmendmentGatedFlags =
+        std::to_array>(
+            {{"allowTrustLineLocking", lsfAllowTrustLineLocking, featureTokenEscrow}});
 
-    static constexpr std::pair kAllowTrustLineLockingFlag{
-        "allowTrustLineLocking", lsfAllowTrustLineLocking};
+    // Every `AccountRoot` flag must be reported by `account_info`, so if a new flag is added, it
+    // needs to be added to one of the arrays above. This can't be a `static_assert` because
+    // `getAccountRootFlags()` builds its map at runtime.
+    XRPL_ASSERT_PARTS(
+        kAccountRootFlags.size() + kAmendmentGatedFlags.size() == getAccountRootFlags().size(),
+        "xrpl::doAccountInfo",
+        "number of account flags");
 
     auto const sleAccepted = ledger->read(keylet::account(accountID));
     if (sleAccepted)
@@ -157,19 +167,13 @@ doAccountInfo(rpc::JsonContext& context)
         result[jss::account_data] = jvAccepted;
 
         json::Value acctFlags{json::ValueType::Object};
-        for (auto const& lsf : kLsFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
+        for (auto const& [name, flag] : kAccountRootFlags)
+            acctFlags[name.data()] = sleAccepted->isFlag(flag);
 
-        for (auto const& lsf : kDisallowIncomingFlags)
-            acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second);
-
-        acctFlags[kAllowTrustLineClawbackFlag.first.data()] =
-            sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second);
-
-        if (ledger->rules().enabled(featureTokenEscrow))
+        for (auto const& [name, flag, amendment] : kAmendmentGatedFlags)
         {
-            acctFlags[kAllowTrustLineLockingFlag.first.data()] =
-                sleAccepted->isFlag(kAllowTrustLineLockingFlag.second);
+            if (ledger->rules().enabled(amendment))
+                acctFlags[name.data()] = sleAccepted->isFlag(flag);
         }
 
         result[jss::account_flags] = std::move(acctFlags);